Skip to content

Commit cbfcb18

Browse files
committed
feat: add observer for notifying about changes in state
1 parent c2e8229 commit cbfcb18

File tree

10 files changed

+260
-68
lines changed

10 files changed

+260
-68
lines changed

sharedExample/src/ContentpassUsage.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useContentpassSdk } from './ContentpassContext';
22
import { Button, ScrollView, StyleSheet, Text, View } from 'react-native';
33
import { useCallback, useEffect, useRef, useState } from 'react';
4-
import type { AuthenticateResult } from 'react-native-contentpass';
4+
import type { ContentpassState } from 'react-native-contentpass';
55
import {
66
SPConsentManager,
77
type SPUserData,
@@ -40,9 +40,7 @@ const setupSourcepoint = (hasValidSubscription: boolean) => {
4040
};
4141

4242
export default function ContentpassUsage() {
43-
const [authResult, setAuthResult] = useState<
44-
AuthenticateResult | undefined
45-
>();
43+
const [authResult, setAuthResult] = useState<ContentpassState | undefined>();
4644
const contentpassSdk = useContentpassSdk();
4745
const spConsentManager = useRef<SPConsentManager | null>();
4846
const [sourcepointUserData, setSourcepointUserData] = useState<
@@ -51,8 +49,7 @@ export default function ContentpassUsage() {
5149

5250
const authenticate = useCallback(async () => {
5351
spConsentManager.current?.dispose();
54-
const result = await contentpassSdk.authenticate();
55-
setAuthResult(result);
52+
await contentpassSdk.authenticate();
5653
}, [contentpassSdk]);
5754

5855
useEffect(() => {
@@ -77,6 +74,17 @@ export default function ContentpassUsage() {
7774
};
7875
}, [authResult, authenticate]);
7976

77+
useEffect(() => {
78+
const onContentpassStateChange = (state: ContentpassState) => {
79+
setAuthResult(state);
80+
};
81+
contentpassSdk.registerObserver(onContentpassStateChange);
82+
83+
return () => {
84+
contentpassSdk.unregisterObserver(onContentpassStateChange);
85+
};
86+
}, [contentpassSdk]);
87+
8088
const clearSourcepointData = () => {
8189
spConsentManager.current?.clearLocalData();
8290
setSourcepointUserData(undefined);
@@ -86,6 +94,7 @@ export default function ContentpassUsage() {
8694
return (
8795
<>
8896
<Button title={'Clear sourcepoint data'} onPress={clearSourcepointData} />
97+
<Button title={'Logout'} onPress={contentpassSdk.logout} />
8998
<View style={styles.logsView}>
9099
<Text>Authenticate result:</Text>
91100
<Text>{JSON.stringify(authResult, null, 2)}</Text>

sharedExample/src/contentpassConfig.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { Config } from 'react-native-contentpass';
1+
import type { ContentpassConfig } from 'react-native-contentpass';
22

3-
export const contentpassConfig: Config = {
3+
export const contentpassConfig: ContentpassConfig = {
44
propertyId: 'cc3fc4ad-cbe5-4d09-bf85-a49796603b19',
55
redirectUrl: 'de.contentpass.demo://oauth',
66
issuer: 'https://my.contentpass.dev',

src/OidcAuthStateStorage.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
import EncryptedStorage from 'react-native-encrypted-storage';
2-
import type { AuthorizeResult } from 'react-native-app-auth';
32

43
const AUTH_STATE_KEY = 'OIDCAuthState';
54

5+
export type OidcAuthState = {
6+
accessToken: string;
7+
accessTokenExpirationDate: string;
8+
idToken: string;
9+
refreshToken: string | null;
10+
tokenType: string;
11+
};
12+
613
export default class OidcAuthStateStorage {
714
private readonly key;
815

916
constructor(clientId: string) {
1017
this.key = `de.contentpass.${clientId}-${AUTH_STATE_KEY}`;
1118
}
1219

13-
public async storeOidcAuthState(authState: AuthorizeResult) {
20+
public async storeOidcAuthState(authState: OidcAuthState) {
1421
await EncryptedStorage.setItem(this.key, JSON.stringify(authState));
1522
}
1623

17-
public async getOidcAuthState() {
24+
public async getOidcAuthState(): Promise<OidcAuthState | undefined> {
1825
const oidcAuthStateString = await EncryptedStorage.getItem(this.key);
1926

2027
return oidcAuthStateString ? JSON.parse(oidcAuthStateString) : undefined;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const SCOPES = ['openid', 'offline_access', 'contentpass'];
22
export const TOKEN_ENDPOINT = `/auth/oidc/token`;
3+
export const REFRESH_TOKEN_RETRIES = 6;

src/index.tsx

Lines changed: 178 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,51 @@
1-
import { authorize, type AuthorizeResult } from 'react-native-app-auth';
2-
import { SCOPES } from './oidcConsts';
3-
import OidcAuthStateStorage from './OidcAuthStateStorage';
4-
import parseContentpassToken from './utils/parseContentpassToken';
1+
import {
2+
authorize,
3+
type AuthorizeResult,
4+
refresh,
5+
} from 'react-native-app-auth';
6+
import { REFRESH_TOKEN_RETRIES, SCOPES } from './consts/oidcConsts';
7+
import OidcAuthStateStorage, {
8+
type OidcAuthState,
9+
} from './OidcAuthStateStorage';
510
import fetchContentpassToken from './utils/fetchContentpassToken';
11+
import {
12+
type ContentpassState,
13+
ContentpassStateType,
14+
} from './types/ContentpassState';
15+
import type { ContentpassConfig } from './types/ContentpassConfig';
16+
import validateSubscription from './utils/validateSubscription';
17+
import { RefreshTokenStrategy } from './types/RefreshTokenStrategy';
618

7-
export type Config = {
8-
propertyId: string;
9-
redirectUrl: string;
10-
issuer: string;
11-
};
12-
13-
export enum State {
14-
INITIALISING = 'INITIALISING',
15-
UNAUTHENTICATED = 'UNAUTHENTICATED',
16-
AUTHENTICATED = 'AUTHENTICATED',
17-
ERROR = 'ERROR',
18-
}
19-
20-
type ErrorAuthenticateResult = {
21-
state: State.ERROR;
22-
hasValidSubscription: false;
23-
error: Error;
24-
};
19+
export type {
20+
ContentpassState,
21+
ErrorState,
22+
AuthenticatedState,
23+
InitialisingState,
24+
UnauthenticatedState,
25+
} from './types/ContentpassState';
2526

26-
type StandardAuthenticateResult = {
27-
state: State;
28-
hasValidSubscription: boolean;
29-
error?: never;
30-
};
27+
export type { ContentpassConfig } from './types/ContentpassConfig';
3128

32-
export type AuthenticateResult =
33-
| StandardAuthenticateResult
34-
| ErrorAuthenticateResult;
29+
export type ContentpassObserver = (state: ContentpassState) => void;
3530

3631
export class Contentpass {
3732
private authStateStorage: OidcAuthStateStorage;
38-
private readonly config: Config;
39-
private state: State = State.INITIALISING;
33+
private readonly config: ContentpassConfig;
34+
35+
private contentpassState: ContentpassState = {
36+
state: ContentpassStateType.INITIALISING,
37+
};
38+
private contentpassStateObservers: ContentpassObserver[] = [];
39+
private oidcAuthState: OidcAuthState | null = null;
40+
private refreshTimer: NodeJS.Timeout | null = null;
4041

41-
constructor(config: Config) {
42+
constructor(config: ContentpassConfig) {
4243
this.authStateStorage = new OidcAuthStateStorage(config.propertyId);
4344
this.config = config;
45+
this.initialiseAuthState();
4446
}
4547

46-
public async authenticate(): Promise<AuthenticateResult> {
48+
public authenticate = async (): Promise<void> => {
4749
let result: AuthorizeResult;
4850

4951
try {
@@ -61,40 +63,160 @@ export class Contentpass {
6163
} catch (err: any) {
6264
// FIXME: logger for error
6365

64-
return {
65-
state: State.ERROR,
66-
hasValidSubscription: false,
66+
this.changeContentpassState({
67+
state: ContentpassStateType.ERROR,
6768
error: 'message' in err ? err.message : 'Unknown error',
68-
};
69+
});
70+
return;
71+
}
72+
73+
await this.onNewAuthState(result);
74+
};
75+
76+
public registerObserver(observer: ContentpassObserver) {
77+
if (this.contentpassStateObservers.includes(observer)) {
78+
return;
79+
}
80+
81+
this.contentpassStateObservers.push(observer);
82+
}
83+
84+
public unregisterObserver(observer: ContentpassObserver) {
85+
this.contentpassStateObservers = this.contentpassStateObservers.filter(
86+
(o) => o !== observer
87+
);
88+
}
89+
90+
public logout = async () => {
91+
await this.authStateStorage.clearOidcAuthState();
92+
this.changeContentpassState({
93+
state: ContentpassStateType.UNAUTHENTICATED,
94+
hasValidSubscription: false,
95+
});
96+
};
97+
98+
public recoverFromError = async () => {
99+
this.changeContentpassState({
100+
state: ContentpassStateType.INITIALISING,
101+
});
102+
103+
await this.initialiseAuthState();
104+
};
105+
106+
private initialiseAuthState = async () => {
107+
const authState = await this.authStateStorage.getOidcAuthState();
108+
if (authState) {
109+
await this.onNewAuthState(authState);
110+
return;
69111
}
70112

71-
this.state = State.AUTHENTICATED;
72-
await this.authStateStorage.storeOidcAuthState(result);
113+
this.changeContentpassState({
114+
state: ContentpassStateType.UNAUTHENTICATED,
115+
hasValidSubscription: false,
116+
});
117+
};
118+
119+
private onNewAuthState = async (authState: OidcAuthState) => {
120+
this.oidcAuthState = authState;
121+
await this.authStateStorage.storeOidcAuthState(authState);
122+
123+
const strategy = this.setupRefreshTimer();
124+
if (strategy !== RefreshTokenStrategy.TIMER_SET) {
125+
return;
126+
}
73127

74128
try {
75129
const contentpassToken = await fetchContentpassToken({
76130
issuer: this.config.issuer,
77131
propertyId: this.config.propertyId,
78-
idToken: result.idToken,
132+
idToken: this.oidcAuthState.idToken,
79133
});
80-
const hasValidSubscription = this.validateSubscription(contentpassToken);
81-
82-
return {
83-
state: this.state,
134+
const hasValidSubscription = validateSubscription(contentpassToken);
135+
this.changeContentpassState({
136+
state: ContentpassStateType.AUTHENTICATED,
84137
hasValidSubscription,
85-
};
138+
});
139+
} catch (err: any) {
140+
this.changeContentpassState({
141+
state: ContentpassStateType.ERROR,
142+
error: err.message || 'Unknown error',
143+
});
144+
}
145+
};
146+
147+
private setupRefreshTimer = (): RefreshTokenStrategy => {
148+
const accessTokenExpirationDate =
149+
this.oidcAuthState?.accessTokenExpirationDate;
150+
151+
if (!accessTokenExpirationDate) {
152+
return RefreshTokenStrategy.NO_REFRESH;
153+
}
154+
155+
const now = new Date();
156+
const expirationDate = new Date(accessTokenExpirationDate);
157+
const timeDiff = expirationDate.getTime() - now.getTime();
158+
if (timeDiff <= 0) {
159+
this.refreshToken(0);
160+
return RefreshTokenStrategy.INSTANTLY;
161+
}
162+
163+
if (this.refreshTimer) {
164+
clearTimeout(this.refreshTimer);
165+
}
166+
167+
this.refreshTimer = setTimeout(async () => {
168+
await this.refreshToken(0);
169+
}, timeDiff);
170+
171+
return RefreshTokenStrategy.TIMER_SET;
172+
};
173+
174+
private refreshToken = async (counter: number) => {
175+
if (!this.oidcAuthState?.refreshToken) {
176+
return;
177+
}
178+
179+
try {
180+
const refreshResult = await refresh(
181+
{
182+
clientId: this.config.propertyId,
183+
redirectUrl: this.config.redirectUrl,
184+
issuer: this.config.issuer,
185+
scopes: SCOPES,
186+
},
187+
{
188+
refreshToken: this.oidcAuthState.refreshToken,
189+
}
190+
);
191+
await this.onNewAuthState(refreshResult);
86192
} catch (err) {
87-
// FIXME: logger for error
88-
return {
89-
state: this.state,
90-
hasValidSubscription: false,
91-
};
193+
await this.onRefreshTokenError(counter, err);
92194
}
93-
}
195+
};
94196

95-
private validateSubscription(contentpassToken: string) {
96-
const { body } = parseContentpassToken(contentpassToken);
197+
// @ts-expect-error remove when err starts being used
198+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
199+
private onRefreshTokenError = async (counter: number, err: unknown) => {
200+
// FIXME: logger for error
201+
// FIXME: add handling for specific error to not retry in every case
202+
if (counter <= REFRESH_TOKEN_RETRIES) {
203+
const delay = counter * 1000 * 10;
204+
await new Promise((resolve) => setTimeout(resolve, delay));
205+
await this.refreshToken(counter + 1);
206+
return;
207+
}
97208

98-
return !!body.auth && !!body.plans.length;
99-
}
209+
this.changeContentpassState({
210+
state: ContentpassStateType.UNAUTHENTICATED,
211+
hasValidSubscription: false,
212+
});
213+
await this.authStateStorage.clearOidcAuthState();
214+
};
215+
216+
private changeContentpassState = (state: ContentpassState) => {
217+
this.contentpassState = state;
218+
this.contentpassStateObservers.forEach((observer) => observer(state));
219+
220+
return this.contentpassState;
221+
};
100222
}

src/types/ContentpassConfig.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type ContentpassConfig = {
2+
propertyId: string;
3+
redirectUrl: string;
4+
issuer: string;
5+
};

0 commit comments

Comments
 (0)