@chipmobilesdk/rn-auth 0.3.1 → 0.5.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.
- package/CHANGELOG.md +141 -0
- package/README.md +99 -11
- package/package.json +23 -2
- package/skills/sdk-rn-auth/SKILL.md +38 -2
- package/src/app-auth.ts +6 -0
- package/src/config/types.ts +58 -10
- package/src/config/validateConfig.ts +32 -1
- package/src/core/authMachine.ts +77 -27
- package/src/core/profile.ts +6 -1
- package/src/core/session.ts +51 -5
- package/src/diagnostics/errors.ts +35 -21
- package/src/diagnostics/messageKeys.ts +34 -21
- package/src/index.ts +9 -1
- package/src/keychain.ts +6 -0
- package/src/oidc/authClient.ts +71 -12
- package/src/oidc/classifyError.ts +2 -1
- package/src/oidc/socialRouting.ts +3 -1
- package/src/react/AuthProvider.tsx +14 -25
- package/src/react/hooks.ts +82 -26
- package/src/testing/conformance.ts +177 -0
- package/src/testing/index.ts +30 -0
- package/src/tokens/accessToken.ts +20 -9
- package/src/tokens/secureStore.ts +43 -9
package/src/oidc/authClient.ts
CHANGED
|
@@ -6,23 +6,27 @@
|
|
|
6
6
|
* requires react-native-app-auth and is validated on device.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { lazyPeer } from '@chipmobilesdk/rn-core';
|
|
10
|
+
|
|
11
|
+
import { AuthConfigError } from '../diagnostics/errors';
|
|
12
|
+
|
|
9
13
|
import { classifyAppAuthError } from './classifyError';
|
|
10
14
|
|
|
11
15
|
export interface OidcTokens {
|
|
12
16
|
/** Resource/audience access token (present for authorize and resource refresh). */
|
|
13
|
-
accessToken?: string;
|
|
17
|
+
accessToken?: string | undefined;
|
|
14
18
|
/**
|
|
15
19
|
* Refresh token used for silent acquisition and restore. Absent when the
|
|
16
20
|
* provider issued none — a refresh that does not rotate, or an authorize the
|
|
17
21
|
* tenant answered without `offline_access`.
|
|
18
22
|
*/
|
|
19
|
-
refreshToken?: string;
|
|
23
|
+
refreshToken?: string | undefined;
|
|
20
24
|
/** Raw id token (JWT). Absent from a refresh requested without `openid`. */
|
|
21
|
-
idToken?: string;
|
|
25
|
+
idToken?: string | undefined;
|
|
22
26
|
/** Decoded id-token (or access-token) claims used for profile + access tier. */
|
|
23
27
|
claims: Record<string, unknown>;
|
|
24
28
|
/** Access-token expiry in epoch milliseconds, when available. */
|
|
25
|
-
accessTokenExpiresAt?: number;
|
|
29
|
+
accessTokenExpiresAt?: number | undefined;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface AuthorizeParams {
|
|
@@ -46,7 +50,7 @@ export interface EndSessionParams {
|
|
|
46
50
|
authority: string;
|
|
47
51
|
clientId: string;
|
|
48
52
|
redirectUrl: string;
|
|
49
|
-
idToken?: string;
|
|
53
|
+
idToken?: string | undefined;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
export interface OidcAuthClient {
|
|
@@ -60,12 +64,12 @@ export function decodeJwtClaims(jwt: string | null | undefined): Record<string,
|
|
|
60
64
|
if (typeof jwt !== 'string') {
|
|
61
65
|
return {};
|
|
62
66
|
}
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
67
|
+
const [, payload] = jwt.split('.');
|
|
68
|
+
if (payload === undefined) {
|
|
65
69
|
return {};
|
|
66
70
|
}
|
|
67
71
|
try {
|
|
68
|
-
const json = base64UrlDecode(
|
|
72
|
+
const json = base64UrlDecode(payload);
|
|
69
73
|
return JSON.parse(json) as Record<string, unknown>;
|
|
70
74
|
} catch {
|
|
71
75
|
return {};
|
|
@@ -112,19 +116,73 @@ function base64UrlDecode(input: string): string {
|
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
/** The endpoints discovery would have returned, when an app already knows them. */
|
|
120
|
+
export interface OidcServiceConfiguration {
|
|
121
|
+
authorizationEndpoint: string;
|
|
122
|
+
tokenEndpoint: string;
|
|
123
|
+
endSessionEndpoint?: string;
|
|
124
|
+
revocationEndpoint?: string;
|
|
125
|
+
registrationEndpoint?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
115
128
|
export interface AppAuthClientOptions {
|
|
116
129
|
/** When true, the OIDC discovery document is fetched from `${authority}`. */
|
|
117
130
|
useDiscovery?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Static endpoints, used instead of discovery. Every authorize, refresh and
|
|
133
|
+
* sign-out otherwise costs the native module one `.well-known` round-trip,
|
|
134
|
+
* including the refresh on the startup path (AUTH-08).
|
|
135
|
+
*/
|
|
136
|
+
serviceConfiguration?: OidcServiceConfiguration;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface AppAuthModule {
|
|
140
|
+
authorize(config: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
141
|
+
refresh(
|
|
142
|
+
config: Record<string, unknown>,
|
|
143
|
+
params: { refreshToken: string },
|
|
144
|
+
): Promise<Record<string, unknown>>;
|
|
145
|
+
logout(config: Record<string, unknown>, params: Record<string, unknown>): Promise<unknown>;
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Loaded on the first sign-in, refresh or sign-out, never at startup (PERF-05):
|
|
150
|
+
* creating the client, or the whole auth instance, loads no native code.
|
|
151
|
+
*/
|
|
152
|
+
const appAuthPeer = lazyPeer('react-native-app-auth', () => {
|
|
153
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
154
|
+
try {
|
|
155
|
+
return require('react-native-app-auth') as AppAuthModule;
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
throw cause;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
function appAuth(): AppAuthModule {
|
|
162
|
+
return appAuthPeer.require(
|
|
163
|
+
peer =>
|
|
164
|
+
new AuthConfigError(
|
|
165
|
+
'auth.error.configuration',
|
|
166
|
+
`${peer} is not installed or its native module is not linked`,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The module's calls, resolved only when one is made. */
|
|
172
|
+
const AppAuth: AppAuthModule = {
|
|
173
|
+
authorize: config => appAuth().authorize(config),
|
|
174
|
+
refresh: (config, params) => appAuth().refresh(config, params),
|
|
175
|
+
logout: (config, params) => appAuth().logout(config, params),
|
|
176
|
+
};
|
|
177
|
+
|
|
120
178
|
/**
|
|
121
179
|
* Default OidcAuthClient adapter wrapping react-native-app-auth (PKCE public
|
|
122
|
-
* client).
|
|
180
|
+
* client). Validated on device (R8).
|
|
123
181
|
*/
|
|
124
182
|
export function createAppAuthClient(options: AppAuthClientOptions = {}): OidcAuthClient {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const useDiscovery = options.useDiscovery ?? true;
|
|
183
|
+
const serviceConfiguration = options.serviceConfiguration;
|
|
184
|
+
// Discovery is pointless once the endpoints are given, whatever was asked for.
|
|
185
|
+
const useDiscovery = serviceConfiguration ? false : (options.useDiscovery ?? true);
|
|
128
186
|
|
|
129
187
|
const baseConfig = (
|
|
130
188
|
authority: string,
|
|
@@ -133,6 +191,7 @@ export function createAppAuthClient(options: AppAuthClientOptions = {}): OidcAut
|
|
|
133
191
|
scopes: string[],
|
|
134
192
|
) => ({
|
|
135
193
|
issuer: useDiscovery ? authority : undefined,
|
|
194
|
+
...(serviceConfiguration ? { serviceConfiguration } : {}),
|
|
136
195
|
clientId,
|
|
137
196
|
redirectUrl,
|
|
138
197
|
scopes,
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { AuthError, type AuthErrorCode } from '../diagnostics/errors';
|
|
15
|
+
import type { AuthMessageKey } from '../diagnostics/messageKeys';
|
|
15
16
|
|
|
16
17
|
export interface ClassificationRule {
|
|
17
18
|
readonly code: AuthErrorCode;
|
|
18
|
-
readonly messageKey:
|
|
19
|
+
readonly messageKey: AuthMessageKey;
|
|
19
20
|
/** Matched against the native error's `code` and `message`, case-insensitively. */
|
|
20
21
|
readonly pattern: RegExp;
|
|
21
22
|
}
|
|
@@ -37,7 +37,9 @@ export function resolveProvider(
|
|
|
37
37
|
|
|
38
38
|
/** Build the OIDC additional parameters that route to the chosen IdP. */
|
|
39
39
|
export function buildAdditionalParameters(provider: ProviderConfig): Record<string, string> {
|
|
40
|
-
|
|
40
|
+
// The routing hint last: a provider's own parameters cannot redirect the flow
|
|
41
|
+
// to a different IdP than the one the user tapped.
|
|
42
|
+
return { ...provider.additionalParameters, domain_hint: provider.domainHint };
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* React context boundary for the auth controller (LIB-002).
|
|
3
|
-
*
|
|
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.
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
|
-
import React, { createContext, useContext
|
|
7
|
-
import type { Auth
|
|
10
|
+
import React, { createContext, useContext } from 'react';
|
|
11
|
+
import type { Auth } from '../config/types';
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
auth: Auth;
|
|
11
|
-
state: AuthState;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
13
|
+
const AuthContext = createContext<Auth | null>(null);
|
|
15
14
|
|
|
16
15
|
export interface AuthProviderProps {
|
|
17
16
|
auth: Auth;
|
|
@@ -19,23 +18,13 @@ export interface AuthProviderProps {
|
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
export function AuthProvider({ auth, children }: AuthProviderProps) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
useEffect(() => {
|
|
25
|
-
// Sync immediately in case state changed between render and subscribe.
|
|
26
|
-
setState(auth.getState());
|
|
27
|
-
return auth.subscribe(setState);
|
|
28
|
-
}, [auth]);
|
|
29
|
-
|
|
30
|
-
const value = useMemo<AuthContextValue>(() => ({ auth, state }), [auth, state]);
|
|
31
|
-
|
|
32
|
-
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
21
|
+
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
|
|
33
22
|
}
|
|
34
23
|
|
|
35
|
-
export function
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
24
|
+
export function useAuthInstance(): Auth {
|
|
25
|
+
const auth = useContext(AuthContext);
|
|
26
|
+
if (!auth) {
|
|
38
27
|
throw new Error('useAuth must be used within an <AuthProvider>.');
|
|
39
28
|
}
|
|
40
|
-
return
|
|
29
|
+
return auth;
|
|
41
30
|
}
|
package/src/react/hooks.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public hooks (LIB-002).
|
|
3
|
-
*
|
|
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.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import { useExternalStore } from '@chipmobilesdk/rn-core/react';
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { useAuthInstance } from './AuthProvider';
|
|
8
10
|
import type {
|
|
9
11
|
AccessTier,
|
|
10
12
|
AccessTokenResult,
|
|
@@ -16,6 +18,8 @@ import type {
|
|
|
16
18
|
ProviderId,
|
|
17
19
|
} from '../config/types';
|
|
18
20
|
import { failClosedTier } from '../access/accessTier';
|
|
21
|
+
import type { AccessTokenRequestOptions } from '../tokens/accessToken';
|
|
22
|
+
import type { AuthMessageMap } from '../diagnostics/messageKeys';
|
|
19
23
|
|
|
20
24
|
export interface UseAuthResult {
|
|
21
25
|
state: AuthState;
|
|
@@ -23,40 +27,64 @@ export interface UseAuthResult {
|
|
|
23
27
|
signOut(): Promise<void>;
|
|
24
28
|
signOutGlobal(): Promise<GlobalSignOutResult>;
|
|
25
29
|
restore(): Promise<AuthState>;
|
|
30
|
+
/** Whether a premium-only path is open. Changes nothing: show the denial where it happened. */
|
|
31
|
+
canAccessPremium(): PremiumGateResult;
|
|
32
|
+
/** @deprecated Use `canAccessPremium`; this moves the whole controller to `access-denied` (AUTH-12). */
|
|
26
33
|
requirePremium(): PremiumGateResult;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Any slice of the auth state; the component re-renders only when the slice
|
|
38
|
+
* changes by `isEqual` (default `Object.is`).
|
|
39
|
+
*/
|
|
40
|
+
export function useAuthSelector<Selected>(
|
|
41
|
+
select: (state: AuthState) => Selected,
|
|
42
|
+
isEqual?: (a: Selected, b: Selected) => boolean,
|
|
43
|
+
): Selected {
|
|
44
|
+
const auth = useAuthInstance();
|
|
45
|
+
return useExternalStore(auth.subscribe, auth.getState, select, isEqual);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const selectState = (state: AuthState) => state;
|
|
49
|
+
const selectProfile = (state: AuthState) => state.profile;
|
|
50
|
+
const selectAccessTier = (state: AuthState) => state.accessTier;
|
|
51
|
+
|
|
29
52
|
export function useAuth(): UseAuthResult {
|
|
30
|
-
const
|
|
53
|
+
const auth = useAuthInstance();
|
|
54
|
+
const state = useAuthSelector(selectState);
|
|
31
55
|
return {
|
|
32
56
|
state,
|
|
33
57
|
signIn: useCallback((providerId: ProviderId) => auth.signIn(providerId), [auth]),
|
|
34
58
|
signOut: useCallback(() => auth.signOut(), [auth]),
|
|
35
59
|
signOutGlobal: useCallback(() => auth.signOutGlobal(), [auth]),
|
|
36
60
|
restore: useCallback(() => auth.restore(), [auth]),
|
|
61
|
+
canAccessPremium: useCallback(() => auth.canAccessPremium(), [auth]),
|
|
37
62
|
requirePremium: useCallback(() => auth.requirePremium(), [auth]),
|
|
38
63
|
};
|
|
39
64
|
}
|
|
40
65
|
|
|
66
|
+
/** This package's copy, with the app's `messages` overrides applied. */
|
|
67
|
+
export function useAuthMessages(): AuthMessageMap {
|
|
68
|
+
return useAuthInstance().getMessages();
|
|
69
|
+
}
|
|
70
|
+
|
|
41
71
|
export function useProfile(): Profile | undefined {
|
|
42
|
-
|
|
43
|
-
return state.profile;
|
|
72
|
+
return useAuthSelector(selectProfile);
|
|
44
73
|
}
|
|
45
74
|
|
|
46
75
|
export function useAccessTier(): AccessTier {
|
|
47
|
-
|
|
48
|
-
return state.accessTier ?? failClosedTier();
|
|
76
|
+
return useAuthSelector(selectAccessTier) ?? failClosedTier();
|
|
49
77
|
}
|
|
50
78
|
|
|
51
79
|
/** The first provider to present on the current platform (UX-004), or undefined. */
|
|
52
80
|
export function useSignInProvider(): ProviderConfig | undefined {
|
|
53
|
-
const
|
|
81
|
+
const auth = useAuthInstance();
|
|
54
82
|
return auth.getPlatformProvider();
|
|
55
83
|
}
|
|
56
84
|
|
|
57
85
|
/** All available providers for the current platform, in configuration order (FR-003/FR-004). */
|
|
58
86
|
export function useSignInProviders(): ProviderConfig[] {
|
|
59
|
-
const
|
|
87
|
+
const auth = useAuthInstance();
|
|
60
88
|
return auth.getPlatformProviders();
|
|
61
89
|
}
|
|
62
90
|
|
|
@@ -65,22 +93,50 @@ export type UseAccessTokenStatus =
|
|
|
65
93
|
|
|
66
94
|
export interface UseAccessTokenResult {
|
|
67
95
|
status: UseAccessTokenStatus;
|
|
68
|
-
token?: string;
|
|
69
|
-
|
|
96
|
+
token?: string | undefined;
|
|
97
|
+
/** `{ forceRefresh: true }` after a server refused the token this returned. */
|
|
98
|
+
request(options?: AccessTokenRequestOptions): Promise<AccessTokenResult>;
|
|
70
99
|
}
|
|
71
100
|
|
|
72
101
|
export function useAccessToken(resourceKey: string): UseAccessTokenResult {
|
|
73
|
-
const
|
|
74
|
-
const [
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
const auth = useAuthInstance();
|
|
103
|
+
const [state, setState] = useState<{
|
|
104
|
+
status: UseAccessTokenStatus;
|
|
105
|
+
token?: string | undefined;
|
|
106
|
+
resourceKey: string;
|
|
107
|
+
}>({ status: 'idle', resourceKey });
|
|
108
|
+
|
|
109
|
+
// Which request may still write. A result for a resource the component has
|
|
110
|
+
// moved on from, or for an unmounted one, is dropped rather than applied
|
|
111
|
+
// (AUTH-17, standard VII).
|
|
112
|
+
const live = useRef(0);
|
|
113
|
+
useEffect(
|
|
114
|
+
() => () => {
|
|
115
|
+
live.current += 1;
|
|
116
|
+
},
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const request = useCallback(
|
|
121
|
+
async (options?: AccessTokenRequestOptions) => {
|
|
122
|
+
live.current += 1;
|
|
123
|
+
const id = live.current;
|
|
124
|
+
setState({ status: 'loading', resourceKey });
|
|
125
|
+
const result = await auth.requestAccessToken(resourceKey, options);
|
|
126
|
+
if (id === live.current) {
|
|
127
|
+
setState({ status: result.status, token: result.token, resourceKey });
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
},
|
|
131
|
+
[auth, resourceKey],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// A new resource starts from `idle`, not from the previous resource's token.
|
|
135
|
+
if (state.resourceKey !== resourceKey) {
|
|
136
|
+
live.current += 1;
|
|
137
|
+
setState({ status: 'idle', resourceKey });
|
|
138
|
+
return { status: 'idle', token: undefined, request };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { status: state.status, token: state.token, request };
|
|
86
142
|
}
|
|
@@ -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';
|
|
@@ -13,7 +13,6 @@ const EXPIRY_SKEW_MS = 60_000;
|
|
|
13
13
|
interface CachedToken {
|
|
14
14
|
token: string;
|
|
15
15
|
expiresAt: number;
|
|
16
|
-
scopes: string[];
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export interface AccessTokenManagerDeps {
|
|
@@ -37,8 +36,19 @@ export interface AccessTokenManagerDeps {
|
|
|
37
36
|
onRefreshTokenRotated: (token: string, epoch: number) => Promise<void>;
|
|
38
37
|
}
|
|
39
38
|
|
|
39
|
+
export interface AccessTokenRequestOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Ignore the cached token and ask the provider for a new one.
|
|
42
|
+
*
|
|
43
|
+
* For the caller that just had one rejected: a token the server refused is
|
|
44
|
+
* still unexpired by this device's clock, so asking again without this
|
|
45
|
+
* returns the same token and the retry is the same request (AI-01).
|
|
46
|
+
*/
|
|
47
|
+
forceRefresh?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
export interface AccessTokenManager {
|
|
41
|
-
request(resourceKey: string): Promise<AccessTokenResult>;
|
|
51
|
+
request(resourceKey: string, options?: AccessTokenRequestOptions): Promise<AccessTokenResult>;
|
|
42
52
|
/** Whether `request` would answer from the cache, without touching the network. */
|
|
43
53
|
hasFreshToken(resourceKey: string): boolean;
|
|
44
54
|
clear(): void;
|
|
@@ -94,11 +104,7 @@ export function createAccessTokenManager(deps: AccessTokenManagerDeps): AccessTo
|
|
|
94
104
|
return { resourceKey, status: 'error', errorCode: 'token-acquisition-failed' };
|
|
95
105
|
}
|
|
96
106
|
const expiresAt = tokens.accessTokenExpiresAt ?? Date.now() + 3_600_000;
|
|
97
|
-
cache.set(resourceKey, {
|
|
98
|
-
token: tokens.accessToken,
|
|
99
|
-
expiresAt,
|
|
100
|
-
scopes: resource.scopes,
|
|
101
|
-
});
|
|
107
|
+
cache.set(resourceKey, { token: tokens.accessToken, expiresAt });
|
|
102
108
|
return { resourceKey, status: 'success', token: tokens.accessToken, expiresAt };
|
|
103
109
|
} catch (error) {
|
|
104
110
|
if (isAuthError(error)) {
|
|
@@ -127,13 +133,18 @@ export function createAccessTokenManager(deps: AccessTokenManagerDeps): AccessTo
|
|
|
127
133
|
return fresh(resourceKey) !== undefined;
|
|
128
134
|
},
|
|
129
135
|
|
|
130
|
-
request(resourceKey): Promise<AccessTokenResult> {
|
|
136
|
+
request(resourceKey, options = {}): Promise<AccessTokenResult> {
|
|
131
137
|
const resource = deps.resources.find(entry => entry.key === resourceKey);
|
|
132
138
|
if (!resource) {
|
|
133
139
|
return Promise.resolve({ resourceKey, status: 'error', errorCode: 'configuration-error' });
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
|
|
142
|
+
if (options.forceRefresh === true) {
|
|
143
|
+
// Dropped, not just skipped: the token is known bad, so no later
|
|
144
|
+
// caller should be handed it either.
|
|
145
|
+
cache.delete(resourceKey);
|
|
146
|
+
}
|
|
147
|
+
const cached = options.forceRefresh === true ? undefined : fresh(resourceKey);
|
|
137
148
|
if (cached) {
|
|
138
149
|
return Promise.resolve({
|
|
139
150
|
resourceKey,
|