Skip to content
Open
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
21 changes: 19 additions & 2 deletions docs/google-sync-and-websocket-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,25 @@ Revocation and reconnect are handled across auth, sync, websocket, and repositor
1. Backend detects missing/invalid Google refresh token (middleware, sync, or Google API error handling).
2. Backend prunes Google-origin data and emits `GOOGLE_REVOKED`.
3. Web app marks Google as revoked in session memory and temporarily switches to local repository behavior.
4. OAuth connect while a session exists triggers reconnect logic (`reconnectGoogleForSession`) instead of normal signup/signin.
5. Reconnect updates Google credentials, marks metadata sync flags as `"restart"`, and restarts sync in background.
4. User initiates re-consent via OAuth flow.
5. Backend auth handler (`handleGoogleAuth`) determines auth mode server-side using:
- User existence (via `findCompassUserBy`)
- Refresh token presence (`user.google.gRefreshToken`)
- Sync health (`canDoIncrementalSync`)
6. If user exists but refresh token is missing or sync is unhealthy → `reconnect_repair` path via `repairGoogleConnection()`.
7. Reconnect updates Google credentials, marks metadata sync flags as `"restart"`, and restarts sync in background.

### Auth Mode Classification

The backend determines auth mode based on server-side state, not frontend intent:

| Condition | Auth Mode | Handler |
| ----------------------------------------------------- | -------------------- | -------------------------- |
| No linked Compass user | `signup` | `googleSignup()` |
| User exists + missing refresh token OR unhealthy sync | `reconnect_repair` | `repairGoogleConnection()` |
| User exists + valid refresh token + healthy sync | `signin_incremental` | `googleSignin()` |

Note: The `googleAuthIntent` field from frontend is deprecated and no longer authoritative for routing.

Primary files:

Expand Down
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
34 changes: 17 additions & 17 deletions packages/backend/src/auth/services/compass.auth.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Credentials } from "google-auth-library";
import type { Credentials } from "google-auth-library";
import { faker } from "@faker-js/faker";
import { UserDriver } from "@backend/__tests__/drivers/user.driver";
import {
Expand All @@ -18,10 +18,10 @@ describe("CompassAuthService", () => {
beforeEach(cleanupCollections);
afterAll(cleanupTestDb);

describe("reconnectGoogleForSession", () => {
it("relinks Google to the current Compass user and schedules a full reimport", async () => {
describe("repairGoogleConnection", () => {
it("relinks Google to the Compass user and schedules a full reimport", async () => {
const user = await UserDriver.createUser();
const sessionUserId = user._id.toString();
const compassUserId = user._id.toString();
const gUser = UserDriver.generateGoogleUser({
sub: faker.string.uuid(),
picture: faker.image.url(),
Expand All @@ -34,35 +34,35 @@ describe("CompassAuthService", () => {
.spyOn(userService, "restartGoogleCalendarSync")
.mockResolvedValue();

await userService.pruneGoogleData(sessionUserId);
await userService.pruneGoogleData(compassUserId);

const result = await compassAuthService.reconnectGoogleForSession(
sessionUserId,
const result = await compassAuthService.repairGoogleConnection(
compassUserId,
gUser,
oAuthTokens,
);

const updatedUser = await mongoService.user.findOne({ _id: user._id });
const metadata =
await userMetadataService.fetchUserMetadata(sessionUserId);
await userMetadataService.fetchUserMetadata(compassUserId);

expect(result).toEqual({ cUserId: sessionUserId });
expect(updatedUser?._id.toString()).toBe(sessionUserId);
expect(result).toEqual({ cUserId: compassUserId });
expect(updatedUser?._id.toString()).toBe(compassUserId);
expect(updatedUser?.google?.googleId).toBe(gUser.sub);
expect(updatedUser?.google?.picture).toBe(gUser.picture);
expect(updatedUser?.google?.gRefreshToken).toBe(
oAuthTokens.refresh_token,
);
expect(metadata.sync?.importGCal).toBe("restart");
expect(metadata.sync?.incrementalGCalSync).toBe("restart");
expect(restartSpy).toHaveBeenCalledWith(sessionUserId);
expect(restartSpy).toHaveBeenCalledWith(compassUserId);

restartSpy.mockRestore();
});

it("returns after persisting reconnect state even if the background sync fails", async () => {
const user = await UserDriver.createUser();
const sessionUserId = user._id.toString();
const compassUserId = user._id.toString();
const gUser = UserDriver.generateGoogleUser({
sub: faker.string.uuid(),
picture: faker.image.url(),
Expand All @@ -76,19 +76,19 @@ describe("CompassAuthService", () => {
.spyOn(userService, "restartGoogleCalendarSync")
.mockRejectedValue(restartError);

await userService.pruneGoogleData(sessionUserId);
await userService.pruneGoogleData(compassUserId);

await expect(
compassAuthService.reconnectGoogleForSession(
sessionUserId,
compassAuthService.repairGoogleConnection(
compassUserId,
gUser,
oAuthTokens,
),
).resolves.toEqual({ cUserId: sessionUserId });
).resolves.toEqual({ cUserId: compassUserId });

await Promise.resolve();

expect(restartSpy).toHaveBeenCalledWith(sessionUserId);
expect(restartSpy).toHaveBeenCalledWith(compassUserId);

restartSpy.mockRestore();
});
Expand Down
41 changes: 12 additions & 29 deletions packages/backend/src/auth/services/compass.auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,10 @@ import { parseReconnectGoogleParams } from "@backend/auth/schemas/reconnect-goog
import GoogleAuthService from "@backend/auth/services/google/google.auth.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";
import syncService from "@backend/sync/services/sync.service";
import { getSync } from "@backend/sync/util/sync.queries";
import { canDoIncrementalSync } from "@backend/sync/util/sync.util";
import { findCompassUserBy } from "@backend/user/queries/user.queries";
import userMetadataService from "@backend/user/services/user-metadata.service";
import userService from "@backend/user/services/user.service";

Expand All @@ -31,28 +27,6 @@ class CompassAuthService {
});
};

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

if (!user) {
return { authMethod: "signup", user: null };
}
const userId = user._id.toString();

const sync = await getSync({ userId });
if (!sync) {
throw error(
SyncError.NoSyncRecordForUser,
"Did not verify sync record for user",
);
}

const canLogin = canDoIncrementalSync(sync);
const authMethod = user && canLogin ? "login" : "signup";

return { authMethod, user };
};

createSessionForUser = async (cUserId: string) => {
const userId = cUserId;
const sUserId = supertokens.convertToRecipeUserId(cUserId);
Expand Down Expand Up @@ -126,16 +100,25 @@ class CompassAuthService {
return user;
}

async reconnectGoogleForSession(
sessionUserId: string,
/**
* Repairs a user's Google connection after revocation or disconnection.
* This method is called when the user has an existing Compass account but
* their refresh token is missing or their sync state is unhealthy.
*
* @param compassUserId - The Compass user ID (not session-based)
* @param gUser - Google user info from OAuth
* @param oAuthTokens - Fresh OAuth tokens from re-consent
*/
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
Loading
Loading