diff --git a/sharedExample/src/App.tsx b/sharedExample/src/App.tsx index c55874b..81aceef 100644 --- a/sharedExample/src/App.tsx +++ b/sharedExample/src/App.tsx @@ -1,6 +1,7 @@ import { StyleSheet, View } from 'react-native'; -import { ContentpassSdkProvider } from './ContentpassContext'; import ContentpassUsage from './ContentpassUsage'; +import { ContentpassSdkProvider } from 'react-native-contentpass'; +import { contentpassConfig } from './contentpassConfig'; const styles = StyleSheet.create({ container: { @@ -17,7 +18,7 @@ const styles = StyleSheet.create({ export default function App() { return ( - + diff --git a/sharedExample/src/ContentpassContext.tsx b/sharedExample/src/ContentpassContext.tsx deleted file mode 100644 index ec64cb9..0000000 --- a/sharedExample/src/ContentpassContext.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; -import { Contentpass } from 'react-native-contentpass'; -import { contentpassConfig } from './contentpassConfig'; - -const contentpassSdkContext = createContext(undefined); - -export const ContentpassSdkProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const [contentpassSdk, setContentpassSdk] = useState< - Contentpass | undefined - >(); - - useEffect(() => { - const contentpass = new Contentpass(contentpassConfig); - - setContentpassSdk(contentpass); - }, []); - - if (!contentpassSdk) { - return null; - } - - return ( - - {children} - - ); -}; - -export const useContentpassSdk = () => { - const contentpassSdk = useContext(contentpassSdkContext); - - if (!contentpassSdk) { - throw new Error( - 'useContentpassSdk must be used within a ContentpassSdkProvider' - ); - } - - return contentpassSdk; -}; diff --git a/sharedExample/src/ContentpassUsage.tsx b/sharedExample/src/ContentpassUsage.tsx index 58aa930..e5d6a05 100644 --- a/sharedExample/src/ContentpassUsage.tsx +++ b/sharedExample/src/ContentpassUsage.tsx @@ -1,11 +1,14 @@ -import { useContentpassSdk } from './ContentpassContext'; import { Button, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useEffect, useRef, useState } from 'react'; -import type { ContentpassState } from 'react-native-contentpass'; +import { + type ContentpassState, + useContentpassSdk, +} from 'react-native-contentpass'; import { SPConsentManager, type SPUserData, } from '@sourcepoint/react-native-cmp'; +import setupSourcepoint from './setupSourcepoint'; const styles = StyleSheet.create({ sourcepointDataContainer: { @@ -23,27 +26,6 @@ const styles = StyleSheet.create({ }, }); -const sourcePointConfig = { - accountId: 375, - propertyId: 37858, - propertyName: 'mobile.cmpsourcepoint.demo', -}; - -const setupSourcepoint = (hasValidSubscription: boolean) => { - const { accountId, propertyName, propertyId } = sourcePointConfig; - const spConsentManager = new SPConsentManager(); - - spConsentManager.build(accountId, propertyId, propertyName, { - gdpr: { - targetingParams: { - acps: hasValidSubscription ? 'true' : 'false', - }, - }, - }); - - return spConsentManager; -}; - export default function ContentpassUsage() { const [authResult, setAuthResult] = useState(); const contentpassSdk = useContentpassSdk(); @@ -78,6 +60,7 @@ export default function ContentpassUsage() { const onContentpassStateChange = (state: ContentpassState) => { setAuthResult(state); }; + contentpassSdk.registerObserver(onContentpassStateChange); return () => { diff --git a/sharedExample/src/setupSourcepoint.ts b/sharedExample/src/setupSourcepoint.ts new file mode 100644 index 0000000..d7c794c --- /dev/null +++ b/sharedExample/src/setupSourcepoint.ts @@ -0,0 +1,24 @@ +import { SPConsentManager } from '@sourcepoint/react-native-cmp'; + +const sourcePointConfig = { + accountId: 375, + propertyId: 37858, + propertyName: 'mobile.cmpsourcepoint.demo', +}; + +const setupSourcepoint = (hasValidSubscription: boolean) => { + const { accountId, propertyName, propertyId } = sourcePointConfig; + const spConsentManager = new SPConsentManager(); + + spConsentManager.build(accountId, propertyId, propertyName, { + gdpr: { + targetingParams: { + acps: hasValidSubscription ? 'true' : 'false', + }, + }, + }); + + return spConsentManager; +}; + +export default setupSourcepoint; diff --git a/src/Contentpass.ts b/src/Contentpass.ts new file mode 100644 index 0000000..ba31fc7 --- /dev/null +++ b/src/Contentpass.ts @@ -0,0 +1,213 @@ +import OidcAuthStateStorage, { + type OidcAuthState, +} from './OidcAuthStateStorage'; +import { + type ContentpassState, + ContentpassStateType, +} from './types/ContentpassState'; +import { + authorize, + type AuthorizeResult, + refresh, +} from 'react-native-app-auth'; +import { REFRESH_TOKEN_RETRIES, SCOPES } from './consts/oidcConsts'; +import { RefreshTokenStrategy } from './types/RefreshTokenStrategy'; +import fetchContentpassToken from './utils/fetchContentpassToken'; +import validateSubscription from './utils/validateSubscription'; +import type { ContentpassConfig } from './types/ContentpassConfig'; + +export type ContentpassObserver = (state: ContentpassState) => void; + +export default class Contentpass { + private authStateStorage: OidcAuthStateStorage; + private readonly config: ContentpassConfig; + + private contentpassState: ContentpassState = { + state: ContentpassStateType.INITIALISING, + }; + private contentpassStateObservers: ContentpassObserver[] = []; + private oidcAuthState: OidcAuthState | null = null; + private refreshTimer: NodeJS.Timeout | null = null; + + constructor(config: ContentpassConfig) { + this.authStateStorage = new OidcAuthStateStorage(config.propertyId); + this.config = config; + this.initialiseAuthState(); + } + + public authenticate = async (): Promise => { + let result: AuthorizeResult; + + try { + result = await authorize({ + clientId: this.config.propertyId, + redirectUrl: this.config.redirectUrl, + issuer: this.config.issuer, + scopes: SCOPES, + additionalParameters: { + cp_route: 'login', + prompt: 'consent', + cp_property: this.config.propertyId, + }, + }); + } catch (err: any) { + // FIXME: logger for error + + this.changeContentpassState({ + state: ContentpassStateType.ERROR, + error: 'message' in err ? err.message : 'Unknown error', + }); + return; + } + + await this.onNewAuthState(result); + }; + + public registerObserver(observer: ContentpassObserver) { + if (this.contentpassStateObservers.includes(observer)) { + return; + } + + observer(this.contentpassState); + this.contentpassStateObservers.push(observer); + } + + public unregisterObserver(observer: ContentpassObserver) { + this.contentpassStateObservers = this.contentpassStateObservers.filter( + (o) => o !== observer + ); + } + + public logout = async () => { + await this.authStateStorage.clearOidcAuthState(); + this.changeContentpassState({ + state: ContentpassStateType.UNAUTHENTICATED, + hasValidSubscription: false, + }); + }; + + public recoverFromError = async () => { + this.changeContentpassState({ + state: ContentpassStateType.INITIALISING, + }); + + await this.initialiseAuthState(); + }; + + private initialiseAuthState = async () => { + const authState = await this.authStateStorage.getOidcAuthState(); + if (authState) { + await this.onNewAuthState(authState); + return; + } + + this.changeContentpassState({ + state: ContentpassStateType.UNAUTHENTICATED, + hasValidSubscription: false, + }); + }; + + private onNewAuthState = async (authState: OidcAuthState) => { + this.oidcAuthState = authState; + await this.authStateStorage.storeOidcAuthState(authState); + + const strategy = this.setupRefreshTimer(); + if (strategy !== RefreshTokenStrategy.TIMER_SET) { + return; + } + + try { + const contentpassToken = await fetchContentpassToken({ + issuer: this.config.issuer, + propertyId: this.config.propertyId, + idToken: this.oidcAuthState.idToken, + }); + const hasValidSubscription = validateSubscription(contentpassToken); + this.changeContentpassState({ + state: ContentpassStateType.AUTHENTICATED, + hasValidSubscription, + }); + } catch (err: any) { + this.changeContentpassState({ + state: ContentpassStateType.ERROR, + error: err.message || 'Unknown error', + }); + } + }; + + private setupRefreshTimer = (): RefreshTokenStrategy => { + const accessTokenExpirationDate = + this.oidcAuthState?.accessTokenExpirationDate; + + if (!accessTokenExpirationDate) { + return RefreshTokenStrategy.NO_REFRESH; + } + + const now = new Date(); + const expirationDate = new Date(accessTokenExpirationDate); + const timeDiff = expirationDate.getTime() - now.getTime(); + if (timeDiff <= 0) { + this.refreshToken(0); + return RefreshTokenStrategy.INSTANTLY; + } + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(async () => { + await this.refreshToken(0); + }, timeDiff); + + return RefreshTokenStrategy.TIMER_SET; + }; + + private refreshToken = async (counter: number) => { + if (!this.oidcAuthState?.refreshToken) { + return; + } + + try { + const refreshResult = await refresh( + { + clientId: this.config.propertyId, + redirectUrl: this.config.redirectUrl, + issuer: this.config.issuer, + scopes: SCOPES, + }, + { + refreshToken: this.oidcAuthState.refreshToken, + } + ); + await this.onNewAuthState(refreshResult); + } catch (err) { + await this.onRefreshTokenError(counter, err); + } + }; + + // @ts-expect-error remove when err starts being used + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private onRefreshTokenError = async (counter: number, err: unknown) => { + // FIXME: logger for error + // FIXME: add handling for specific error to not retry in every case + if (counter <= REFRESH_TOKEN_RETRIES) { + const delay = counter * 1000 * 10; + await new Promise((resolve) => setTimeout(resolve, delay)); + await this.refreshToken(counter + 1); + return; + } + + this.changeContentpassState({ + state: ContentpassStateType.UNAUTHENTICATED, + hasValidSubscription: false, + }); + await this.authStateStorage.clearOidcAuthState(); + }; + + private changeContentpassState = (state: ContentpassState) => { + this.contentpassState = state; + this.contentpassStateObservers.forEach((observer) => observer(state)); + + return this.contentpassState; + }; +} diff --git a/src/index.tsx b/src/index.tsx index a7e388e..d547522 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,21 +1,3 @@ -import { - authorize, - type AuthorizeResult, - refresh, -} from 'react-native-app-auth'; -import { REFRESH_TOKEN_RETRIES, SCOPES } from './consts/oidcConsts'; -import OidcAuthStateStorage, { - type OidcAuthState, -} from './OidcAuthStateStorage'; -import fetchContentpassToken from './utils/fetchContentpassToken'; -import { - type ContentpassState, - ContentpassStateType, -} from './types/ContentpassState'; -import type { ContentpassConfig } from './types/ContentpassConfig'; -import validateSubscription from './utils/validateSubscription'; -import { RefreshTokenStrategy } from './types/RefreshTokenStrategy'; - export type { ContentpassState, ErrorState, @@ -26,198 +8,11 @@ export type { export type { ContentpassConfig } from './types/ContentpassConfig'; -export type ContentpassObserver = (state: ContentpassState) => void; - -export class Contentpass { - private authStateStorage: OidcAuthStateStorage; - private readonly config: ContentpassConfig; - - private contentpassState: ContentpassState = { - state: ContentpassStateType.INITIALISING, - }; - private contentpassStateObservers: ContentpassObserver[] = []; - private oidcAuthState: OidcAuthState | null = null; - private refreshTimer: NodeJS.Timeout | null = null; - - constructor(config: ContentpassConfig) { - this.authStateStorage = new OidcAuthStateStorage(config.propertyId); - this.config = config; - this.initialiseAuthState(); - } - - public authenticate = async (): Promise => { - let result: AuthorizeResult; - - try { - result = await authorize({ - clientId: this.config.propertyId, - redirectUrl: this.config.redirectUrl, - issuer: this.config.issuer, - scopes: SCOPES, - additionalParameters: { - cp_route: 'login', - prompt: 'consent', - cp_property: this.config.propertyId, - }, - }); - } catch (err: any) { - // FIXME: logger for error - - this.changeContentpassState({ - state: ContentpassStateType.ERROR, - error: 'message' in err ? err.message : 'Unknown error', - }); - return; - } - - await this.onNewAuthState(result); - }; - - public registerObserver(observer: ContentpassObserver) { - if (this.contentpassStateObservers.includes(observer)) { - return; - } - - observer(this.contentpassState); - this.contentpassStateObservers.push(observer); - } - - public unregisterObserver(observer: ContentpassObserver) { - this.contentpassStateObservers = this.contentpassStateObservers.filter( - (o) => o !== observer - ); - } - - public logout = async () => { - await this.authStateStorage.clearOidcAuthState(); - this.changeContentpassState({ - state: ContentpassStateType.UNAUTHENTICATED, - hasValidSubscription: false, - }); - }; - - public recoverFromError = async () => { - this.changeContentpassState({ - state: ContentpassStateType.INITIALISING, - }); - - await this.initialiseAuthState(); - }; - - private initialiseAuthState = async () => { - const authState = await this.authStateStorage.getOidcAuthState(); - if (authState) { - await this.onNewAuthState(authState); - return; - } - - this.changeContentpassState({ - state: ContentpassStateType.UNAUTHENTICATED, - hasValidSubscription: false, - }); - }; - - private onNewAuthState = async (authState: OidcAuthState) => { - this.oidcAuthState = authState; - await this.authStateStorage.storeOidcAuthState(authState); - - const strategy = this.setupRefreshTimer(); - if (strategy !== RefreshTokenStrategy.TIMER_SET) { - return; - } - - try { - const contentpassToken = await fetchContentpassToken({ - issuer: this.config.issuer, - propertyId: this.config.propertyId, - idToken: this.oidcAuthState.idToken, - }); - const hasValidSubscription = validateSubscription(contentpassToken); - this.changeContentpassState({ - state: ContentpassStateType.AUTHENTICATED, - hasValidSubscription, - }); - } catch (err: any) { - this.changeContentpassState({ - state: ContentpassStateType.ERROR, - error: err.message || 'Unknown error', - }); - } - }; - - private setupRefreshTimer = (): RefreshTokenStrategy => { - const accessTokenExpirationDate = - this.oidcAuthState?.accessTokenExpirationDate; - - if (!accessTokenExpirationDate) { - return RefreshTokenStrategy.NO_REFRESH; - } - - const now = new Date(); - const expirationDate = new Date(accessTokenExpirationDate); - const timeDiff = expirationDate.getTime() - now.getTime(); - if (timeDiff <= 0) { - this.refreshToken(0); - return RefreshTokenStrategy.INSTANTLY; - } - - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - - this.refreshTimer = setTimeout(async () => { - await this.refreshToken(0); - }, timeDiff); - - return RefreshTokenStrategy.TIMER_SET; - }; - - private refreshToken = async (counter: number) => { - if (!this.oidcAuthState?.refreshToken) { - return; - } - - try { - const refreshResult = await refresh( - { - clientId: this.config.propertyId, - redirectUrl: this.config.redirectUrl, - issuer: this.config.issuer, - scopes: SCOPES, - }, - { - refreshToken: this.oidcAuthState.refreshToken, - } - ); - await this.onNewAuthState(refreshResult); - } catch (err) { - await this.onRefreshTokenError(counter, err); - } - }; - - // @ts-expect-error remove when err starts being used - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private onRefreshTokenError = async (counter: number, err: unknown) => { - // FIXME: logger for error - // FIXME: add handling for specific error to not retry in every case - if (counter <= REFRESH_TOKEN_RETRIES) { - const delay = counter * 1000 * 10; - await new Promise((resolve) => setTimeout(resolve, delay)); - await this.refreshToken(counter + 1); - return; - } - - this.changeContentpassState({ - state: ContentpassStateType.UNAUTHENTICATED, - hasValidSubscription: false, - }); - await this.authStateStorage.clearOidcAuthState(); - }; +export { + default as Contentpass, + type ContentpassObserver, +} from './Contentpass'; - private changeContentpassState = (state: ContentpassState) => { - this.contentpassState = state; - this.contentpassStateObservers.forEach((observer) => observer(state)); +export { ContentpassSdkProvider } from './sdkContext/ContentpassSdkProvider'; - return this.contentpassState; - }; -} +export { default as useContentpassSdk } from './sdkContext/useContentpassSdk'; diff --git a/src/sdkContext/ContentpassSdkProvider.tsx b/src/sdkContext/ContentpassSdkProvider.tsx new file mode 100644 index 0000000..7a072e3 --- /dev/null +++ b/src/sdkContext/ContentpassSdkProvider.tsx @@ -0,0 +1,35 @@ +import React, { createContext, useEffect, useState } from 'react'; +import type { ContentpassConfig } from '../types/ContentpassConfig'; +import Contentpass from '../Contentpass'; + +export const contentpassSdkContext = createContext( + undefined +); + +export const ContentpassSdkProvider = ({ + children, + contentpassConfig, +}: { + children: React.ReactNode; + contentpassConfig: ContentpassConfig; +}) => { + const [contentpassSdk, setContentpassSdk] = useState< + Contentpass | undefined + >(); + + useEffect(() => { + const contentpass = new Contentpass(contentpassConfig); + + setContentpassSdk(contentpass); + }, [contentpassConfig]); + + if (!contentpassSdk) { + return null; + } + + return ( + + {children} + + ); +}; diff --git a/src/sdkContext/useContentpassSdk.ts b/src/sdkContext/useContentpassSdk.ts new file mode 100644 index 0000000..3e18837 --- /dev/null +++ b/src/sdkContext/useContentpassSdk.ts @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { contentpassSdkContext } from './ContentpassSdkProvider'; + +const useContentpassSdk = () => { + const contentpassSdk = useContext(contentpassSdkContext); + + if (!contentpassSdk) { + throw new Error( + 'useContentpassSdk must be used within a ContentpassSdkProvider' + ); + } + + return contentpassSdk; +}; + +export default useContentpassSdk; diff --git a/yarn.lock b/yarn.lock index a150f18..52cdcaa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3630,7 +3630,7 @@ __metadata: languageName: node linkType: hard -"@sourcepoint/react-native-cmp@patch:@sourcepoint/react-native-cmp@npm%3A0.3.0#./.yarn/patches/@sourcepoint-react-native-cmp-npm-0.3.0-2434c31dc9.patch::locator=react-native-contentpass%40workspace%3A.": +"@sourcepoint/react-native-cmp@patch:@sourcepoint/react-native-cmp@npm%3A0.3.0#./.yarn/patches/@sourcepoint-react-native-cmp-npm-0.3.0-2434c31dc9.patch::version=0.3.0&hash=664e70&locator=react-native-contentpass%40workspace%3A.": version: 0.3.0 resolution: "@sourcepoint/react-native-cmp@patch:@sourcepoint/react-native-cmp@npm%3A0.3.0#./.yarn/patches/@sourcepoint-react-native-cmp-npm-0.3.0-2434c31dc9.patch::version=0.3.0&hash=664e70&locator=react-native-contentpass%40workspace%3A." peerDependencies: @@ -3640,6 +3640,16 @@ __metadata: languageName: node linkType: hard +"@sourcepoint/react-native-cmp@patch:@sourcepoint/react-native-cmp@patch%3A@sourcepoint/react-native-cmp@npm%253A0.3.0%23./.yarn/patches/@sourcepoint-react-native-cmp-npm-0.3.0-2434c31dc9.patch%3A%3Aversion=0.3.0&hash=664e70&locator=react-native-contentpass%2540workspace%253A.#./.yarn/patches/@sourcepoint-react-native-cmp-patch-34fca36663.patch::locator=react-native-contentpass%40workspace%3A.": + version: 0.3.0 + resolution: "@sourcepoint/react-native-cmp@patch:@sourcepoint/react-native-cmp@patch%3A@sourcepoint/react-native-cmp@npm%253A0.3.0%23./.yarn/patches/@sourcepoint-react-native-cmp-npm-0.3.0-2434c31dc9.patch%3A%3Aversion=0.3.0&hash=664e70&locator=react-native-contentpass%2540workspace%253A.#./.yarn/patches/@sourcepoint-react-native-cmp-patch-34fca36663.patch::version=0.3.0&hash=5a881d&locator=react-native-contentpass%40workspace%3A." + peerDependencies: + react: "*" + react-native: "*" + checksum: 0e4d72b3f43e6fa7ee00a7d9fddc53058a7b8dc0a513bc85d2d05f3cd58da2b52fecc498212d6546ae0cd40556c2ab4fa7e12ac8fa70a4e0716c6c2078aaf966 + languageName: node + linkType: hard + "@tootallnate/quickjs-emscripten@npm:^0.23.0": version: 0.23.0 resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0"