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
5 changes: 5 additions & 0 deletions .changeset/lemon-doors-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@seamless-auth/react': patch
---

Read the OAuth failure code from a nested `details` object when the error body does not carry it at the top level. `getOAuthErrorCode` only looked at a top-level `code`, which is where the auth API puts it, so a proxy that normalized the error body and moved the siblings of `error` under `details` silently downgraded OAuth messaging to a generic failure. Both locations are accepted now, the top level still wins, and the allowlist is unchanged: an unrecognized code in either place still returns `undefined`.
16 changes: 15 additions & 1 deletion src/client/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,23 @@ const OAUTH_ERROR_CODES: Record<OAuthErrorCode, true> = {
oauth_missing_subject: true,
};

function readCode(body: unknown): unknown {
if (typeof body !== 'object' || body === null) {
return undefined;
}

return (body as { code?: unknown }).code;
}

/**
* Read the OAuth failure code off a result error. Returns `undefined` for
* anything unrecognized, including codes added by a newer API, so callers keep
* their generic messaging instead of showing a raw code.
*
* The auth API puts `code` at the top level of the error body, but a proxy in
* front of it may normalize that body and nest the siblings of `error` under
* `details`. Both locations are accepted so such a proxy does not silently
* downgrade OAuth messaging to a generic failure.
*/
export function getOAuthErrorCode(error: unknown): OAuthErrorCode | undefined {
if (!(error instanceof SeamlessAuthError)) {
Expand All @@ -60,7 +73,8 @@ export function getOAuthErrorCode(error: unknown): OAuthErrorCode | undefined {
return undefined;
}

const { code } = error.body as { code?: unknown };
const code =
readCode(error.body) ?? readCode((error.body as { details?: unknown }).details);

return typeof code === 'string' &&
Object.prototype.hasOwnProperty.call(OAUTH_ERROR_CODES, code)
Expand Down
55 changes: 55 additions & 0 deletions tests/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,27 @@ describe('getOAuthErrorCode', () => {
}
);

it.each(['oauth_missing_email', 'oauth_email_not_verified', 'oauth_missing_subject'])(
'returns the known code %s nested under details',
code => {
const error = new SeamlessAuthError('Sign-in failed', 400, {
error: 'Sign-in failed',
details: { code },
});

expect(getOAuthErrorCode(error)).toBe(code);
}
);

it('prefers the top-level code over the nested one', () => {
const error = new SeamlessAuthError('Sign-in failed', 400, {
code: 'oauth_missing_email',
details: { code: 'oauth_missing_subject' },
});

expect(getOAuthErrorCode(error)).toBe('oauth_missing_email');
});

it('ignores a code the SDK does not know', () => {
const error = new SeamlessAuthError('Sign-in failed', 400, {
code: 'oauth_something_new',
Expand All @@ -91,19 +112,53 @@ describe('getOAuthErrorCode', () => {
expect(getOAuthErrorCode(error)).toBeUndefined();
});

it('ignores a nested code the SDK does not know', () => {
const error = new SeamlessAuthError('Sign-in failed', 400, {
details: { code: 'oauth_something_new' },
});

expect(getOAuthErrorCode(error)).toBeUndefined();
});

it('returns undefined when the body carries no code', () => {
expect(
getOAuthErrorCode(new SeamlessAuthError('Sign-in failed', 400, { error: 'nope' }))
).toBeUndefined();
});

it('returns undefined when details carries no code', () => {
expect(
getOAuthErrorCode(
new SeamlessAuthError('Sign-in failed', 400, {
error: 'nope',
details: { requestId: 'abc' },
})
)
).toBeUndefined();
});

it('returns undefined for a missing or non-object body', () => {
expect(getOAuthErrorCode(new SeamlessAuthError('nope', 500))).toBeUndefined();
expect(
getOAuthErrorCode(new SeamlessAuthError('nope', 500, undefined))
).toBeUndefined();
expect(getOAuthErrorCode(new SeamlessAuthError('nope', 500, null))).toBeUndefined();
expect(
getOAuthErrorCode(new SeamlessAuthError('nope', 500, 'oauth_missing_email'))
).toBeUndefined();
});

it('returns undefined for a non-object details', () => {
expect(
getOAuthErrorCode(
new SeamlessAuthError('nope', 500, { details: 'oauth_missing_email' })
)
).toBeUndefined();
expect(
getOAuthErrorCode(new SeamlessAuthError('nope', 500, { details: null }))
).toBeUndefined();
});

it('returns undefined for anything that is not a SeamlessAuthError', () => {
expect(getOAuthErrorCode(new Error('boom'))).toBeUndefined();
expect(getOAuthErrorCode({ body: { code: 'oauth_missing_email' } })).toBeUndefined();
Expand Down
Loading