diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEvent.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEvent.swift index 86047bc03c..9a13312a14 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEvent.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEvent.swift @@ -10,6 +10,8 @@ public enum ProductEvent: String, Codable, Hashable, Sendable, CaseIterable { case localModeSelected = "local_mode_selected" case signupCompleted = "signup_completed" case oauthRedirectStarted = "oauth_redirect_started" + case oauthCallbackReceived = "oauth_callback_received" + case oauthFailed = "oauth_failed" case oauthReturn = "oauth_return" case signupStepViewed = "signup_step_viewed" case signupFailed = "signup_failed" diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEventNativeCoverage.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEventNativeCoverage.swift index ad5604aa75..2de78a94b8 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEventNativeCoverage.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Generated/ProductEventNativeCoverage.swift @@ -20,6 +20,8 @@ public enum ProductEventNativeCoverage: Sendable { .localModeSelected, .signupCompleted, .oauthRedirectStarted, + .oauthCallbackReceived, + .oauthFailed, .oauthReturn, .signupStepViewed, .signupFailed, diff --git a/apps/calendar-web/src/auth/callback/report-auth-callback-crash.ts b/apps/calendar-web/src/auth/callback/report-auth-callback-crash.ts index 9c9a3ae759..b7b549c6ee 100644 --- a/apps/calendar-web/src/auth/callback/report-auth-callback-crash.ts +++ b/apps/calendar-web/src/auth/callback/report-auth-callback-crash.ts @@ -1,12 +1,10 @@ -import { - type SignupMethod, - trackSignupFailed, -} from "@web/auth/posthog/signup-funnel"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { trackOAuthSignupFailed } from "@web/auth/posthog/oauth-signup-funnel"; import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes"; import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; type ReportAuthCallbackCrashOptions = { - method: SignupMethod; + method: ProviderKind; errorMessage: string; replace: (path: string) => void; }; @@ -22,8 +20,9 @@ type ReportAuthCallbackCrashOptions = { export const reportAuthCallbackCrash = ({ method, errorMessage, replace }: ReportAuthCallbackCrashOptions) => (error: unknown): void => { - trackSignupFailed("oauth_callback_crashed", { - method, + trackOAuthSignupFailed({ + provider: method, + reason: "oauth_callback_crashed", error: error instanceof Error ? error.message : String(error), }); showErrorToast(errorMessage); diff --git a/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.test.ts b/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.test.ts new file mode 100644 index 0000000000..530ea3d90f --- /dev/null +++ b/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.test.ts @@ -0,0 +1,19 @@ +import { oauthFailedReasonFromSignupFailure } from "@web/auth/posthog/oauth-signup-funnel"; +import { describe, expect, it } from "bun:test"; + +describe("oauth-signup-funnel", () => { + it("maps signup failure reasons to oauth_failed reasons", () => { + expect(oauthFailedReasonFromSignupFailure("oauth_user_cancelled")).toBe( + "access_denied", + ); + expect(oauthFailedReasonFromSignupFailure("oauth_missing_state")).toBe( + "invalid_state", + ); + expect(oauthFailedReasonFromSignupFailure("oauth_missing_code")).toBe( + "missing_code", + ); + expect(oauthFailedReasonFromSignupFailure("oauth_exchange_failed")).toBe( + "token_exchange_error", + ); + }); +}); diff --git a/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts b/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts new file mode 100644 index 0000000000..0a452b94d3 --- /dev/null +++ b/apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts @@ -0,0 +1,45 @@ +import { type OAuthFailedReason } from "@core/analytics/oauth-signup.events"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { + type SignupFailureReason, + type SignupStep, + trackSignupFailed, +} from "@web/auth/posthog/signup-funnel"; +import { track } from "@web/auth/posthog/track"; + +const SIGNUP_FAILURE_TO_OAUTH_FAILED: Partial< + Record +> = { + oauth_user_cancelled: "access_denied", + oauth_missing_state: "invalid_state", + oauth_missing_intent: "invalid_state", + oauth_missing_code: "missing_code", + oauth_exchange_failed: "token_exchange_error", + oauth_missing_scopes: "other", + oauth_callback_crashed: "other", +}; + +export function oauthFailedReasonFromSignupFailure( + reason: SignupFailureReason, +): OAuthFailedReason { + return SIGNUP_FAILURE_TO_OAUTH_FAILED[reason] ?? "other"; +} + +export function trackOAuthSignupFailed(input: { + provider: ProviderKind; + reason: SignupFailureReason; + providerError?: string; + step?: SignupStep; + error?: string; +}): void { + track("oauth_failed", { + provider: input.provider, + reason: oauthFailedReasonFromSignupFailure(input.reason), + ...(input.providerError ? { provider_error: input.providerError } : {}), + }); + trackSignupFailed(input.reason, { + method: input.provider, + step: input.step, + error: input.error, + }); +} diff --git a/apps/calendar-web/src/auth/posthog/posthog-distinct-id.util.ts b/apps/calendar-web/src/auth/posthog/posthog-distinct-id.util.ts new file mode 100644 index 0000000000..e1940b5c1c --- /dev/null +++ b/apps/calendar-web/src/auth/posthog/posthog-distinct-id.util.ts @@ -0,0 +1,13 @@ +import { getPosthogClient } from "@web/auth/posthog/posthog.bootstrap"; + +/** Best-effort anon distinct id for tying server-side OAuth events to the browser. */ +export function readPosthogDistinctIdForOAuth(): string | undefined { + try { + const distinctId = getPosthogClient()?.get_distinct_id?.(); + return typeof distinctId === "string" && distinctId.length > 0 + ? distinctId + : undefined; + } catch { + return undefined; + } +} diff --git a/apps/calendar-web/src/auth/posthog/track.ts b/apps/calendar-web/src/auth/posthog/track.ts index d04890732a..5f47d9ce1c 100644 --- a/apps/calendar-web/src/auth/posthog/track.ts +++ b/apps/calendar-web/src/auth/posthog/track.ts @@ -11,6 +11,11 @@ import { getPosthogClient } from "@web/auth/posthog/posthog.bootstrap"; * `trial_step_viewed`, `trial_step_completed`, `trial_step_abandoned`) * - `signup_failed`: `reason_code`, optional `method`, `step`, `error` * + * OAuth signup drop-off (server + client; `packages/core/src/analytics/oauth-signup.events.ts`): + * - `oauth_redirect_started`: `provider`, `intent` + * - `oauth_callback_received`: `provider` + * - `oauth_failed`: `provider`, `reason`, optional `provider_error` + * * Billing: * - `trial_converted`: `source`, optional `feature_area`, `action_id`, `trial` */ @@ -22,6 +27,8 @@ export type ProductEvent = | "local_mode_selected" | "signup_completed" | "oauth_redirect_started" + | "oauth_callback_received" + | "oauth_failed" | "oauth_return" | "signup_step_viewed" | "signup_failed" diff --git a/apps/calendar-web/src/auth/providers/authorization/relay-provider-oauth-callback-telemetry.test.ts b/apps/calendar-web/src/auth/providers/authorization/relay-provider-oauth-callback-telemetry.test.ts new file mode 100644 index 0000000000..2d4cc2a861 --- /dev/null +++ b/apps/calendar-web/src/auth/providers/authorization/relay-provider-oauth-callback-telemetry.test.ts @@ -0,0 +1,26 @@ +import { relayProviderOAuthCallbackTelemetry } from "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry"; +import { afterEach, describe, expect, it, mock } from "bun:test"; + +describe("relayProviderOAuthCallbackTelemetry", () => { + const realFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it("requests backend callback telemetry with the relay header", () => { + const fetchMock = mock(async () => new Response(null, { status: 204 })); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + relayProviderOAuthCallbackTelemetry("google", "?state=test-state&code=abc"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const firstCall = fetchMock.mock.calls[0]; + expect(firstCall).toBeDefined(); + const [url, init] = firstCall as unknown as [string, RequestInit]; + expect(url).toContain("/auth/google/callback?state=test-state"); + expect(init.headers).toEqual( + expect.objectContaining({ "X-Compass-OAuth-Telemetry": "1" }), + ); + }); +}); diff --git a/apps/calendar-web/src/auth/providers/authorization/relay-provider-oauth-callback-telemetry.ts b/apps/calendar-web/src/auth/providers/authorization/relay-provider-oauth-callback-telemetry.ts new file mode 100644 index 0000000000..a5816986f7 --- /dev/null +++ b/apps/calendar-web/src/auth/providers/authorization/relay-provider-oauth-callback-telemetry.ts @@ -0,0 +1,28 @@ +import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { providerAuthCallbackPath } from "@web/auth/providers/authorization/provider-authorization.constants"; +import { ENV_WEB } from "@web/common/constants/env.constants"; + +const TELEMETRY_HEADER = "X-Compass-OAuth-Telemetry"; + +/** + * Records server-side OAuth callback funnel events on the backend using the + * same query Google (or the provider) returned to the SPA callback route. + */ +export function relayProviderOAuthCallbackTelemetry( + provider: ProviderKind, + search: string, +): void { + const query = search.startsWith("?") ? search : `?${search}`; + const url = `${ENV_WEB.BACKEND_BASEURL}${providerAuthCallbackPath(provider)}${query}`; + + try { + void fetch(url, { + method: "GET", + credentials: "omit", + headers: { [TELEMETRY_HEADER]: "1" }, + keepalive: true, + }); + } catch { + // Analytics must never block the OAuth callback UI. + } +} diff --git a/apps/calendar-web/src/auth/providers/authorization/useStartProviderAuthorization.impl.ts b/apps/calendar-web/src/auth/providers/authorization/useStartProviderAuthorization.impl.ts index b55a60ccc6..864b8f2faa 100644 --- a/apps/calendar-web/src/auth/providers/authorization/useStartProviderAuthorization.impl.ts +++ b/apps/calendar-web/src/auth/providers/authorization/useStartProviderAuthorization.impl.ts @@ -7,6 +7,7 @@ import { buildOAuthStateForClient } from "@core/desktop/desktop-oauth-state.util import { GOOGLE_SCOPES } from "@core/providers/google.scopes"; import { MICROSOFT_SCOPES } from "@core/providers/microsoft.scopes"; import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { readPosthogDistinctIdForOAuth } from "@web/auth/posthog/posthog-distinct-id.util"; import { trackSignupStep } from "@web/auth/posthog/signup-funnel"; import { track } from "@web/auth/posthog/track"; import { rememberSignupTrialMethod } from "@web/billing/signup-trial.util"; @@ -52,6 +53,11 @@ type AuthorizationSession = { redirectUri: string; }; +type CreateOAuthState = ( + redirectUri: string, + posthogDistinctId?: string, +) => string; + /** * Pins the OAuth `state` and callback URL for the life of the mount, so the * value handed to the consent screen is the one the callback later looks up. @@ -60,11 +66,14 @@ type AuthorizationSession = { */ function useAuthorizationSession( provider: ProviderKind, - createState: (redirectUri: string) => string, + createState: CreateOAuthState, ): AuthorizationSession { const [session] = useState(() => { const redirectUri = buildProviderAuthCallbackUrl(provider); - return { state: createState(redirectUri), redirectUri }; + return { + state: createState(redirectUri, readPosthogDistinctIdForOAuth()), + redirectUri, + }; }); return session; } @@ -118,7 +127,7 @@ type RedirectAuthorizationSpec = { */ getClientId: () => string; notConfiguredMessage: string; - createState: (redirectUri: string) => string; + createState: CreateOAuthState; buildAuthorizationUrl: ( args: AuthorizationSession & { clientId: string; @@ -179,8 +188,10 @@ const useGoogleProviderAuthorizationStrategy: ProviderAuthorizationStrategy = ({ prompt, }) => { const [loading, setLoading] = useState(false); - const { state, redirectUri } = useAuthorizationSession("google", () => - buildOAuthStateForClient(false), + const { state, redirectUri } = useAuthorizationSession( + "google", + (_redirectUri, posthogDistinctId) => + buildOAuthStateForClient(false, posthogDistinctId), ); const loginOptions = useMemo< @@ -223,7 +234,8 @@ const useMicrosoftProviderAuthorizationStrategy = provider: "microsoft", getClientId: () => getMicrosoftSignInClientId(), notConfiguredMessage: "Microsoft sign-in is not configured", - createState: () => buildOAuthStateForClient(false), + createState: (_redirectUri, posthogDistinctId) => + buildOAuthStateForClient(false, posthogDistinctId), buildAuthorizationUrl: ({ clientId, redirectUri, state, prompt }) => buildMicrosoftAuthorizationUrl({ clientId, @@ -241,11 +253,12 @@ const useAppleProviderAuthorizationStrategy = notConfiguredMessage: "Apple sign-in is not configured", // Apple returns to the backend form_post route, so the frontend callback it // should land on afterwards rides along inside the state. - createState: (redirectUri) => + createState: (redirectUri, posthogDistinctId) => btoa( JSON.stringify({ frontendRedirectURI: redirectUri, nonce: crypto.randomUUID(), + ...(posthogDistinctId ? { ph: posthogDistinctId } : {}), }), ), buildAuthorizationUrl: ({ clientId, state }) => diff --git a/apps/calendar-web/src/auth/providers/authorization/useStartProviderAuthorization.test.tsx b/apps/calendar-web/src/auth/providers/authorization/useStartProviderAuthorization.test.tsx index b224076cac..4e93c6f25e 100644 --- a/apps/calendar-web/src/auth/providers/authorization/useStartProviderAuthorization.test.tsx +++ b/apps/calendar-web/src/auth/providers/authorization/useStartProviderAuthorization.test.tsx @@ -61,7 +61,7 @@ describe("useStartProviderAuthorizationImpl", () => { key.includes("googleAuthorizationIntent"), ); expect(intents).toHaveLength(1); - const state = intents[0]?.split(".").at(-1); + const state = intents[0]?.replace("compass.googleAuthorizationIntent.", ""); expect(state).toBeDefined(); expect(readProviderAuthorizationIntent("google", state!)).toEqual( expect.objectContaining({ @@ -95,7 +95,10 @@ describe("useStartProviderAuthorizationImpl", () => { key.includes("providerAuthorizationIntent.microsoft"), ); expect(intents).toHaveLength(1); - const state = intents[0]?.split(".").at(-1); + const state = intents[0]?.replace( + "compass.providerAuthorizationIntent.microsoft.", + "", + ); expect(readProviderAuthorizationIntent("microsoft", state!)).toEqual( expect.objectContaining({ intent: "signIn", diff --git a/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.test.ts b/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.test.ts index 699aa8855a..9c73c1bd46 100644 --- a/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.test.ts +++ b/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.test.ts @@ -7,6 +7,13 @@ import { import { registerToastPort } from "@web/common/utils/toast/toast.port"; import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +mock.module( + "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry", + () => ({ + relayProviderOAuthCallbackTelemetry: () => undefined, + }), +); + const mockLoginOrSignup = mock(); const realLoginOrSignup = AuthApi.loginOrSignup; AuthApi.loginOrSignup = mockLoginOrSignup as typeof AuthApi.loginOrSignup; @@ -17,8 +24,7 @@ afterAll(() => { const { port, mocks } = createTestToastPort(); -const { completeAppleAuthCallback } = - require("./AppleAuthCallback") as typeof import("./AppleAuthCallback"); +const { completeAppleAuthCallback } = await import("./AppleAuthCallback"); const callbackSearch = (state: string) => `?state=${encodeURIComponent(state)}&code=auth-code`; diff --git a/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.tsx b/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.tsx index af3ae52f42..0fcabaee3c 100644 --- a/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.tsx +++ b/apps/calendar-web/src/views/AppleAuthCallback/AppleAuthCallback.tsx @@ -17,6 +17,7 @@ import { clearProviderAuthorizationIntent, readProviderAuthorizationIntent, } from "@web/auth/providers/authorization/provider-authorization.storage"; +import { relayProviderOAuthCallbackTelemetry } from "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry"; import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes"; import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; @@ -33,7 +34,9 @@ export async function completeAppleAuthCallback({ navigate, search, }: CompleteAppleAuthCallbackOptions): Promise { + relayProviderOAuthCallbackTelemetry("apple", search); trackSignupStep("oauth_callback_returned", { method: "apple" }); + track("oauth_callback_received", { provider: "apple" }); const params = new URLSearchParams(search); diff --git a/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.test.ts b/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.test.ts index 3b3360c935..1332bc5a1c 100644 --- a/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.test.ts +++ b/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.test.ts @@ -11,9 +11,19 @@ import { useMissingPermissionsStore, } from "@web/auth/providers/missing-permissions.store"; import { registerToastPort } from "@web/common/utils/toast/toast.port"; -import { completeProviderAuthCallback } from "@web/views/ProviderAuthCallback/ProviderAuthCallback"; import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +mock.module( + "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry", + () => ({ + relayProviderOAuthCallbackTelemetry: () => undefined, + }), +); + +const { completeProviderAuthCallback } = await import( + "@web/views/ProviderAuthCallback/ProviderAuthCallback" +); + const GOOGLE_SCOPES_REQUIRED = PROVIDER_AUTH_SCOPES_REQUIRED.google; const mockLoginOrSignup = mock(); diff --git a/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.tsx b/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.tsx index 9f25bb4c0a..3c5d76d321 100644 --- a/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.tsx +++ b/apps/calendar-web/src/views/ProviderAuthCallback/ProviderAuthCallback.tsx @@ -7,9 +7,9 @@ import { shouldRelayDesktopOAuthCallback } from "@web/auth/callback/desktop-oaut import { reportAuthCallbackCrash } from "@web/auth/callback/report-auth-callback-crash"; import { useOneShotAuthCallback } from "@web/auth/callback/useOneShotAuthCallback"; import { useCompleteAuthentication } from "@web/auth/compass/hooks/useCompleteAuthentication"; +import { trackOAuthSignupFailed } from "@web/auth/posthog/oauth-signup-funnel"; import { trackSignupCompleted, - trackSignupFailed, trackSignupStep, } from "@web/auth/posthog/signup-funnel"; import { track } from "@web/auth/posthog/track"; @@ -18,6 +18,7 @@ import { isSignInProviderKind, PROVIDER_AUTHORIZATION_ERROR_MESSAGE, } from "@web/auth/providers/authorization/provider-authorization.constants"; +import { relayProviderOAuthCallbackTelemetry } from "@web/auth/providers/authorization/relay-provider-oauth-callback-telemetry"; import { missingPermissionsActions } from "@web/auth/providers/missing-permissions.store"; import { DEFAULT_CALENDAR_ROUTE } from "@web/common/constants/routes"; import { getToastDefaultOptions } from "@web/common/constants/toast.constants"; @@ -39,9 +40,12 @@ export async function completeProviderAuthCallback({ navigate, search, }: CompleteProviderAuthCallbackOptions): Promise { + relayProviderOAuthCallbackTelemetry(provider, search); + // Before any branch: a user who came back from the provider is counted even // when the exchange below fails. trackSignupStep("oauth_callback_returned", { method: provider }); + track("oauth_callback_received", { provider }); const result = await completeProviderAuthorization({ provider, @@ -51,8 +55,11 @@ export async function completeProviderAuthCallback({ }); if (result.status === "failed") { - trackSignupFailed(result.reason, { - method: provider, + trackOAuthSignupFailed({ + provider, + reason: result.reason, + providerError: + result.error === "access_denied" ? result.error : undefined, step: "oauth_callback_returned", error: result.error, }); diff --git a/docs/frontend/frontend-runtime-flow.md b/docs/frontend/frontend-runtime-flow.md index a5c50c73e3..258adfdd91 100644 --- a/docs/frontend/frontend-runtime-flow.md +++ b/docs/frontend/frontend-runtime-flow.md @@ -226,6 +226,23 @@ the booking filter. `signup_started`, which is reserved for explicit login or trial CTAs (OAuth, email sign-up modal, command palette, and similar). +OAuth signup drop-off uses four named stages shared by Google, Microsoft, and +Apple (`packages/core/src/analytics/oauth-signup.events.ts`): + +1. `oauth_redirect_started` (`provider`, `intent`) when the browser leaves for + the provider consent screen. +2. `oauth_callback_received` (`provider`) when the provider redirect lands. The + SPA relays the query string to the backend route + `GET /auth/:provider/callback` with `X-Compass-OAuth-Telemetry: 1` so the + event is captured server-side even when JavaScript fails later. OAuth `state` + carries the PostHog anon distinct id when available. +3. `oauth_failed` (`provider`, `reason`, optional `provider_error`) for consent + denial, invalid state, missing code, token exchange errors, and other + failures. Legacy `signup_failed` still fires with `reason_code` for existing + insights. +4. `signup_completed` or `login_completed` (`method`) after a successful + SuperTokens exchange (unchanged). + `calendar_connected` has two sources, distinguished by a `source` property: `signup_google` from `GoogleAuthCallback` (a new user whose Google grant included the calendar scopes) and `connect_redirect` from diff --git a/packages/backend/src/auth/auth.routes.config.ts b/packages/backend/src/auth/auth.routes.config.ts index 2a12ad8776..d7fe6360f2 100644 --- a/packages/backend/src/auth/auth.routes.config.ts +++ b/packages/backend/src/auth/auth.routes.config.ts @@ -28,6 +28,14 @@ export const appleSignInCallbackLimiter = rateLimit({ keyGenerator: (req) => req.ip ?? "anonymous", }); +export const providerOAuthCallbackLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 60, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.ip ?? "anonymous", +}); + /** * Routes with the verifyIsDev middleware are * only available when running the app in dev, @@ -90,6 +98,12 @@ export class AuthRoutes extends CommonRoutesConfig { }, ); + this.app + .route("/auth/:provider/callback") + .get(providerOAuthCallbackLimiter, (req, res) => { + authController.providerOAuthCallback(req, res); + }); + return this.app; } } diff --git a/packages/backend/src/auth/controllers/auth.controller.ts b/packages/backend/src/auth/controllers/auth.controller.ts index cc78f24cbb..ef1ebee36a 100644 --- a/packages/backend/src/auth/controllers/auth.controller.ts +++ b/packages/backend/src/auth/controllers/auth.controller.ts @@ -1,3 +1,4 @@ +import type express from "express"; import { ObjectId } from "mongodb"; import { type SessionRequest } from "supertokens-node/framework/express"; import { Logger } from "@core/logger/winston.logger"; @@ -14,6 +15,11 @@ import { } from "@core/types/sync/identity.contracts"; import { resolveAppleFormPostRedirect } from "@backend/auth/services/apple/apple.auth.callback"; import compassAuthService from "@backend/auth/services/compass/compass.auth.service"; +import { + appleFormPostAnalytics, + handleProviderOAuthCallback, + parseProviderOAuthCallbackProvider, +} from "@backend/auth/services/provider-oauth-callback.service"; import { CONFIG } from "@backend/common/constants/config.constants"; import { isAppleConnectConfigured, @@ -202,13 +208,53 @@ class AuthController { req: ReqBody>, res: Res_Promise, ): void => { - const result = resolveAppleFormPostRedirect(req.body ?? {}); + const body = req.body ?? {}; + const state = typeof body["state"] === "string" ? body["state"].trim() : ""; + if (state) { + appleFormPostAnalytics({ + state, + code: typeof body["code"] === "string" ? body["code"] : undefined, + error: typeof body["error"] === "string" ? body["error"] : undefined, + }); + } + + const result = resolveAppleFormPostRedirect(body); if ("status" in result) { res.status(result.status).json({ error: result.message }); return; } res.redirect(302, result.location); }; + + providerOAuthCallback = ( + req: express.Request, + res: express.Response, + ): void => { + const provider = parseProviderOAuthCallbackProvider( + String(req.params["provider"] ?? ""), + ); + if (!provider) { + res.status(404).json({ error: "Unknown OAuth provider" }); + return; + } + + const telemetryOnly = + req.header("x-compass-oauth-telemetry") === "1" || + req.query["telemetry"] === "1"; + + const resolution = handleProviderOAuthCallback({ + provider, + query: req.query as Record, + telemetryOnly, + }); + + if (resolution.mode === "telemetry") { + res.status(204).end(); + return; + } + + res.redirect(302, resolution.location); + }; } export default new AuthController(); diff --git a/packages/backend/src/auth/services/provider-oauth-callback.service.test.ts b/packages/backend/src/auth/services/provider-oauth-callback.service.test.ts new file mode 100644 index 0000000000..eb1be219a4 --- /dev/null +++ b/packages/backend/src/auth/services/provider-oauth-callback.service.test.ts @@ -0,0 +1,76 @@ +import { buildSignInOAuthState } from "@core/auth/signin-oauth-state.util"; +import { + handleProviderOAuthCallback, + parseProviderOAuthCallbackProvider, +} from "@backend/auth/services/provider-oauth-callback.service"; +import * as analytics from "@backend/auth/services/provider-oauth-signup.analytics"; +import { describe, expect, it, spyOn } from "bun:test"; + +describe("provider-oauth-callback.service", () => { + it("parses supported providers", () => { + expect(parseProviderOAuthCallbackProvider("google")).toBe("google"); + expect(parseProviderOAuthCallbackProvider("unknown")).toBeNull(); + }); + + it("captures callback and failure then redirects to the SPA", () => { + const received = spyOn( + analytics.providerOAuthSignupAnalytics, + "captureCallbackReceived", + ).mockImplementation(() => undefined); + const failed = spyOn( + analytics.providerOAuthSignupAnalytics, + "captureFailed", + ).mockImplementation(() => undefined); + + const state = buildSignInOAuthState({ + isDesktopClient: false, + posthogDistinctId: "anon-abc", + }); + + const result = handleProviderOAuthCallback({ + provider: "google", + query: { state, error: "access_denied" }, + telemetryOnly: false, + frontendUrl: "http://localhost:9080", + }); + + expect(received).toHaveBeenCalledWith({ provider: "google", state }); + expect(failed).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "google", + reason: "access_denied", + providerError: "access_denied", + }), + ); + expect(result).toEqual({ + mode: "redirect", + location: expect.stringContaining( + "http://localhost:9080/auth/google/callback", + ), + }); + + received.mockRestore(); + failed.mockRestore(); + }); + + it("returns telemetry-only without redirect", () => { + const received = spyOn( + analytics.providerOAuthSignupAnalytics, + "captureCallbackReceived", + ).mockImplementation(() => undefined); + spyOn( + analytics.providerOAuthSignupAnalytics, + "captureFailed", + ).mockImplementation(() => undefined); + + const state = buildSignInOAuthState({ isDesktopClient: false }); + const result = handleProviderOAuthCallback({ + provider: "microsoft", + query: { state, code: "auth-code" }, + telemetryOnly: true, + }); + + expect(result).toEqual({ mode: "telemetry" }); + received.mockRestore(); + }); +}); diff --git a/packages/backend/src/auth/services/provider-oauth-callback.service.ts b/packages/backend/src/auth/services/provider-oauth-callback.service.ts new file mode 100644 index 0000000000..3f53088bde --- /dev/null +++ b/packages/backend/src/auth/services/provider-oauth-callback.service.ts @@ -0,0 +1,116 @@ +import { oauthFailedReasonFromCallbackQuery } from "@core/analytics/oauth-signup.events"; +import { + type ProviderKind, + ProviderKindSchema, +} from "@core/types/sync/identity.contracts"; +import { providerOAuthSignupAnalytics } from "@backend/auth/services/provider-oauth-signup.analytics"; +import { CONFIG } from "@backend/common/constants/config.constants"; + +export const PROVIDER_SIGNIN_CALLBACK_PATH_PREFIX = "/auth"; + +const providerCallbackPath = (provider: ProviderKind) => + `${PROVIDER_SIGNIN_CALLBACK_PATH_PREFIX}/${provider}/callback`; + +export type ProviderOAuthCallbackQuery = { + state?: string; + code?: string; + error?: string; + scope?: string; +}; + +export type ProviderOAuthCallbackResolution = + | { mode: "telemetry" } + | { mode: "redirect"; location: string }; + +function queryField( + query: ProviderOAuthCallbackQuery, + key: keyof ProviderOAuthCallbackQuery, +): string | undefined { + const value = query[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function spaCallbackUrl( + provider: ProviderKind, + query: ProviderOAuthCallbackQuery, + frontendUrl: string = CONFIG.FRONTEND_URL, +): string { + const url = new URL(providerCallbackPath(provider), frontendUrl); + for (const key of ["state", "code", "error", "scope"] as const) { + const value = queryField(query, key); + if (value) { + url.searchParams.set(key, value); + } + } + return url.toString(); +} + +export function parseProviderOAuthCallbackProvider( + providerParam: string, +): ProviderKind | null { + const parsed = ProviderKindSchema.safeParse(providerParam); + return parsed.success ? parsed.data : null; +} + +export function handleProviderOAuthCallback(input: { + provider: ProviderKind; + query: ProviderOAuthCallbackQuery; + telemetryOnly: boolean; + frontendUrl?: string; +}): ProviderOAuthCallbackResolution { + const state = queryField(input.query, "state"); + const code = queryField(input.query, "code"); + const providerError = queryField(input.query, "error"); + + providerOAuthSignupAnalytics.captureCallbackReceived({ + provider: input.provider, + state, + }); + + const failureReason = oauthFailedReasonFromCallbackQuery({ + state, + code, + providerError, + }); + if (failureReason) { + providerOAuthSignupAnalytics.captureFailed({ + provider: input.provider, + state, + reason: failureReason, + providerError, + }); + } + + if (input.telemetryOnly) { + return { mode: "telemetry" }; + } + + return { + mode: "redirect", + location: spaCallbackUrl(input.provider, input.query, input.frontendUrl), + }; +} + +export function appleFormPostAnalytics(input: { + state: string; + code?: string; + error?: string; +}): void { + providerOAuthSignupAnalytics.captureCallbackReceived({ + provider: "apple", + state: input.state, + }); + const failureReason = oauthFailedReasonFromCallbackQuery({ + state: input.state, + code: input.code, + providerError: input.error, + }); + if (failureReason) { + providerOAuthSignupAnalytics.captureFailed({ + provider: "apple", + state: input.state, + reason: failureReason, + providerError: input.error, + }); + } +} diff --git a/packages/backend/src/auth/services/provider-oauth-signup.analytics.ts b/packages/backend/src/auth/services/provider-oauth-signup.analytics.ts new file mode 100644 index 0000000000..9990fabfa6 --- /dev/null +++ b/packages/backend/src/auth/services/provider-oauth-signup.analytics.ts @@ -0,0 +1,50 @@ +import { + OAUTH_CALLBACK_RECEIVED_EVENT, + OAUTH_FAILED_EVENT, + type OAuthFailedReason, +} from "@core/analytics/oauth-signup.events"; +import { posthogDistinctIdFromSignInOAuthState } from "@core/auth/signin-oauth-state.util"; +import { captureSafely } from "@core/logger/posthog-capture"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { CONFIG } from "@backend/common/constants/config.constants"; +import { getBackendPostHogClient } from "@backend/common/helpers/backend-posthog-client"; + +const FALLBACK_DISTINCT_ID = "compass-oauth-signup"; + +function distinctIdFromOAuthState(state: string | null | undefined): string { + return posthogDistinctIdFromSignInOAuthState(state) ?? FALLBACK_DISTINCT_ID; +} + +export const providerOAuthSignupAnalytics = { + captureCallbackReceived(input: { + provider: ProviderKind; + state: string | null | undefined; + }): void { + void captureSafely(getBackendPostHogClient(), { + event: OAUTH_CALLBACK_RECEIVED_EVENT, + distinctId: distinctIdFromOAuthState(input.state), + properties: { + environment: CONFIG.NODE_ENV, + provider: input.provider, + }, + }); + }, + + captureFailed(input: { + provider: ProviderKind; + state: string | null | undefined; + reason: OAuthFailedReason; + providerError?: string; + }): void { + void captureSafely(getBackendPostHogClient(), { + event: OAUTH_FAILED_EVENT, + distinctId: distinctIdFromOAuthState(input.state), + properties: { + environment: CONFIG.NODE_ENV, + provider: input.provider, + reason: input.reason, + ...(input.providerError ? { provider_error: input.providerError } : {}), + }, + }); + }, +}; diff --git a/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts b/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts index 32d6414994..08ec78d5d3 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.handlers.ts @@ -20,6 +20,7 @@ import { microsoftSubjectId, } from "@backend/auth/services/microsoft/microsoft.auth.service"; import { type MicrosoftSignInSuccess } from "@backend/auth/services/microsoft/microsoft.auth.types"; +import { providerOAuthSignupAnalytics } from "@backend/auth/services/provider-oauth-signup.analytics"; import { CONFIG } from "@backend/common/constants/config.constants"; import { appleFormUserJsonFromInput, @@ -30,6 +31,7 @@ import { ensureExternalUserIdMapping, getFormFieldValue, maybeReplaceEmailPasswordSession, + oauthStateFromThirdPartySignInUpInput, withAppleFirstAuthorizationName, } from "@backend/common/middleware/supertokens.middleware.util"; import userService from "@backend/user/services/user.service"; @@ -224,7 +226,8 @@ export async function handleThirdPartySignInUp( originalSignInUpPOST: ThirdPartySignInUpPostFn, ): Promise>> { const thirdPartyId = input.provider?.id ?? "google"; - const kind = providerKindFromThirdPartyId(thirdPartyId); + const kind = providerKindFromThirdPartyId(thirdPartyId) ?? "google"; + const oauthState = oauthStateFromThirdPartySignInUpInput(input); let response: Awaited>; @@ -235,6 +238,11 @@ export async function handleThirdPartySignInUp( provider: kind, error: err instanceof Error ? err.message : String(err), }); + providerOAuthSignupAnalytics.captureFailed({ + provider: kind, + state: oauthState, + reason: "token_exchange_error", + }); throw err; } @@ -243,6 +251,11 @@ export async function handleThirdPartySignInUp( provider: kind, status: response.status, }); + providerOAuthSignupAnalytics.captureFailed({ + provider: kind, + state: oauthState, + reason: "token_exchange_error", + }); } try { @@ -312,6 +325,11 @@ export async function handleThirdPartySignInUp( provider: kind, error: err instanceof Error ? err.message : String(err), }); + providerOAuthSignupAnalytics.captureFailed({ + provider: kind, + state: oauthState, + reason: "other", + }); throw err; } } diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.ts index a02edfdace..4d00b36af1 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.ts @@ -164,6 +164,16 @@ export function withAppleFirstAuthorizationName( }; } +export function oauthStateFromThirdPartySignInUpInput( + input: ThirdPartySignInUpInput, +): string | undefined { + if (!("redirectURIInfo" in input) || input.redirectURIInfo == null) { + return undefined; + } + const state = input.redirectURIInfo.redirectURIQueryParams?.state; + return typeof state === "string" && state.length > 0 ? state : undefined; +} + export function appleFormUserJsonFromInput( input: ThirdPartySignInUpInput, ): string | undefined { diff --git a/packages/core/src/analytics/oauth-signup.events.test.ts b/packages/core/src/analytics/oauth-signup.events.test.ts new file mode 100644 index 0000000000..9fc9143319 --- /dev/null +++ b/packages/core/src/analytics/oauth-signup.events.test.ts @@ -0,0 +1,31 @@ +import { + oauthFailedReasonFromCallbackQuery, + oauthFailedReasonFromProviderError, +} from "@core/analytics/oauth-signup.events"; +import { describe, expect, it } from "bun:test"; + +describe("oauth-signup.events", () => { + it("maps provider errors", () => { + expect(oauthFailedReasonFromProviderError("access_denied")).toBe( + "access_denied", + ); + expect(oauthFailedReasonFromProviderError("server_error")).toBe("other"); + }); + + it("derives callback failure reasons from query params", () => { + expect( + oauthFailedReasonFromCallbackQuery({ + state: "x", + code: null, + providerError: null, + }), + ).toBe("missing_code"); + expect( + oauthFailedReasonFromCallbackQuery({ + state: null, + code: "abc", + providerError: null, + }), + ).toBe("invalid_state"); + }); +}); diff --git a/packages/core/src/analytics/oauth-signup.events.ts b/packages/core/src/analytics/oauth-signup.events.ts new file mode 100644 index 0000000000..7a075b15ab --- /dev/null +++ b/packages/core/src/analytics/oauth-signup.events.ts @@ -0,0 +1,60 @@ +import { type ProviderKind } from "@core/types/sync/identity.contracts"; + +/** Server- and client-side OAuth signup funnel events (see `track.ts`). */ +export const OAUTH_CALLBACK_RECEIVED_EVENT = "oauth_callback_received" as const; +export const OAUTH_FAILED_EVENT = "oauth_failed" as const; + +export type OAuthSignupServerEvent = + | typeof OAUTH_CALLBACK_RECEIVED_EVENT + | typeof OAUTH_FAILED_EVENT; + +export type OAuthFailedReason = + | "access_denied" + | "invalid_state" + | "token_exchange_error" + | "missing_code" + | "other"; + +const OAUTH_FAILED_REASONS: readonly OAuthFailedReason[] = [ + "access_denied", + "invalid_state", + "token_exchange_error", + "missing_code", + "other", +]; + +export function isOAuthFailedReason( + value: unknown, +): value is OAuthFailedReason { + return ( + typeof value === "string" && + (OAUTH_FAILED_REASONS as readonly string[]).includes(value) + ); +} + +export type OAuthSignupEventProperties = { + provider: ProviderKind; + reason?: OAuthFailedReason; + /** Raw provider `error` query param when present (e.g. Google OAuth errors). */ + provider_error?: string; +}; + +export function oauthFailedReasonFromProviderError( + providerError: string | null | undefined, +): OAuthFailedReason { + if (providerError === "access_denied") return "access_denied"; + if (providerError) return "other"; + return "other"; +} + +export function oauthFailedReasonFromCallbackQuery(input: { + state: string | null | undefined; + code: string | null | undefined; + providerError: string | null | undefined; +}): OAuthFailedReason | undefined { + if (input.providerError === "access_denied") return "access_denied"; + if (input.providerError) return "other"; + if (!input.state) return "invalid_state"; + if (!input.code) return "missing_code"; + return undefined; +} diff --git a/packages/core/src/auth/signin-oauth-state.util.test.ts b/packages/core/src/auth/signin-oauth-state.util.test.ts new file mode 100644 index 0000000000..cb6e7b15e4 --- /dev/null +++ b/packages/core/src/auth/signin-oauth-state.util.test.ts @@ -0,0 +1,44 @@ +import { + buildSignInOAuthState, + parseSignInOAuthState, + posthogDistinctIdFromSignInOAuthState, +} from "@core/auth/signin-oauth-state.util"; +import { DESKTOP_OAUTH_STATE_PREFIX } from "@core/desktop/desktop-oauth-state.util"; +import { describe, expect, it } from "bun:test"; + +describe("signin-oauth-state", () => { + it("round-trips posthog distinct id for web sign-in", () => { + const state = buildSignInOAuthState({ + isDesktopClient: false, + posthogDistinctId: "anon-123", + }); + expect(parseSignInOAuthState(state)).toEqual({ + rawState: state, + nonce: expect.any(String), + posthogDistinctId: "anon-123", + isDesktop: false, + }); + }); + + it("prefixes desktop sign-in state", () => { + const state = buildSignInOAuthState({ + isDesktopClient: true, + posthogDistinctId: "anon-desktop", + }); + expect(state.startsWith(DESKTOP_OAUTH_STATE_PREFIX)).toBe(true); + expect(parseSignInOAuthState(state)?.isDesktop).toBe(true); + expect(parseSignInOAuthState(state)?.posthogDistinctId).toBe( + "anon-desktop", + ); + }); + + it("parses legacy uuid-only state", () => { + const legacy = "550e8400-e29b-41d4-a716-446655440000"; + expect(parseSignInOAuthState(legacy)).toEqual({ + rawState: legacy, + nonce: legacy, + isDesktop: false, + }); + expect(posthogDistinctIdFromSignInOAuthState(legacy)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/auth/signin-oauth-state.util.ts b/packages/core/src/auth/signin-oauth-state.util.ts new file mode 100644 index 0000000000..8f73d476af --- /dev/null +++ b/packages/core/src/auth/signin-oauth-state.util.ts @@ -0,0 +1,142 @@ +import { DESKTOP_OAUTH_STATE_PREFIX } from "@core/desktop/desktop-oauth-state.util"; + +const SIGNIN_OAUTH_STATE_VERSION_PREFIX = "v1."; + +const LEGACY_UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type ParsedSignInOAuthState = { + rawState: string; + nonce: string; + posthogDistinctId?: string; + isDesktop: boolean; +}; + +type SignInOAuthStatePayload = { + n: string; + ph?: string; +}; + +function base64UrlEncode(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function base64UrlDecode(encoded: string): string { + const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "===".slice((normalized.length + 3) % 4); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +function encodePayload(payload: SignInOAuthStatePayload): string { + return `${SIGNIN_OAUTH_STATE_VERSION_PREFIX}${base64UrlEncode(JSON.stringify(payload))}`; +} + +function decodePayload(body: string): SignInOAuthStatePayload | null { + if (!body.startsWith(SIGNIN_OAUTH_STATE_VERSION_PREFIX)) { + return null; + } + try { + const json = base64UrlDecode( + body.slice(SIGNIN_OAUTH_STATE_VERSION_PREFIX.length), + ); + const parsed = JSON.parse(json) as SignInOAuthStatePayload; + if (typeof parsed.n !== "string" || parsed.n.length === 0) { + return null; + } + if (parsed.ph !== undefined && typeof parsed.ph !== "string") { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** Builds OAuth `state` for provider sign-in, optionally carrying PostHog anon id. */ +export function buildSignInOAuthState(input: { + isDesktopClient: boolean; + posthogDistinctId?: string; +}): string { + const payload: SignInOAuthStatePayload = { + n: crypto.randomUUID(), + ...(input.posthogDistinctId ? { ph: input.posthogDistinctId } : {}), + }; + const body = encodePayload(payload); + return input.isDesktopClient ? `${DESKTOP_OAUTH_STATE_PREFIX}${body}` : body; +} + +export function parseSignInOAuthState( + state: string, +): ParsedSignInOAuthState | null { + let body = state; + let isDesktop = false; + if (body.startsWith(DESKTOP_OAUTH_STATE_PREFIX)) { + isDesktop = true; + body = body.slice(DESKTOP_OAUTH_STATE_PREFIX.length); + } + + if (LEGACY_UUID_PATTERN.test(body)) { + return { + rawState: state, + nonce: body, + isDesktop, + }; + } + + const payload = decodePayload(body); + if (!payload) { + return null; + } + + const posthogDistinctId = + payload.ph && payload.ph.length > 0 ? payload.ph : undefined; + + return { + rawState: state, + nonce: payload.n, + posthogDistinctId, + isDesktop, + }; +} + +function posthogDistinctIdFromAppleOAuthState( + state: string, +): string | undefined { + try { + const decoded = base64UrlDecode(state); + const parsed = JSON.parse(decoded) as { ph?: unknown }; + return typeof parsed.ph === "string" && parsed.ph.length > 0 + ? parsed.ph + : undefined; + } catch { + try { + const decoded = atob(state); + const parsed = JSON.parse(decoded) as { ph?: unknown }; + return typeof parsed.ph === "string" && parsed.ph.length > 0 + ? parsed.ph + : undefined; + } catch { + return undefined; + } + } +} + +export function posthogDistinctIdFromSignInOAuthState( + state: string | null | undefined, +): string | undefined { + if (!state) return undefined; + return ( + parseSignInOAuthState(state)?.posthogDistinctId ?? + posthogDistinctIdFromAppleOAuthState(state) + ); +} diff --git a/packages/core/src/desktop/desktop-oauth-state.util.ts b/packages/core/src/desktop/desktop-oauth-state.util.ts index 11c4ff86ed..dd094f581f 100644 --- a/packages/core/src/desktop/desktop-oauth-state.util.ts +++ b/packages/core/src/desktop/desktop-oauth-state.util.ts @@ -1,3 +1,4 @@ +import { buildSignInOAuthState } from "@core/auth/signin-oauth-state.util"; import { type ProviderKind, ProviderKindSchema, @@ -29,9 +30,11 @@ const DESKTOP_EVENT_LINK_PREFIX = `${DESKTOP_DEEP_LINK_SCHEME}event/`; const DESKTOP_DAY_LINK_PATTERN = /^compass:\/\/day\/(?\d{4}-\d{2}-\d{2})\/?$/; -export function buildOAuthStateForClient(isDesktopClient: boolean): string { - const id = crypto.randomUUID(); - return isDesktopClient ? `${DESKTOP_OAUTH_STATE_PREFIX}${id}` : id; +export function buildOAuthStateForClient( + isDesktopClient: boolean, + posthogDistinctId?: string, +): string { + return buildSignInOAuthState({ isDesktopClient, posthogDistinctId }); } export function hasDesktopOAuthStateMarker(state: string): boolean {