From 84227832d35f2bea8c33c5106e5fbcaacf375c34 Mon Sep 17 00:00:00 2001 From: Maximilian Haupt Date: Thu, 13 Aug 2026 13:35:42 +0200 Subject: [PATCH 1/2] fix: log out on invalid refresh grants Retrying invalid_grant keeps the SDK in INITIALISING for minutes with a dead refresh token. Clear stored auth immediately so the next launch does not repeat the same failure. --- .../src/Contentpass.test.ts | 67 ++++++++++++++++++- .../src/Contentpass.ts | 21 +++++- .../src/consts/oidcConsts.test.ts | 18 +++++ .../src/consts/oidcConsts.ts | 17 +++++ 4 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 packages/react-native-contentpass/src/consts/oidcConsts.test.ts diff --git a/packages/react-native-contentpass/src/Contentpass.test.ts b/packages/react-native-contentpass/src/Contentpass.test.ts index 3d41ce8..f4ac873 100644 --- a/packages/react-native-contentpass/src/Contentpass.test.ts +++ b/packages/react-native-contentpass/src/Contentpass.test.ts @@ -205,6 +205,38 @@ describe('Contentpass', () => { }); }); + it('should log out immediately when an expired stored token fails to refresh with invalid_grant', async () => { + (oidcAuthStorageMock.getOidcAuthState as jest.Mock).mockResolvedValue({ + ...EXAMPLE_AUTH_RESULT, + accessTokenExpirationDate: '2024-12-02T11:53:56.272Z', + }); + const refreshError = Object.assign(new Error('invalid_grant'), { + code: 'invalid_grant', + }); + refreshSpy.mockRejectedValue(refreshError); + + contentpass = new Contentpass(config); + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + await jest.advanceTimersByTimeAsync(100); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(oidcAuthStorageMock.clearOidcAuthState).toHaveBeenCalled(); + expect(reportErrorSpy).toHaveBeenCalledWith(refreshError, { + msg: 'Failed to refresh token with a non-retryable error', + }); + expect(contentpassStates[contentpassStates.length - 1]).toEqual({ + state: 'UNAUTHENTICATED', + hasValidSubscription: false, + }); + + await jest.advanceTimersByTimeAsync(120000); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + it('should enable logger if logLevel is set', () => { contentpass = new Contentpass({ ...config, @@ -385,7 +417,7 @@ describe('Contentpass', () => { hasValidSubscription: true, }); - // after 6 retries the state should change to error + // after 6 retries the state should change to unauthenticated await jest.advanceTimersByTimeAsync(120001); expect(reportErrorSpy).toHaveBeenCalledTimes(1); expect(reportErrorSpy).toHaveBeenCalledWith(refreshError, { @@ -397,6 +429,39 @@ describe('Contentpass', () => { hasValidSubscription: false, }); }); + + it('should log out immediately when refresh fails with invalid_grant', async () => { + const contentpassStates: ContentpassState[] = []; + contentpass.registerObserver((state) => { + contentpassStates.push(state); + }); + + await contentpass.authenticate(); + + const expirationDate = new Date( + EXAMPLE_AUTH_RESULT.accessTokenExpirationDate + ).getTime(); + const expectedDelay = expirationDate - NOW; + const refreshError = Object.assign(new Error('invalid_grant'), { + code: 'invalid_grant', + }); + refreshSpy.mockRejectedValue(refreshError); + + await jest.advanceTimersByTimeAsync(expectedDelay); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(oidcAuthStorageMock.clearOidcAuthState).toHaveBeenCalled(); + expect(reportErrorSpy).toHaveBeenCalledWith(refreshError, { + msg: 'Failed to refresh token with a non-retryable error', + }); + expect(contentpassStates[contentpassStates.length - 1]).toEqual({ + state: 'UNAUTHENTICATED', + hasValidSubscription: false, + }); + + await jest.advanceTimersByTimeAsync(120000); + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); }); describe('registerObserver', () => { diff --git a/packages/react-native-contentpass/src/Contentpass.ts b/packages/react-native-contentpass/src/Contentpass.ts index a17a7e5..9ec2e1b 100644 --- a/packages/react-native-contentpass/src/Contentpass.ts +++ b/packages/react-native-contentpass/src/Contentpass.ts @@ -11,7 +11,11 @@ import { type AuthorizeResult, refresh, } from 'react-native-app-auth'; -import { REFRESH_TOKEN_RETRIES, SCOPES } from './consts/oidcConsts'; +import { + isNonRetryableRefreshError, + REFRESH_TOKEN_RETRIES, + SCOPES, +} from './consts/oidcConsts'; import { RefreshTokenStrategy } from './types/RefreshTokenStrategy'; import fetchContentpassToken from './contentpassTokenUtils/fetchContentpassToken'; import validateSubscription from './contentpassTokenUtils/validateSubscription'; @@ -165,6 +169,11 @@ export default class Contentpass implements ContentpassInterface { public logout = async () => { logger.info('Logging out and clearing auth state'); + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.oidcAuthState = null; await this.authStateStorage.clearOidcAuthState(); this.changeContentpassState({ state: ContentpassStateType.UNAUTHENTICATED, @@ -323,7 +332,15 @@ export default class Contentpass implements ContentpassInterface { }; private onRefreshTokenError = async (counter: number, err: Error) => { - // FIXME: add handling for specific error to not retry in every case + if (isNonRetryableRefreshError(err)) { + logger.warn({ err }, 'Refresh token rejected, logging out'); + reportError(err, { + msg: 'Failed to refresh token with a non-retryable error', + }); + await this.logout(); + return; + } + if (counter < REFRESH_TOKEN_RETRIES) { logger.warn({ err, counter }, 'Failed to refresh token, retrying'); const delay = counter * 1000 * 10; diff --git a/packages/react-native-contentpass/src/consts/oidcConsts.test.ts b/packages/react-native-contentpass/src/consts/oidcConsts.test.ts new file mode 100644 index 0000000..5134f32 --- /dev/null +++ b/packages/react-native-contentpass/src/consts/oidcConsts.test.ts @@ -0,0 +1,18 @@ +import { isNonRetryableRefreshError } from './oidcConsts'; + +describe('isNonRetryableRefreshError', () => { + it('returns true for permanent OAuth token errors', () => { + expect(isNonRetryableRefreshError({ code: 'invalid_grant' })).toBe(true); + expect(isNonRetryableRefreshError({ code: 'invalid_client' })).toBe(true); + expect(isNonRetryableRefreshError({ code: 'unauthorized_client' })).toBe( + true + ); + }); + + it('returns false for transient or unknown errors', () => { + expect(isNonRetryableRefreshError(new Error('network'))).toBe(false); + expect(isNonRetryableRefreshError({ code: 'server_error' })).toBe(false); + expect(isNonRetryableRefreshError({ code: 2002 })).toBe(false); + expect(isNonRetryableRefreshError(null)).toBe(false); + }); +}); diff --git a/packages/react-native-contentpass/src/consts/oidcConsts.ts b/packages/react-native-contentpass/src/consts/oidcConsts.ts index 21134ef..5e4a446 100644 --- a/packages/react-native-contentpass/src/consts/oidcConsts.ts +++ b/packages/react-native-contentpass/src/consts/oidcConsts.ts @@ -1,3 +1,20 @@ export const SCOPES = ['openid', 'offline_access', 'contentpass']; export const TOKEN_ENDPOINT = `/auth/oidc/token`; export const REFRESH_TOKEN_RETRIES = 6; + +export const NON_RETRYABLE_REFRESH_ERROR_CODES = new Set([ + 'invalid_grant', + 'invalid_client', + 'unauthorized_client', +]); + +export function isNonRetryableRefreshError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false; + } + + const code = (err as { code?: unknown }).code; + return ( + typeof code === 'string' && NON_RETRYABLE_REFRESH_ERROR_CODES.has(code) + ); +} From 906669bf5971ed69b7705a2fcf90aa911b62184c Mon Sep 17 00:00:00 2001 From: Maximilian Haupt Date: Thu, 13 Aug 2026 13:41:56 +0200 Subject: [PATCH 2/2] chore: bump versions --- examples/consentmanager/CHANGELOG.md | 9 +++++++++ examples/consentmanager/package.json | 2 +- packages/react-native-contentpass/CHANGELOG.md | 6 ++++++ packages/react-native-contentpass/package.json | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/examples/consentmanager/CHANGELOG.md b/examples/consentmanager/CHANGELOG.md index a4af487..5863fd9 100644 --- a/examples/consentmanager/CHANGELOG.md +++ b/examples/consentmanager/CHANGELOG.md @@ -1,5 +1,14 @@ # @contentpass/examples-consentmanager +## 0.0.8 + +### Patch Changes + +- Updated dependencies []: + - @contentpass/react-native-contentpass@0.8.1 + - @contentpass/react-native-contentpass-cmp-consentmanager@0.1.1 + - @contentpass/react-native-contentpass-ui@0.7.1 + ## 0.0.7 ### Patch Changes diff --git a/examples/consentmanager/package.json b/examples/consentmanager/package.json index a0880f3..f0cac73 100644 --- a/examples/consentmanager/package.json +++ b/examples/consentmanager/package.json @@ -1,6 +1,6 @@ { "name": "@contentpass/examples-consentmanager", - "version": "0.0.7", + "version": "0.0.8", "main": "index.ts", "scripts": { "start": "expo start", diff --git a/packages/react-native-contentpass/CHANGELOG.md b/packages/react-native-contentpass/CHANGELOG.md index f4f39ca..be8fc17 100644 --- a/packages/react-native-contentpass/CHANGELOG.md +++ b/packages/react-native-contentpass/CHANGELOG.md @@ -1,5 +1,11 @@ # @contentpass/react-native-contentpass +## 0.8.1 + +### Patch Changes + +- Fix retrying invalid refresh grants + ## 0.8.0 ### Minor Changes diff --git a/packages/react-native-contentpass/package.json b/packages/react-native-contentpass/package.json index 7e5d51e..72bf20b 100644 --- a/packages/react-native-contentpass/package.json +++ b/packages/react-native-contentpass/package.json @@ -1,6 +1,6 @@ { "name": "@contentpass/react-native-contentpass", - "version": "0.8.0", + "version": "0.8.1", "description": "Contentpass React Native SDK", "source": "./src/index.tsx", "main": "./lib/commonjs/index.js",