From 8558b31a510309e305743eb080ad43f45a97c802 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 29 Jul 2026 19:59:35 -0400 Subject: [PATCH] fix(oauth): read the OAuth error code from a nested details object getOAuthErrorCode only looked at a top-level `code`, which is where the auth API puts it. That made the SDK depend on the API's exact top-level error shape: any proxy that normalized the body and moved the siblings of `error` under `details` silently dropped the code, so the UI fell back to generic messaging instead of the user-actionable message. seamless-auth-server#124 hit this and had to forward auth API error bodies verbatim to work around it, which now constrains error handling there. Accept both locations, top level first, and keep the allowlist unchanged so an unrecognized code in either place still returns undefined. Closes #120 --- .changeset/lemon-doors-listen.md | 5 +++ src/client/errors.ts | 16 +++++++++- tests/errors.test.ts | 55 ++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 .changeset/lemon-doors-listen.md diff --git a/.changeset/lemon-doors-listen.md b/.changeset/lemon-doors-listen.md new file mode 100644 index 0000000..13cb2a8 --- /dev/null +++ b/.changeset/lemon-doors-listen.md @@ -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`. diff --git a/src/client/errors.ts b/src/client/errors.ts index 082ada5..9f5ccdf 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -46,10 +46,23 @@ const OAUTH_ERROR_CODES: Record = { 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)) { @@ -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) diff --git a/tests/errors.test.ts b/tests/errors.test.ts index f44cf8b..ff90389 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -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', @@ -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();