Skip to content

OAuth callback discards actionable failure reasons, including missing profile email #91

Description

@Bccorb

Summary

The OAuth callback computes specific, user-actionable failure reasons and then discards them at the HTTP boundary, returning a generic 400 { error: 'OAuth login failed' } for all of them. The most common case in practice is a provider that does not return an email address, which the user could fix if they were told about it.

Current behavior

fetchOAuthProfile in src/services/oauthService.ts throws plain Errors for several distinguishable conditions:

  • 'OAuth token response did not include an access token'
  • 'OAuth profile fetch failed with status <n>'
  • 'OAuth profile did not include a provider subject'
  • 'OAuth profile did not include an email address' (line 386)
  • 'OAuth profile email is not verified' (line 397)

finishOAuthLogin in src/controllers/oauth.ts wraps the whole flow in one try, and the catch collapses every one of them:

} catch (error) {
  logger.error(`OAuth callback failed for provider ${provider.id}: ${error}`);
  await AuthEventService.log({
    type: 'oauth_login_failed',
    req,
    metadata: { providerId: provider.id, reason: 'callback_failed' },
  });

  return res.status(400).json({
    error: 'OAuth login failed',
  });
}

So the reason is written to the server log and the audit event as callback_failed, but never reaches the caller.

Impact

A user signing in with a GitHub account that has no public email gets "OAuth login failed" and no way to know what to change. They typically retry indefinitely. Consuming apps cannot do better, because the detail never crosses the wire.

The two email cases are the ones worth surfacing: they are about the user's own provider profile and are fixable by the user (make the email public, or verify it).

Proposed change

Distinguish known profile failures from unexpected ones, and return a stable machine-readable code alongside the existing error string.

  1. Introduce a tagged error for the known conditions, for example an OAuthProfileError carrying a code, thrown from fetchOAuthProfile instead of a bare Error.
  2. In the controller catch, map that error to a response that keeps the current error field for compatibility and adds code:
{ "error": "OAuth profile did not include an email address", "code": "oauth_missing_email" }

Suggested codes:

  • oauth_missing_email
  • oauth_email_not_verified
  • oauth_missing_subject
  1. Keep the existing generic { error: 'OAuth login failed' } for anything not on that curated list, so unexpected internals are not exposed.
  2. Use the specific reason in the AuthEventService metadata too, instead of the blanket callback_failed, so the audit trail distinguishes them.

Deliberate scope limit

Only expose the curated set above. Do not forward upstream provider detail such as 'OAuth profile fetch failed with status <n>' or raw provider response bodies, since that leaks integration internals and is not actionable for the end user. This is why the change is a small allowlist rather than passing error.message through.

Client side is already prepared

@seamless-auth/react currently discards the OAuth error body as well. That is being fixed in fells-code/seamless-auth-react#83, which throws a SeamlessAuthError carrying the HTTP status and the parsed response body. Once this API change lands, consuming apps can read body.code and map oauth_missing_email to their own messaging.

That SDK change alone is not sufficient: without this ticket, the only thing available to surface for the missing-email case is the generic OAuth login failed.

Acceptance criteria

  • The missing-email and unverified-email cases return a distinct, stable code
  • The existing error string field is preserved for backward compatibility
  • Unexpected failures still return the generic message with no internal detail
  • Audit events record the specific reason rather than callback_failed for the known cases
  • Tests cover each surfaced code and confirm unknown errors stay generic

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions