diff --git a/.changeset/oauth-start-failure-detail.md b/.changeset/oauth-start-failure-detail.md new file mode 100644 index 00000000..47098fe6 --- /dev/null +++ b/.changeset/oauth-start-failure-detail.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': patch +--- + +A failed OAuth start now says why. The `oauth_login_failed` event with reason `start_failed` carries a `detail` of `redirect_not_allowed` or `provider_unusable`, and the error is logged. A refused redirect is logged by scheme and host only. The answer to the client is unchanged. diff --git a/src/controllers/oauth.ts b/src/controllers/oauth.ts index 35a3e219..4c0568de 100644 --- a/src/controllers/oauth.ts +++ b/src/controllers/oauth.ts @@ -25,6 +25,7 @@ import { OAuthProfileError, oauthProfileFromIdToken, OAuthProviderRetiredError, + OAuthRedirectNotAllowedError, resolveOAuthProvider, resolveOAuthRedirectUri, resolveOAuthUser, @@ -98,11 +99,21 @@ export async function startOAuthLogin(req: RouteRequest, res: Response) { ...(codeChallenge ? { codeChallenge } : {}), }), }); - } catch { + } catch (error) { + // The client only ever sees a generic answer, so this is the one place an operator + // can learn why. A redirect refusal is a client or allowlist problem and anything + // else is the provider's configuration or discovery, and those are fixed differently. + const detail = + error instanceof OAuthRedirectNotAllowedError ? 'redirect_not_allowed' : 'provider_unusable'; + + // The provider is on the event below. The error names a refused redirect by its parsed + // scheme and host only, so nothing the caller sent reaches the log as written. + logger.warn(`OAuth start failed: ${String(error)}`); + await AuthEventService.log({ type: 'oauth_login_failed', req, - metadata: { providerId: provider.id, reason: 'start_failed' }, + metadata: { providerId: provider.id, reason: 'start_failed', detail }, }); return res.status(400).json({ diff --git a/src/services/oauthService.ts b/src/services/oauthService.ts index 3a08f635..a47e27f0 100644 --- a/src/services/oauthService.ts +++ b/src/services/oauthService.ts @@ -87,6 +87,24 @@ export class OAuthProfileError extends Error { } } +/** + * The client asked for a redirect the provider config does not allow. The message names + * the scheme and host only: the rest of the value is whatever the caller sent. + */ +export class OAuthRedirectNotAllowedError extends Error { + constructor(requestedRedirectUri: string) { + let target = 'an unparsable value'; + try { + const url = new URL(requestedRedirectUri); + target = `${url.protocol}//${url.host}`; + } catch { + // Named as unparsable above. + } + super(`OAuth redirect URI is not allowed: ${target}`); + this.name = 'OAuthRedirectNotAllowedError'; + } +} + export class OAuthProviderRetiredError extends Error { readonly code = 'oauth_provider_retired'; readonly userId: string; @@ -259,7 +277,7 @@ export async function resolveOAuthRedirectUri( if (requestedRedirectUri) { if (!allowedRedirect(requestedRedirectUri, providerAllowlist, config.origins)) { - throw new Error('OAuth redirect URI is not allowed'); + throw new OAuthRedirectNotAllowedError(requestedRedirectUri); } return requestedRedirectUri; diff --git a/tests/integration/oauth/oauth.spec.ts b/tests/integration/oauth/oauth.spec.ts index 62273c90..1c7f6002 100644 --- a/tests/integration/oauth/oauth.spec.ts +++ b/tests/integration/oauth/oauth.spec.ts @@ -570,6 +570,12 @@ describe('OAuth routes', () => { vi.unstubAllGlobals(); expect(res.status).toBe(400); expect(res.body.error).toBe('OAuth start failed'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'oauth_login_failed', + metadata: { providerId: 'idp', reason: 'start_failed', detail: 'provider_unusable' }, + }), + ); }); it('refuses an ID token minted for a different sign-in', async () => { @@ -621,7 +627,22 @@ describe('OAuth routes', () => { expect(AuthEventService.log).toHaveBeenCalledWith( expect.objectContaining({ type: 'oauth_login_failed', - metadata: { providerId: 'google', reason: 'start_failed' }, + metadata: { providerId: 'google', reason: 'start_failed', detail: 'redirect_not_allowed' }, + }), + ); + }); + + it('refuses an app scheme the provider does not allow, and says that is why', async () => { + const res = await request(app).post('/oauth/google/start').send({ + redirectUri: 'myapp://oauth/callback?code=secret', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('OAuth start failed'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'oauth_login_failed', + metadata: { providerId: 'google', reason: 'start_failed', detail: 'redirect_not_allowed' }, }), ); });