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
9 changes: 9 additions & 0 deletions examples/consentmanager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/consentmanager/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentpass/examples-consentmanager",
"version": "0.0.7",
"version": "0.0.8",
"main": "index.ts",
"scripts": {
"start": "expo start",
Expand Down
6 changes: 6 additions & 0 deletions packages/react-native-contentpass/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @contentpass/react-native-contentpass

## 0.8.1

### Patch Changes

- Fix retrying invalid refresh grants

## 0.8.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native-contentpass/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
67 changes: 66 additions & 1 deletion packages/react-native-contentpass/src/Contentpass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand All @@ -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', () => {
Expand Down
21 changes: 19 additions & 2 deletions packages/react-native-contentpass/src/Contentpass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions packages/react-native-contentpass/src/consts/oidcConsts.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 17 additions & 0 deletions packages/react-native-contentpass/src/consts/oidcConsts.ts
Original file line number Diff line number Diff line change
@@ -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)
);
}
Loading