Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe("oauth-signup-funnel", () => {
"missing_code",
);
expect(oauthFailedReasonFromSignupFailure("oauth_exchange_failed")).toBe(
"token_exchange_error",
"token_exchange_failed",
);
});
});
2 changes: 1 addition & 1 deletion apps/calendar-web/src/auth/posthog/oauth-signup-funnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const SIGNUP_FAILURE_TO_OAUTH_FAILED: Partial<
oauth_missing_state: "invalid_state",
oauth_missing_intent: "invalid_state",
oauth_missing_code: "missing_code",
oauth_exchange_failed: "token_exchange_error",
oauth_exchange_failed: "token_exchange_failed",
oauth_missing_scopes: "other",
oauth_callback_crashed: "other",
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ type AuthorizationSession = {

type CreateOAuthState = (
redirectUri: string,
posthogDistinctId?: string,
posthogDistinctId: string | undefined,
signupFlow: boolean | undefined,
) => string;

/**
Expand All @@ -67,11 +68,16 @@ type CreateOAuthState = (
function useAuthorizationSession(
provider: ProviderKind,
createState: CreateOAuthState,
signupFlow?: boolean,
): AuthorizationSession {
const [session] = useState<AuthorizationSession>(() => {
const redirectUri = buildProviderAuthCallbackUrl(provider);
return {
state: createState(redirectUri, readPosthogDistinctIdForOAuth()),
state: createState(
redirectUri,
readPosthogDistinctIdForOAuth(),
signupFlow,
),
redirectUri,
};
});
Expand Down Expand Up @@ -151,7 +157,7 @@ function createRedirectAuthorizationStrategy({
}: RedirectAuthorizationSpec): ProviderAuthorizationStrategy {
return ({ intent, signupFlow, onStart, onError, prompt }) => {
const [loading, setLoading] = useState(false);
const session = useAuthorizationSession(provider, createState);
const session = useAuthorizationSession(provider, createState, signupFlow);

return {
loading,
Expand Down Expand Up @@ -190,8 +196,13 @@ const useGoogleProviderAuthorizationStrategy: ProviderAuthorizationStrategy = ({
const [loading, setLoading] = useState(false);
const { state, redirectUri } = useAuthorizationSession(
"google",
(_redirectUri, posthogDistinctId) =>
buildSignInOAuthState({ isDesktopClient: false, posthogDistinctId }),
(_redirectUri, posthogDistinctId, signupFlow) =>
buildSignInOAuthState({
isDesktopClient: false,
posthogDistinctId,
flow: signupFlow ? "signup" : "login",
}),
signupFlow,
);

const loginOptions = useMemo<
Expand Down Expand Up @@ -234,8 +245,12 @@ const useMicrosoftProviderAuthorizationStrategy =
provider: "microsoft",
getClientId: () => getMicrosoftSignInClientId(),
notConfiguredMessage: "Microsoft sign-in is not configured",
createState: (_redirectUri, posthogDistinctId) =>
buildSignInOAuthState({ isDesktopClient: false, posthogDistinctId }),
createState: (_redirectUri, posthogDistinctId, signupFlow) =>
buildSignInOAuthState({
isDesktopClient: false,
posthogDistinctId,
flow: signupFlow ? "signup" : "login",
}),
buildAuthorizationUrl: ({ clientId, redirectUri, state, prompt }) =>
buildMicrosoftAuthorizationUrl({
clientId,
Expand All @@ -253,12 +268,13 @@ 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, posthogDistinctId) =>
createState: (redirectUri, posthogDistinctId, signupFlow) =>
btoa(
JSON.stringify({
frontendRedirectURI: redirectUri,
nonce: crypto.randomUUID(),
...(posthogDistinctId ? { ph: posthogDistinctId } : {}),
fl: signupFlow ? "signup" : "login",
}),
),
buildAuthorizationUrl: ({ clientId, state }) =>
Expand Down
20 changes: 11 additions & 9 deletions docs/frontend/frontend-runtime-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,15 +231,17 @@ 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.
2. `oauth_callback_received` (`provider`) on the client when the provider
redirect lands, plus server `oauth_callback` (`environment`, `provider`,
`flow`, `outcome`, optional `reason`) from the backend route
`GET /auth/:provider/callback`. The SPA relays the query string with
`X-Compass-OAuth-Telemetry: 1` so the server event is captured even when
JavaScript fails later. OAuth `state` carries the PostHog anon distinct id and
signup vs login flow when available.
3. Server `oauth_callback` failures cover consent denial (`access_denied`),
invalid state, missing code, token exchange failures, and user creation
failures. Client `oauth_failed` and legacy `signup_failed` still fire for
existing insights.
4. `signup_completed` or `login_completed` (`method`) after a successful
SuperTokens exchange (unchanged).

Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/auth/auth.routes.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,14 @@ export class AuthRoutes extends CommonRoutesConfig {
appleSignInCallbackLimiter,
urlencoded({ extended: false }),
(req, res) => {
authController.appleSignInCallback(req, res);
void authController.appleSignInCallback(req, res);
},
);

this.app
.route("/auth/:provider/callback")
.get(providerOAuthCallbackLimiter, (req, res) => {
authController.providerOAuthCallback(req, res);
void authController.providerOAuthCallback(req, res);
});

return this.app;
Expand Down
12 changes: 6 additions & 6 deletions packages/backend/src/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,14 @@ class AuthController {
);
};

appleSignInCallback = (
appleSignInCallback = async (
req: ReqBody<Record<string, unknown>>,
res: Res_Promise,
): void => {
): Promise<void> => {
const body = req.body ?? {};
const state = typeof body["state"] === "string" ? body["state"].trim() : "";
if (state) {
appleFormPostAnalytics({
await appleFormPostAnalytics({
state,
code: typeof body["code"] === "string" ? body["code"] : undefined,
error: typeof body["error"] === "string" ? body["error"] : undefined,
Expand All @@ -226,10 +226,10 @@ class AuthController {
res.redirect(302, result.location);
};

providerOAuthCallback = (
providerOAuthCallback = async (
req: express.Request,
res: express.Response,
): void => {
): Promise<void> => {
const provider = parseProviderOAuthCallbackProvider(
String(req.params["provider"] ?? ""),
);
Expand All @@ -240,7 +240,7 @@ class AuthController {

const telemetryOnly = req.header("x-compass-oauth-telemetry") === "1";

const resolution = handleProviderOAuthCallback({
const resolution = await handleProviderOAuthCallback({
provider,
query: req.query as Record<string, string | undefined>,
telemetryOnly,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,96 +13,75 @@ describe("provider-oauth-callback.service", () => {
expect(parseProviderOAuthCallbackProvider("unknown")).toBeNull();
});

it("captures callback and failure then redirects to the SPA", () => {
const received = spyOn(
it("captures oauth_callback then redirects to the SPA", async () => {
const capture = spyOn(
analytics.providerOAuthSignupAnalytics,
"captureCallbackReceived",
).mockImplementation(() => undefined);
const failed = spyOn(
analytics.providerOAuthSignupAnalytics,
"captureFailed",
).mockImplementation(() => undefined);
"captureCallbackQuery",
).mockResolvedValue(true);

const state = buildSignInOAuthState({
isDesktopClient: false,
posthogDistinctId: "anon-abc",
});

const result = handleProviderOAuthCallback({
const result = await 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(capture).toHaveBeenCalledWith({
provider: "google",
state,
code: undefined,
providerError: "access_denied",
});
expect(result).toEqual({
mode: "redirect",
location: expect.stringContaining(
"http://localhost:9080/auth/google/callback",
),
});

received.mockRestore();
failed.mockRestore();
capture.mockRestore();
});

it("returns telemetry-only without redirect", () => {
const received = spyOn(
it("returns telemetry-only without redirect", async () => {
const capture = spyOn(
analytics.providerOAuthSignupAnalytics,
"captureCallbackReceived",
).mockImplementation(() => undefined);
spyOn(
analytics.providerOAuthSignupAnalytics,
"captureFailed",
).mockImplementation(() => undefined);
"captureCallbackQuery",
).mockResolvedValue(true);

const state = buildSignInOAuthState({ isDesktopClient: false });
const result = handleProviderOAuthCallback({
const result = await handleProviderOAuthCallback({
provider: "microsoft",
query: { state, code: "auth-code" },
telemetryOnly: true,
});

expect(result).toEqual({ mode: "telemetry" });
received.mockRestore();
capture.mockRestore();
});

it("captures Apple form-post callback and failure on the same analytics path", () => {
const received = spyOn(
analytics.providerOAuthSignupAnalytics,
"captureCallbackReceived",
).mockImplementation(() => undefined);
const failed = spyOn(
it("captures Apple form-post callback on the same analytics path", async () => {
const capture = spyOn(
analytics.providerOAuthSignupAnalytics,
"captureFailed",
).mockImplementation(() => undefined);
"captureCallbackQuery",
).mockResolvedValue(true);

appleFormPostAnalytics({
await appleFormPostAnalytics({
state: "apple-state",
error: "access_denied",
});

expect(received).toHaveBeenCalledWith({
expect(capture).toHaveBeenCalledWith({
provider: "apple",
state: "apple-state",
code: undefined,
providerError: "access_denied",
});
expect(failed).toHaveBeenCalledWith(
expect.objectContaining({
provider: "apple",
reason: "access_denied",
providerError: "access_denied",
}),
);

received.mockRestore();
failed.mockRestore();
capture.mockRestore();
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { oauthFailedReasonFromCallbackQuery } from "@core/analytics/oauth-signup.events";
import {
type ProviderKind,
ProviderKindSchema,
Expand Down Expand Up @@ -30,28 +29,16 @@ function queryField(
return typeof value === "string" && value.length > 0 ? value : undefined;
}

function captureOAuthCallbackQueryAnalytics(
async function captureOAuthCallbackQueryAnalytics(
provider: ProviderKind,
query: { state?: string; code?: string; providerError?: string },
): void {
providerOAuthSignupAnalytics.captureCallbackReceived({
): Promise<void> {
await providerOAuthSignupAnalytics.captureCallbackQuery({
provider,
state: query.state,
});

const failureReason = oauthFailedReasonFromCallbackQuery({
state: query.state,
code: query.code,
providerError: query.providerError,
providerError: query.providerError ?? null,
});
if (failureReason) {
providerOAuthSignupAnalytics.captureFailed({
provider,
state: query.state,
reason: failureReason,
providerError: query.providerError,
});
}
}

function spaCallbackUrl(
Expand All @@ -76,17 +63,17 @@ export function parseProviderOAuthCallbackProvider(
return parsed.success ? parsed.data : null;
}

export function handleProviderOAuthCallback(input: {
export async function handleProviderOAuthCallback(input: {
provider: ProviderKind;
query: ProviderOAuthCallbackQuery;
telemetryOnly: boolean;
frontendUrl?: string;
}): ProviderOAuthCallbackResolution {
}): Promise<ProviderOAuthCallbackResolution> {
const state = queryField(input.query, "state");
const code = queryField(input.query, "code");
const providerError = queryField(input.query, "error");

captureOAuthCallbackQueryAnalytics(input.provider, {
await captureOAuthCallbackQueryAnalytics(input.provider, {
state,
code,
providerError,
Expand All @@ -102,12 +89,12 @@ export function handleProviderOAuthCallback(input: {
};
}

export function appleFormPostAnalytics(input: {
export async function appleFormPostAnalytics(input: {
state: string;
code?: string;
error?: string;
}): void {
captureOAuthCallbackQueryAnalytics("apple", {
}): Promise<void> {
await captureOAuthCallbackQueryAnalytics("apple", {
state: input.state,
code: input.code,
providerError: input.error,
Expand Down
Loading
Loading