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.
- Introduce a tagged error for the known conditions, for example an
OAuthProfileError carrying a code, thrown from fetchOAuthProfile instead of a bare Error.
- 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
- Keep the existing generic
{ error: 'OAuth login failed' } for anything not on that curated list, so unexpected internals are not exposed.
- 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
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
fetchOAuthProfileinsrc/services/oauthService.tsthrows plainErrors 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)finishOAuthLogininsrc/controllers/oauth.tswraps the whole flow in onetry, and thecatchcollapses every one of them: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
errorstring.OAuthProfileErrorcarrying acode, thrown fromfetchOAuthProfileinstead of a bareError.catch, map that error to a response that keeps the currenterrorfield for compatibility and addscode:{ "error": "OAuth profile did not include an email address", "code": "oauth_missing_email" }Suggested codes:
oauth_missing_emailoauth_email_not_verifiedoauth_missing_subject{ error: 'OAuth login failed' }for anything not on that curated list, so unexpected internals are not exposed.AuthEventServicemetadata too, instead of the blanketcallback_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 passingerror.messagethrough.Client side is already prepared
@seamless-auth/reactcurrently discards the OAuth error body as well. That is being fixed in fells-code/seamless-auth-react#83, which throws aSeamlessAuthErrorcarrying the HTTPstatusand the parsed responsebody. Once this API change lands, consuming apps can readbody.codeand mapoauth_missing_emailto 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
codeerrorstring field is preserved for backward compatibilitycallback_failedfor the known cases