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();