@chipmobilesdk/rn-auth 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,73 +1,66 @@
1
- /**
2
- * Social provider routing (FR-002/FR-003, R3). Google and Apple are federated
3
- * inside the External ID tenant; the app presents the platform-correct action
4
- * and starts the user flow with a per-provider `domain_hint`.
5
- */
6
-
7
- import type {
8
- AuthConfig,
9
- DevicePlatform,
10
- ProviderConfig,
11
- ProviderId,
12
- } from '../config/types';
13
- import { AuthConfigError } from '../diagnostics/errors';
14
-
15
- /** Find the configured provider, ensuring it is enabled for the platform. */
16
- export function resolveProvider(
17
- config: AuthConfig,
18
- providerId: ProviderId,
19
- platform: DevicePlatform,
20
- ): ProviderConfig {
21
- const provider = config.providers.find(candidate => candidate.id === providerId);
22
- if (!provider) {
23
- throw new AuthConfigError(
24
- 'auth.error.configuration',
25
- `Provider "${providerId}" is not configured`,
26
- );
27
- }
28
- if (!provider.platforms.includes(platform)) {
29
- throw new AuthConfigError(
30
- 'auth.error.configuration',
31
- `Provider "${providerId}" is not enabled for platform "${platform}"`,
32
- );
33
- }
34
- if (provider.available === false) {
35
- throw new AuthConfigError(
36
- 'auth.error.configuration',
37
- `Provider "${providerId}" is marked unavailable`,
38
- );
39
- }
40
- return provider;
41
- }
42
-
43
- /** Build the OIDC additional parameters that route to the chosen IdP. */
44
- export function buildAdditionalParameters(
45
- provider: ProviderConfig,
46
- ): Record<string, string> {
47
- return { domain_hint: provider.domainHint };
48
- }
49
-
50
- /**
51
- * All providers to present on a given platform, in configuration array order
52
- * (FR-003/FR-004). Filtered to providers enabled for the platform and not marked
53
- * unavailable.
54
- */
55
- export function availableProvidersForPlatform(
56
- config: AuthConfig,
57
- platform: DevicePlatform,
58
- ): ProviderConfig[] {
59
- return config.providers.filter(
60
- provider => provider.platforms.includes(platform) && provider.available !== false,
61
- );
62
- }
63
-
64
- /**
65
- * The first available provider for a platform (UX-004, back-compat). Equals
66
- * `availableProvidersForPlatform(config, platform)[0]`.
67
- */
68
- export function defaultProviderForPlatform(
69
- config: AuthConfig,
70
- platform: DevicePlatform,
71
- ): ProviderConfig | undefined {
72
- return availableProvidersForPlatform(config, platform)[0];
73
- }
1
+ /**
2
+ * Social provider routing (FR-002/FR-003, R3). Google and Apple are federated
3
+ * inside the External ID tenant; the app presents the platform-correct action
4
+ * and starts the user flow with a per-provider `domain_hint`.
5
+ */
6
+
7
+ import type { AuthConfig, DevicePlatform, ProviderConfig, ProviderId } from '../config/types';
8
+ import { AuthConfigError } from '../diagnostics/errors';
9
+
10
+ /** Find the configured provider, ensuring it is enabled for the platform. */
11
+ export function resolveProvider(
12
+ config: AuthConfig,
13
+ providerId: ProviderId,
14
+ platform: DevicePlatform,
15
+ ): ProviderConfig {
16
+ const provider = config.providers.find(candidate => candidate.id === providerId);
17
+ if (!provider) {
18
+ throw new AuthConfigError(
19
+ 'auth.error.configuration',
20
+ `Provider "${providerId}" is not configured`,
21
+ );
22
+ }
23
+ if (!provider.platforms.includes(platform)) {
24
+ throw new AuthConfigError(
25
+ 'auth.error.configuration',
26
+ `Provider "${providerId}" is not enabled for platform "${platform}"`,
27
+ );
28
+ }
29
+ if (provider.available === false) {
30
+ throw new AuthConfigError(
31
+ 'auth.error.configuration',
32
+ `Provider "${providerId}" is marked unavailable`,
33
+ );
34
+ }
35
+ return provider;
36
+ }
37
+
38
+ /** Build the OIDC additional parameters that route to the chosen IdP. */
39
+ export function buildAdditionalParameters(provider: ProviderConfig): Record<string, string> {
40
+ return { domain_hint: provider.domainHint };
41
+ }
42
+
43
+ /**
44
+ * All providers to present on a given platform, in configuration array order
45
+ * (FR-003/FR-004). Filtered to providers enabled for the platform and not marked
46
+ * unavailable.
47
+ */
48
+ export function availableProvidersForPlatform(
49
+ config: AuthConfig,
50
+ platform: DevicePlatform,
51
+ ): ProviderConfig[] {
52
+ return config.providers.filter(
53
+ provider => provider.platforms.includes(platform) && provider.available !== false,
54
+ );
55
+ }
56
+
57
+ /**
58
+ * The first available provider for a platform (UX-004, back-compat). Equals
59
+ * `availableProvidersForPlatform(config, platform)[0]`.
60
+ */
61
+ export function defaultProviderForPlatform(
62
+ config: AuthConfig,
63
+ platform: DevicePlatform,
64
+ ): ProviderConfig | undefined {
65
+ return availableProvidersForPlatform(config, platform)[0];
66
+ }
@@ -1,47 +1,30 @@
1
- /**
2
- * React context boundary for the auth controller (LIB-002). Subscribes to the
3
- * controller and re-renders consumers on every state change.
4
- */
5
-
6
- import React, {
7
- createContext,
8
- useContext,
9
- useEffect,
10
- useMemo,
11
- useState,
12
- } from 'react';
13
- import type { Auth, AuthState } from '../config/types';
14
-
15
- interface AuthContextValue {
16
- auth: Auth;
17
- state: AuthState;
18
- }
19
-
20
- const AuthContext = createContext<AuthContextValue | null>(null);
21
-
22
- export interface AuthProviderProps {
23
- auth: Auth;
24
- children: React.ReactNode;
25
- }
26
-
27
- export function AuthProvider({ auth, children }: AuthProviderProps) {
28
- const [state, setState] = useState<AuthState>(() => auth.getState());
29
-
30
- useEffect(() => {
31
- // Sync immediately in case state changed between render and subscribe.
32
- setState(auth.getState());
33
- return auth.subscribe(setState);
34
- }, [auth]);
35
-
36
- const value = useMemo<AuthContextValue>(() => ({ auth, state }), [auth, state]);
37
-
38
- return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
39
- }
40
-
41
- export function useAuthContext(): AuthContextValue {
42
- const value = useContext(AuthContext);
43
- if (!value) {
44
- throw new Error('useAuth must be used within an <AuthProvider>.');
45
- }
46
- return value;
47
- }
1
+ /**
2
+ * React context boundary for the auth controller (LIB-002).
3
+ *
4
+ * The context carries the controller only, never its state: a context value that
5
+ * changed on every transition re-rendered every consumer on every transition
6
+ * (PERF-02). Hooks read the state through `useSyncExternalStore` with a selector,
7
+ * so a component re-renders only when the part it reads changes.
8
+ */
9
+
10
+ import React, { createContext, useContext } from 'react';
11
+ import type { Auth } from '../config/types';
12
+
13
+ const AuthContext = createContext<Auth | null>(null);
14
+
15
+ export interface AuthProviderProps {
16
+ auth: Auth;
17
+ children: React.ReactNode;
18
+ }
19
+
20
+ export function AuthProvider({ auth, children }: AuthProviderProps) {
21
+ return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
22
+ }
23
+
24
+ export function useAuthInstance(): Auth {
25
+ const auth = useContext(AuthContext);
26
+ if (!auth) {
27
+ throw new Error('useAuth must be used within an <AuthProvider>.');
28
+ }
29
+ return auth;
30
+ }
@@ -1,91 +1,103 @@
1
- /**
2
- * Public hooks (LIB-002). All read from the AuthProvider context so consumers
3
- * re-render on state changes.
4
- */
5
-
6
- import { useCallback, useState } from 'react';
7
- import { useAuthContext } from './AuthProvider';
8
- import type {
9
- AccessTier,
10
- AccessTokenResult,
11
- AuthState,
12
- GlobalSignOutResult,
13
- PremiumGateResult,
14
- Profile,
15
- ProviderConfig,
16
- ProviderId,
17
- } from '../config/types';
18
- import { failClosedTier } from '../access/accessTier';
19
-
20
- export interface UseAuthResult {
21
- state: AuthState;
22
- signIn(providerId: ProviderId): Promise<AuthState>;
23
- signOut(): Promise<void>;
24
- signOutGlobal(): Promise<GlobalSignOutResult>;
25
- restore(): Promise<AuthState>;
26
- requirePremium(): PremiumGateResult;
27
- }
28
-
29
- export function useAuth(): UseAuthResult {
30
- const { auth, state } = useAuthContext();
31
- return {
32
- state,
33
- signIn: useCallback((providerId: ProviderId) => auth.signIn(providerId), [auth]),
34
- signOut: useCallback(() => auth.signOut(), [auth]),
35
- signOutGlobal: useCallback(() => auth.signOutGlobal(), [auth]),
36
- restore: useCallback(() => auth.restore(), [auth]),
37
- requirePremium: useCallback(() => auth.requirePremium(), [auth]),
38
- };
39
- }
40
-
41
- export function useProfile(): Profile | undefined {
42
- const { state } = useAuthContext();
43
- return state.profile;
44
- }
45
-
46
- export function useAccessTier(): AccessTier {
47
- const { state } = useAuthContext();
48
- return state.accessTier ?? failClosedTier();
49
- }
50
-
51
- /** The first provider to present on the current platform (UX-004), or undefined. */
52
- export function useSignInProvider(): ProviderConfig | undefined {
53
- const { auth } = useAuthContext();
54
- return auth.getPlatformProvider();
55
- }
56
-
57
- /** All available providers for the current platform, in configuration order (FR-003/FR-004). */
58
- export function useSignInProviders(): ProviderConfig[] {
59
- const { auth } = useAuthContext();
60
- return auth.getPlatformProviders();
61
- }
62
-
63
- export type UseAccessTokenStatus =
64
- | 'idle'
65
- | 'loading'
66
- | 'success'
67
- | 'denied'
68
- | 'unavailable'
69
- | 'error';
70
-
71
- export interface UseAccessTokenResult {
72
- status: UseAccessTokenStatus;
73
- token?: string;
74
- request(): Promise<AccessTokenResult>;
75
- }
76
-
77
- export function useAccessToken(resourceKey: string): UseAccessTokenResult {
78
- const { auth } = useAuthContext();
79
- const [status, setStatus] = useState<UseAccessTokenStatus>('idle');
80
- const [token, setToken] = useState<string | undefined>(undefined);
81
-
82
- const request = useCallback(async () => {
83
- setStatus('loading');
84
- const result = await auth.requestAccessToken(resourceKey);
85
- setStatus(result.status);
86
- setToken(result.token);
87
- return result;
88
- }, [auth, resourceKey]);
89
-
90
- return { status, token, request };
91
- }
1
+ /**
2
+ * Public hooks (LIB-002). Each reads the controller's state through
3
+ * `useSyncExternalStore` (rn-core's `useExternalStore`), selecting only what it
4
+ * returns, so `useProfile` does not re-render when only the status changes.
5
+ */
6
+
7
+ import { useExternalStore } from '@chipmobilesdk/rn-core/react';
8
+ import { useCallback, useState } from 'react';
9
+ import { useAuthInstance } from './AuthProvider';
10
+ import type {
11
+ AccessTier,
12
+ AccessTokenResult,
13
+ AuthState,
14
+ GlobalSignOutResult,
15
+ PremiumGateResult,
16
+ Profile,
17
+ ProviderConfig,
18
+ ProviderId,
19
+ } from '../config/types';
20
+ import { failClosedTier } from '../access/accessTier';
21
+
22
+ export interface UseAuthResult {
23
+ state: AuthState;
24
+ signIn(providerId: ProviderId): Promise<AuthState>;
25
+ signOut(): Promise<void>;
26
+ signOutGlobal(): Promise<GlobalSignOutResult>;
27
+ restore(): Promise<AuthState>;
28
+ requirePremium(): PremiumGateResult;
29
+ }
30
+
31
+ /**
32
+ * Any slice of the auth state; the component re-renders only when the slice
33
+ * changes by `isEqual` (default `Object.is`).
34
+ */
35
+ export function useAuthSelector<Selected>(
36
+ select: (state: AuthState) => Selected,
37
+ isEqual?: (a: Selected, b: Selected) => boolean,
38
+ ): Selected {
39
+ const auth = useAuthInstance();
40
+ return useExternalStore(auth.subscribe, auth.getState, select, isEqual);
41
+ }
42
+
43
+ const selectState = (state: AuthState) => state;
44
+ const selectProfile = (state: AuthState) => state.profile;
45
+ const selectAccessTier = (state: AuthState) => state.accessTier;
46
+
47
+ export function useAuth(): UseAuthResult {
48
+ const auth = useAuthInstance();
49
+ const state = useAuthSelector(selectState);
50
+ return {
51
+ state,
52
+ signIn: useCallback((providerId: ProviderId) => auth.signIn(providerId), [auth]),
53
+ signOut: useCallback(() => auth.signOut(), [auth]),
54
+ signOutGlobal: useCallback(() => auth.signOutGlobal(), [auth]),
55
+ restore: useCallback(() => auth.restore(), [auth]),
56
+ requirePremium: useCallback(() => auth.requirePremium(), [auth]),
57
+ };
58
+ }
59
+
60
+ export function useProfile(): Profile | undefined {
61
+ return useAuthSelector(selectProfile);
62
+ }
63
+
64
+ export function useAccessTier(): AccessTier {
65
+ return useAuthSelector(selectAccessTier) ?? failClosedTier();
66
+ }
67
+
68
+ /** The first provider to present on the current platform (UX-004), or undefined. */
69
+ export function useSignInProvider(): ProviderConfig | undefined {
70
+ const auth = useAuthInstance();
71
+ return auth.getPlatformProvider();
72
+ }
73
+
74
+ /** All available providers for the current platform, in configuration order (FR-003/FR-004). */
75
+ export function useSignInProviders(): ProviderConfig[] {
76
+ const auth = useAuthInstance();
77
+ return auth.getPlatformProviders();
78
+ }
79
+
80
+ export type UseAccessTokenStatus =
81
+ 'idle' | 'loading' | 'success' | 'denied' | 'unavailable' | 'error';
82
+
83
+ export interface UseAccessTokenResult {
84
+ status: UseAccessTokenStatus;
85
+ token?: string | undefined;
86
+ request(): Promise<AccessTokenResult>;
87
+ }
88
+
89
+ export function useAccessToken(resourceKey: string): UseAccessTokenResult {
90
+ const auth = useAuthInstance();
91
+ const [status, setStatus] = useState<UseAccessTokenStatus>('idle');
92
+ const [token, setToken] = useState<string | undefined>(undefined);
93
+
94
+ const request = useCallback(async () => {
95
+ setStatus('loading');
96
+ const result = await auth.requestAccessToken(resourceKey);
97
+ setStatus(result.status);
98
+ setToken(result.token);
99
+ return result;
100
+ }, [auth, resourceKey]);
101
+
102
+ return { status, token, request };
103
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * What any `SecureTokenStore` and `OidcAuthClient` must do (IOS-12, AUTH-13).
3
+ *
4
+ * The same checks run against the in-memory doubles and against the keychain and
5
+ * app-auth adapters (native modules mocked in Jest, or for real from a demo
6
+ * screen on a device), so a test that drives a double proves something about the
7
+ * device path. Each check builds its own subject through a harness.
8
+ */
9
+ import {
10
+ ensure,
11
+ runChecks,
12
+ type Check,
13
+ type ConformanceReport,
14
+ } from '@chipmobilesdk/rn-core/testing';
15
+
16
+ import type { OidcAuthClient } from '../oidc/authClient';
17
+ import type { SecureTokenStore } from '../tokens/secureStore';
18
+
19
+ export interface SecureStoreHarness {
20
+ /** An empty store. */
21
+ create(): SecureTokenStore;
22
+ }
23
+
24
+ export interface OidcSubject {
25
+ readonly client: OidcAuthClient;
26
+ /** The identity provider answers sign-in and refresh with tokens for these claims. */
27
+ issues(claims: Record<string, unknown>): void;
28
+ /** The user closes the sign-in sheet. */
29
+ userCancels(): void;
30
+ /** The provider no longer accepts the refresh token. */
31
+ rejectsRefresh(): void;
32
+ }
33
+
34
+ export interface OidcHarness {
35
+ create(): OidcSubject;
36
+ }
37
+
38
+ const KEY = '@chipmobilesdk/rn-auth/conformance';
39
+ const OTHER_KEY = '@chipmobilesdk/rn-auth/conformance-other';
40
+
41
+ export const secureTokenStoreChecks: readonly Check<SecureStoreHarness>[] = [
42
+ {
43
+ name: 'reads nothing for a key never written',
44
+ async run(harness) {
45
+ const value = await harness.create().getItem(KEY);
46
+ ensure(value === null, `expected null, got ${JSON.stringify(value)}`);
47
+ },
48
+ },
49
+ {
50
+ name: 'reads back the last value written',
51
+ async run(harness) {
52
+ const store = harness.create();
53
+ await store.setItem(KEY, 'first');
54
+ await store.setItem(KEY, 'second');
55
+ const value = await store.getItem(KEY);
56
+ ensure(value === 'second', `expected the last write, got ${JSON.stringify(value)}`);
57
+ },
58
+ },
59
+ {
60
+ name: 'keeps keys apart',
61
+ async run(harness) {
62
+ const store = harness.create();
63
+ await store.setItem(KEY, 'one');
64
+ await store.setItem(OTHER_KEY, 'two');
65
+ await store.removeItem(OTHER_KEY);
66
+ ensure((await store.getItem(KEY)) === 'one', 'removing one key touched another');
67
+ },
68
+ },
69
+ {
70
+ name: 'forgets a removed key, and removes a missing key safely',
71
+ async run(harness) {
72
+ const store = harness.create();
73
+ await store.removeItem(KEY);
74
+ await store.setItem(KEY, 'value');
75
+ await store.removeItem(KEY);
76
+ ensure((await store.getItem(KEY)) === null, 'a removed key still reads back');
77
+ },
78
+ },
79
+ {
80
+ name: 'keeps a long session artifact with non-ASCII text intact',
81
+ async run(harness) {
82
+ const store = harness.create();
83
+ const artifact = JSON.stringify({ name: 'Nguyễn Ánh', pad: 'x'.repeat(4096) });
84
+ await store.setItem(KEY, artifact);
85
+ ensure((await store.getItem(KEY)) === artifact, 'the artifact changed on the way back');
86
+ },
87
+ },
88
+ ];
89
+
90
+ const SIGN_IN = {
91
+ authority: 'https://login.example.com/tenant/v2.0',
92
+ clientId: 'conformance-client',
93
+ redirectUrl: 'com.example.app://auth',
94
+ scopes: ['openid', 'offline_access'],
95
+ };
96
+
97
+ function codeOf(error: unknown): unknown {
98
+ return (error as { code?: unknown } | null)?.code;
99
+ }
100
+
101
+ export const oidcAuthClientChecks: readonly Check<OidcHarness>[] = [
102
+ {
103
+ name: 'signs in with an access token, the id-token claims and a future expiry',
104
+ async run(harness) {
105
+ const subject = harness.create();
106
+ subject.issues({ oid: 'user-1', name: 'Ada' });
107
+ const before = Date.now();
108
+ const tokens = await subject.client.authorize(SIGN_IN);
109
+ ensure(
110
+ typeof tokens.accessToken === 'string' && tokens.accessToken !== '',
111
+ 'no access token',
112
+ );
113
+ ensure(tokens.claims.oid === 'user-1', `claims ${JSON.stringify(tokens.claims)}`);
114
+ ensure(
115
+ tokens.accessTokenExpiresAt === undefined || tokens.accessTokenExpiresAt > before,
116
+ 'the access token expires in the past',
117
+ );
118
+ },
119
+ },
120
+ {
121
+ name: 'refreshes with a refresh token',
122
+ async run(harness) {
123
+ const subject = harness.create();
124
+ subject.issues({ oid: 'user-1' });
125
+ const tokens = await subject.client.refresh({ ...SIGN_IN, refreshToken: 'refresh-1' });
126
+ ensure(
127
+ typeof tokens.accessToken === 'string' && tokens.accessToken !== '',
128
+ 'no access token',
129
+ );
130
+ },
131
+ },
132
+ {
133
+ name: 'reports a closed sign-in sheet as provider-cancelled',
134
+ async run(harness) {
135
+ const subject = harness.create();
136
+ subject.userCancels();
137
+ let code: unknown;
138
+ try {
139
+ await subject.client.authorize(SIGN_IN);
140
+ } catch (error) {
141
+ code = codeOf(error);
142
+ }
143
+ ensure(code === 'provider-cancelled', `expected provider-cancelled, got ${String(code)}`);
144
+ },
145
+ },
146
+ {
147
+ name: 'reports a rejected refresh token as session-expired',
148
+ async run(harness) {
149
+ const subject = harness.create();
150
+ subject.rejectsRefresh();
151
+ let code: unknown;
152
+ try {
153
+ await subject.client.refresh({ ...SIGN_IN, refreshToken: 'revoked' });
154
+ } catch (error) {
155
+ code = codeOf(error);
156
+ }
157
+ ensure(code === 'session-expired', `expected session-expired, got ${String(code)}`);
158
+ },
159
+ },
160
+ {
161
+ name: 'ends the session',
162
+ async run(harness) {
163
+ const subject = harness.create();
164
+ await subject.client.endSession({ ...SIGN_IN, idToken: undefined });
165
+ },
166
+ },
167
+ ];
168
+
169
+ export function runSecureTokenStoreConformance(
170
+ harness: SecureStoreHarness,
171
+ ): Promise<ConformanceReport> {
172
+ return runChecks(() => harness, secureTokenStoreChecks);
173
+ }
174
+
175
+ export function runOidcAuthClientConformance(harness: OidcHarness): Promise<ConformanceReport> {
176
+ return runChecks(() => harness, oidcAuthClientChecks);
177
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `@chipmobilesdk/rn-auth/testing`: test doubles and the port conformance suites.
3
+ *
4
+ * A separate entrypoint so neither can reach a production bundle.
5
+ */
6
+ import type { SecureTokenStore } from '../tokens/secureStore';
7
+
8
+ /** A `SecureTokenStore` held in memory, for tests and for the conformance suite. */
9
+ export function createMemorySecureStore(seed: Record<string, string> = {}): SecureTokenStore {
10
+ const values = new Map<string, string>(Object.entries(seed));
11
+ return {
12
+ async getItem(key) {
13
+ return values.get(key) ?? null;
14
+ },
15
+ async setItem(key, value) {
16
+ values.set(key, value);
17
+ },
18
+ async removeItem(key) {
19
+ values.delete(key);
20
+ },
21
+ };
22
+ }
23
+
24
+ export {
25
+ oidcAuthClientChecks,
26
+ runOidcAuthClientConformance,
27
+ runSecureTokenStoreConformance,
28
+ secureTokenStoreChecks,
29
+ } from './conformance';
30
+ export type { OidcHarness, OidcSubject, SecureStoreHarness } from './conformance';