Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/backend/src/auth/schemas/reconnect-google.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ export type ParsedReconnectGoogleParams = {
};

export function parseReconnectGoogleParams(
sessionUserId: string,
compassUserId: string,
gUser: TokenPayload,
oAuthTokens: Pick<Credentials, "refresh_token" | "access_token">,
): ParsedReconnectGoogleParams {
const cUserId = zObjectId
.parse(sessionUserId, { error: () => "Invalid credentials" })
.parse(compassUserId, { error: () => "Invalid credentials" })
.toString();
StringV4Schema.parse(gUser.sub, { error: () => "Invalid Google user ID" });
const refreshToken = StringV4Schema.parse(oAuthTokens.refresh_token, {
Expand Down
176 changes: 173 additions & 3 deletions packages/backend/src/auth/services/compass.auth.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { type Credentials } from "google-auth-library";
import { ObjectId } from "mongodb";
import { faker } from "@faker-js/faker";
import { Resource_Sync } from "@core/types/sync.types";
import { WatchSchema } from "@core/types/watch.types";
import { UserDriver } from "@backend/__tests__/drivers/user.driver";
import {
cleanupCollections,
Expand All @@ -8,17 +11,146 @@ import {
} from "@backend/__tests__/helpers/mock.db.setup";
import { initSupertokens } from "@backend/common/middleware/supertokens.middleware";
import mongoService from "@backend/common/services/mongo.service";
import syncService from "@backend/sync/services/sync.service";
import { updateSync } from "@backend/sync/util/sync.queries";
import userMetadataService from "@backend/user/services/user-metadata.service";
import userService from "@backend/user/services/user.service";
import compassAuthService from "./compass.auth.service";

const buildGoogleSignInSuccess = (userId: string, googleId: string) => ({
providerUser: UserDriver.generateGoogleUser({ sub: googleId }),
oAuthTokens: {
access_token: faker.internet.jwt(),
refresh_token: faker.string.uuid(),
} as Pick<Credentials, "access_token" | "refresh_token">,
createdNewRecipeUser: false,
recipeUserId: userId,
loginMethodsLength: 1,
sessionUserId: null,
});

const createActiveWatch = async (userId: string, gCalendarId: string) => {
await mongoService.watch.insertOne(
WatchSchema.parse({
_id: new ObjectId(),
user: userId,
resourceId: faker.string.uuid(),
expiration: new Date(Date.now() + 60_000),
gCalendarId,
createdAt: new Date(),
}),
);
};

describe("CompassAuthService", () => {
beforeAll(initSupertokens);
beforeEach(setupTestDb);
beforeEach(cleanupCollections);
afterAll(cleanupTestDb);

describe("reconnectGoogleForSession", () => {
describe("determineGoogleAuthMode", () => {
it("returns reconnect_repair when the stored refresh token is missing", async () => {
const user = await UserDriver.createUser({
withGoogleRefreshToken: false,
});
const success = buildGoogleSignInSuccess(
user._id.toString(),
user.google?.googleId ?? faker.string.uuid(),
);

const result = await compassAuthService.determineGoogleAuthMode(success);

expect(result).toMatchObject({
authMode: "reconnect_repair",
cUserId: user._id.toString(),
hasStoredRefreshTokenBefore: false,
isReconnectRepair: true,
});
});

it("returns signin_incremental when the user has a healthy incremental sync", async () => {
const user = await UserDriver.createUser();
const userId = user._id.toString();
await updateSync(Resource_Sync.CALENDAR, userId, user.email, {
nextSyncToken: faker.string.uuid(),
});
await updateSync(Resource_Sync.EVENTS, userId, user.email, {
nextSyncToken: faker.string.uuid(),
});
await createActiveWatch(userId, Resource_Sync.CALENDAR);
await createActiveWatch(userId, user.email);
await userMetadataService.updateUserMetadata({
userId,
data: {
sync: { importGCal: "completed", incrementalGCalSync: "completed" },
},
});

const result = await compassAuthService.determineGoogleAuthMode(
buildGoogleSignInSuccess(
userId,
user.google?.googleId ?? faker.string.uuid(),
),
);

expect(result).toMatchObject({
authMode: "signin_incremental",
cUserId: userId,
hasStoredRefreshTokenBefore: true,
isReconnectRepair: false,
});
});

it("returns signup for a brand-new Google user", async () => {
const newUserId = faker.database.mongodbObjectId();

const result = await compassAuthService.determineGoogleAuthMode({
providerUser: UserDriver.generateGoogleUser(),
oAuthTokens: {
access_token: faker.internet.jwt(),
refresh_token: faker.string.uuid(),
},
createdNewRecipeUser: true,
recipeUserId: newUserId,
loginMethodsLength: 1,
sessionUserId: null,
});

expect(result).toMatchObject({
authMode: "signup",
cUserId: newUserId,
hasStoredRefreshTokenBefore: false,
isReconnectRepair: false,
});
});

it("returns reconnect_repair when sync state is not incremental-ready", async () => {
const user = await UserDriver.createUser();
const userId = user._id.toString();
await userMetadataService.updateUserMetadata({
userId,
data: {
sync: { importGCal: "completed", incrementalGCalSync: "completed" },
},
});

const result = await compassAuthService.determineGoogleAuthMode(
buildGoogleSignInSuccess(
userId,
user.google?.googleId ?? faker.string.uuid(),
),
);

expect(result).toMatchObject({
authMode: "reconnect_repair",
cUserId: userId,
hasStoredRefreshTokenBefore: true,
isReconnectRepair: true,
});
});
});

describe("repairGoogleConnection", () => {
it("relinks Google to the current Compass user and schedules a full reimport", async () => {
const user = await UserDriver.createUser();
const sessionUserId = user._id.toString();
Expand All @@ -36,7 +168,7 @@ describe("CompassAuthService", () => {

await userService.pruneGoogleData(sessionUserId);

const result = await compassAuthService.reconnectGoogleForSession(
const result = await compassAuthService.repairGoogleConnection(
sessionUserId,
gUser,
oAuthTokens,
Expand Down Expand Up @@ -79,7 +211,7 @@ describe("CompassAuthService", () => {
await userService.pruneGoogleData(sessionUserId);

await expect(
compassAuthService.reconnectGoogleForSession(
compassAuthService.repairGoogleConnection(
sessionUserId,
gUser,
oAuthTokens,
Expand All @@ -93,4 +225,42 @@ describe("CompassAuthService", () => {
restartSpy.mockRestore();
});
});

describe("googleSignin", () => {
it("queues a full repair instead of incremental sync when the user was revoked", async () => {
const user = await UserDriver.createUser();
const userId = user._id.toString();
const restartSpy = jest
.spyOn(userService, "restartGoogleCalendarSync")
.mockResolvedValue();
const incrementalSpy = jest
.spyOn(syncService, "importIncremental")
.mockResolvedValue(undefined as never);
const oAuthTokens: Pick<Credentials, "access_token" | "refresh_token"> = {
access_token: faker.internet.jwt(),
refresh_token: faker.string.uuid(),
};

await userService.pruneGoogleData(userId);

const result = await compassAuthService.googleSignin(
UserDriver.generateGoogleUser({
sub: user.google?.googleId,
picture: faker.image.url(),
}),
oAuthTokens,
);

const metadata = await userMetadataService.fetchUserMetadata(userId);

expect(result).toEqual({ cUserId: userId });
expect(restartSpy).toHaveBeenCalledWith(userId);
expect(incrementalSpy).not.toHaveBeenCalled();
expect(metadata.sync?.importGCal).toBe("restart");
expect(metadata.sync?.incrementalGCalSync).toBe("restart");

restartSpy.mockRestore();
incrementalSpy.mockRestore();
});
});
});
101 changes: 86 additions & 15 deletions packages/backend/src/auth/services/compass.auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import { mapCompassUserToEmailSubscriber } from "@core/mappers/subscriber/map.su
import { StringV4Schema, zObjectId } from "@core/types/type.utils";
import { parseReconnectGoogleParams } from "@backend/auth/schemas/reconnect-google.schemas";
import GoogleAuthService from "@backend/auth/services/google/google.auth.service";
import {
type GoogleAuthDecision,
type GoogleSignInSuccess,
} from "@backend/auth/services/google/google.auth.success.service";
import { ENV } from "@backend/common/constants/env.constants";
import { isMissingUserTagId } from "@backend/common/constants/env.util";
import { error } from "@backend/common/errors/handlers/error.handler";
import { SyncError } from "@backend/common/errors/sync/sync.errors";
import mongoService from "@backend/common/services/mongo.service";
import EmailService from "@backend/email/email.service";
Expand All @@ -31,26 +34,80 @@ class CompassAuthService {
});
};

determineAuthMethod = async (gUserId: string) => {
const user = await findCompassUserBy("google.googleId", gUserId);
private assessGoogleConnection = async (userId: string) => {
const user = await findCompassUserBy("_id", userId);

if (!user) {
return { authMethod: "signup", user: null };
throw new Error(
`Could not resolve Compass user for Google auth: ${userId}`,
);
}
const userId = user._id.toString();

const hasStoredRefreshTokenBefore = Boolean(user.google?.gRefreshToken);
const sync = await getSync({ userId });
if (!sync) {
throw error(
SyncError.NoSyncRecordForUser,
"Did not verify sync record for user",
const isIncrementalReady = Boolean(sync && canDoIncrementalSync(sync));
const googleMetadata =
await userMetadataService.assessGoogleMetadata(userId);
const isHealthy =
googleMetadata.connectionStatus === "connected" &&
googleMetadata.syncStatus === "healthy";

return {
hasStoredRefreshTokenBefore,
isIncrementalReady,
isHealthy,
needsRepair:
!hasStoredRefreshTokenBefore || !isIncrementalReady || !isHealthy,
};
};

determineGoogleAuthMode = async (
success: GoogleSignInSuccess,
): Promise<GoogleAuthDecision> => {
const {
createdNewRecipeUser,
loginMethodsLength,
providerUser,
recipeUserId,
sessionUserId,
} = success;
const isNewUser = createdNewRecipeUser && loginMethodsLength === 1;

if (isNewUser) {
return {
authMode: "signup",
cUserId: recipeUserId,
hasStoredRefreshTokenBefore: false,
hasSession: sessionUserId !== null,
isReconnectRepair: false,
};
}

const googleUserId = StringV4Schema.parse(providerUser.sub, {
error: () => "Invalid Google user ID",
});
const existingUser =
(await findCompassUserBy("_id", recipeUserId)) ??
(await findCompassUserBy("google.googleId", googleUserId));

if (!existingUser) {
throw new Error(
`Could not resolve Compass user for Google auth: ${recipeUserId}`,
);
}

const canLogin = canDoIncrementalSync(sync);
const authMethod = user && canLogin ? "login" : "signup";
const cUserId = existingUser._id.toString();
const assessment = await this.assessGoogleConnection(cUserId);

return { authMethod, user };
return {
authMode: assessment.needsRepair
? "reconnect_repair"
: "signin_incremental",
cUserId,
hasStoredRefreshTokenBefore: assessment.hasStoredRefreshTokenBefore,
hasSession: sessionUserId !== null,
isReconnectRepair: assessment.needsRepair,
};
};

createSessionForUser = async (cUserId: string) => {
Expand Down Expand Up @@ -126,16 +183,16 @@ class CompassAuthService {
return user;
}

async reconnectGoogleForSession(
sessionUserId: string,
async repairGoogleConnection(
compassUserId: string,
gUser: TokenPayload,
oAuthTokens: Pick<Credentials, "refresh_token" | "access_token">,
) {
const {
cUserId,
gUser: validatedGUser,
refreshToken,
} = parseReconnectGoogleParams(sessionUserId, gUser, oAuthTokens);
} = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens);

await userService.reconnectGoogleCredentials(
cUserId,
Expand Down Expand Up @@ -181,6 +238,20 @@ class CompassAuthService {
const cUserId = zObjectId
.parse(user?._id, { error: () => "Invalid credentials" })
.toString();
const assessment = await this.assessGoogleConnection(cUserId);

if (assessment.needsRepair) {
await userMetadataService.updateUserMetadata({
userId: cUserId,
data: {
sync: { importGCal: "restart", incrementalGCalSync: "restart" },
},
});

this.restartGoogleCalendarSyncInBackground(cUserId);

return { cUserId };
}

// start incremental sync - do not await
const gAuthClient = new GoogleAuthService();
Expand Down
Loading
Loading