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
15 changes: 15 additions & 0 deletions .changeset/oauth-profile-error-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'seamless-auth-api': minor
---

Surface actionable OAuth callback failure codes.

The OAuth callback previously collapsed every profile failure into a generic
`400 { error: 'OAuth login failed' }`, so a user whose provider returned no email
(the most common case, for example a GitHub account with no public email) had no
way to know what to fix. The callback now returns a stable machine-readable `code`
alongside the existing `error` string for the curated, user-actionable cases:
`oauth_missing_email`, `oauth_email_not_verified`, and `oauth_missing_subject`.
Unexpected internal failures still return the generic message with no detail, and
the audit event records the specific reason instead of the blanket
`callback_failed` for the known cases.
15 changes: 15 additions & 0 deletions src/controllers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
fetchOAuthProfile,
getEnabledOAuthProviders,
getOAuthProvider,
OAuthProfileError,
resolveOAuthRedirectUri,
resolveOAuthUser,
serializeOAuthProvider,
Expand Down Expand Up @@ -157,6 +158,20 @@ export async function finishOAuthLogin(req: Request, res: Response) {
});
} catch (error) {
logger.error(`OAuth callback failed for provider ${provider.id}: ${error}`);

if (error instanceof OAuthProfileError) {
await AuthEventService.log({
type: 'oauth_login_failed',
req,
metadata: { providerId: provider.id, reason: error.code },
});

return res.status(400).json({
error: error.message,
code: error.code,
});
}

await AuthEventService.log({
type: 'oauth_login_failed',
req,
Expand Down
3 changes: 2 additions & 1 deletion src/routes/oauth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
StartOAuthLoginRequestSchema,
} from '../schemas/oauth.requests.js';
import {
OAuthLoginErrorResponseSchema,
OAuthLoginSuccessResponseSchema,
OAuthProvidersResponseSchema,
StartOAuthLoginResponseSchema,
Expand Down Expand Up @@ -65,7 +66,7 @@ oauthRouter.post(
body: FinishOAuthLoginRequestSchema,
response: {
200: OAuthLoginSuccessResponseSchema,
400: InternalErrorSchema,
400: OAuthLoginErrorResponseSchema,
403: InternalErrorSchema,
404: InternalErrorSchema,
},
Expand Down
8 changes: 8 additions & 0 deletions src/schemas/oauth.responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,11 @@ export const StartOAuthLoginResponseSchema = z.object({
export const OAuthLoginSuccessResponseSchema = RefreshSuccessResponseSchema.omit({
sessionId: true,
});

export const OAuthLoginErrorResponseSchema = z.object({
message: z.string().optional(),
error: z.string(),
code: z
.enum(['oauth_missing_email', 'oauth_email_not_verified', 'oauth_missing_subject'])
.optional(),
});
29 changes: 26 additions & 3 deletions src/services/oauthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ export type OAuthProfile = {
raw: Record<string, unknown>;
};

export type OAuthProfileErrorCode =
| 'oauth_missing_subject'
| 'oauth_missing_email'
| 'oauth_email_not_verified';

// Curated, user-actionable profile failures. The controller forwards the code
// to the caller; every other failure stays a bare Error and returns generic.
export class OAuthProfileError extends Error {
readonly code: OAuthProfileErrorCode;

constructor(code: OAuthProfileErrorCode, message: string) {
super(message);
this.name = 'OAuthProfileError';
this.code = code;
}
}

function stateSecret() {
const explicit = process.env.OAUTH_STATE_SECRET?.trim();
if (explicit) return explicit;
Expand Down Expand Up @@ -379,11 +396,17 @@ export async function fetchOAuthProfile(provider: OAuthProviderConfig, accessTok
const name = getJsonPathValue(raw, provider.nameJsonPath);

if (typeof subject !== 'string' && typeof subject !== 'number') {
throw new Error('OAuth profile did not include a provider subject');
throw new OAuthProfileError(
'oauth_missing_subject',
'OAuth profile did not include a provider subject',
);
}

if (!email) {
throw new Error('OAuth profile did not include an email address');
throw new OAuthProfileError(
'oauth_missing_email',
'OAuth profile did not include an email address',
);
}

const emailVerified =
Expand All @@ -394,7 +417,7 @@ export async function fetchOAuthProfile(provider: OAuthProviderConfig, accessTok
: undefined;

if (emailVerified === false || (provider.requireEmailVerified && emailVerified !== true)) {
throw new Error('OAuth profile email is not verified');
throw new OAuthProfileError('oauth_email_not_verified', 'OAuth profile email is not verified');
}

return {
Expand Down
76 changes: 76 additions & 0 deletions tests/integration/oauth/oauth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,80 @@ describe('OAuth routes', () => {
}),
);
});

const callbackWithProfile = async (profile: Record<string, unknown>) => {
const start = await request(app).post('/oauth/google/start').send({
redirectUri: 'http://localhost:5174/oauth/callback',
});

const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({ access_token: 'provider-token' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => profile,
});

return request(app).post('/oauth/google/callback').send({
code: 'oauth-code',
state: start.body.state,
});
};

it('surfaces oauth_missing_email when the profile has no email', async () => {
const res = await callbackWithProfile({ sub: 'provider-user', email_verified: true });

expect(res.status).toBe(400);
expect(res.body).toEqual({
error: 'OAuth profile did not include an email address',
code: 'oauth_missing_email',
});
expect(Session.create).not.toHaveBeenCalled();
expect(AuthEventService.log).toHaveBeenCalledWith(
expect.objectContaining({
type: 'oauth_login_failed',
metadata: { providerId: 'google', reason: 'oauth_missing_email' },
}),
);
});

it('surfaces oauth_email_not_verified when the provider reports an unverified email', async () => {
const res = await callbackWithProfile({
sub: 'provider-user',
email: 'person@example.com',
email_verified: false,
});

expect(res.status).toBe(400);
expect(res.body).toEqual({
error: 'OAuth profile email is not verified',
code: 'oauth_email_not_verified',
});
expect(AuthEventService.log).toHaveBeenCalledWith(
expect.objectContaining({
type: 'oauth_login_failed',
metadata: { providerId: 'google', reason: 'oauth_email_not_verified' },
}),
);
});

it('surfaces oauth_missing_subject when the profile has no provider subject', async () => {
const res = await callbackWithProfile({ email: 'person@example.com', email_verified: true });

expect(res.status).toBe(400);
expect(res.body).toEqual({
error: 'OAuth profile did not include a provider subject',
code: 'oauth_missing_subject',
});
expect(AuthEventService.log).toHaveBeenCalledWith(
expect.objectContaining({
type: 'oauth_login_failed',
metadata: { providerId: 'google', reason: 'oauth_missing_subject' },
}),
);
});
});
Loading