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
29 changes: 29 additions & 0 deletions src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,35 @@ export function resolveResponseAuthMethod(
}
}

/** Maps a session `auth_method` (snake_case) back to a spec-valid response authentication_method. */
const SESSION_AUTH_METHOD_RESPONSE_VALUES: Record<string, string> = {
password: 'Password',
magic_code: 'MagicAuth',
sso: 'SSO',
passkey: 'Passkey',
cross_app_auth: 'CrossAppAuth',
external_auth: 'ExternalAuth',
impersonation: 'Impersonation',
migrated_session: 'MigratedSession',
};

/**
* Resolve the response authentication_method for a refresh_token grant, which reuses an existing
* session rather than authenticating fresh. Real WorkOS echoes the session's *original* method, so
* we recover it from the reused session's stored `auth_method` (snake_case) instead of the grant's
* hard-coded 'OAuth' category — a password login that refreshes truthfully reports 'Password'.
*
* Generic 'oauth' has no provider recorded on the session, so it falls back to the user's
* oauth_provider (else omitted); 'unknown' and any unmapped value are omitted rather than guessed.
*/
export function resolveSessionResponseAuthMethod(
sessionAuthMethod: string,
opts?: { oauthProvider?: string | null },
): string | undefined {
if (sessionAuthMethod === 'oauth') return opts?.oauthProvider ?? undefined;
return SESSION_AUTH_METHOD_RESPONSE_VALUES[sessionAuthMethod];
}

/** authentication.* event names per method, resolved from the spec-generated catalog. */
export const AUTH_EVENTS: Record<string, { succeeded: WorkOSEventName; failed: WorkOSEventName }> = {
OAuth: { succeeded: EVENTS.authenticationOauthSucceeded, failed: EVENTS.authenticationOauthFailed },
Expand Down
4 changes: 4 additions & 0 deletions src/workos/routes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ describe('Auth routes', () => {
const authBody = await json(authRes);
const oldRefresh = authBody.refresh_token;
expect(oldRefresh).toBeDefined();
expect(authBody.authentication_method).toBe('Password');

// Use refresh token
const refreshRes = await app.request('/user_management/authenticate', {
Expand All @@ -253,6 +254,9 @@ describe('Auth routes', () => {
expect(refreshBody.access_token).toBeDefined();
expect(refreshBody.refresh_token).toBeDefined();
expect(refreshBody.refresh_token).not.toBe(oldRefresh);
// A refresh reuses the session, so it echoes the original method ('Password') rather than the
// grant's internal 'OAuth' category — which would otherwise drop the field for this user.
expect(refreshBody.authentication_method).toBe('Password');

// Old refresh token should be invalidated (rotation)
const retryRes = await app.request('/user_management/authenticate', {
Expand Down
14 changes: 10 additions & 4 deletions src/workos/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
sealSession,
AUTH_METHOD_SESSION_VALUES,
resolveResponseAuthMethod,
resolveSessionResponseAuthMethod,
emitAuthenticationEvent,
generateCode,
formatAuthChallenge,
Expand Down Expand Up @@ -598,10 +599,15 @@ export function authRoutes(ctx: RouteContext): void {
// The response enum is PascalCase/provider-specific — the internal 'OAuth'/'MFA'/
// 'EmailVerification' categories aren't valid here. Resolve to a spec-valid value, or
// undefined (key omitted, like impersonator below) when the concrete method is unknown
// rather than inventing a provider. Mirrors the session's sessionAuthMethod precedence.
authentication_method: resolveResponseAuthMethod(sessionAuthMethod ?? authMethod, {
oauthProvider: updatedUser.oauth_provider,
}),
// rather than inventing a provider. A refresh reuses an existing session, so it echoes that
// session's original method; a fresh login mirrors the session's sessionAuthMethod precedence.
authentication_method: isFreshLogin
? resolveResponseAuthMethod(sessionAuthMethod ?? authMethod, {
oauthProvider: updatedUser.oauth_provider,
})
: resolveSessionResponseAuthMethod(session.auth_method, {
oauthProvider: updatedUser.oauth_provider,
}),
sealed_session: sealedSession,
impersonator: updatedUser.impersonator ?? undefined,
});
Expand Down