@chipmobilesdk/rn-auth 0.3.1 → 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.
- package/CHANGELOG.md +113 -0
- package/README.md +35 -4
- package/package.json +23 -2
- package/skills/sdk-rn-auth/SKILL.md +3 -0
- package/src/app-auth.ts +6 -0
- package/src/config/types.ts +5 -5
- package/src/core/authMachine.ts +37 -21
- package/src/core/session.ts +2 -2
- package/src/diagnostics/errors.ts +28 -16
- package/src/diagnostics/messageKeys.ts +34 -21
- package/src/index.ts +2 -1
- package/src/keychain.ts +6 -0
- package/src/oidc/authClient.ts +52 -11
- package/src/react/AuthProvider.tsx +14 -25
- package/src/react/hooks.ts +29 -12
- package/src/testing/conformance.ts +177 -0
- package/src/testing/index.ts +30 -0
- package/src/tokens/secureStore.ts +43 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,118 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-auth
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
8
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
9
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
10
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
11
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
12
|
+
|
|
13
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
14
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
15
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
16
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
17
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
18
|
+
builds only. The warn-once memory is bounded.
|
|
19
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
20
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
21
|
+
instead of swallowing it.
|
|
22
|
+
|
|
23
|
+
- The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
|
|
24
|
+
dependency: `isSdkError` recognises them alongside every other package's, and each
|
|
25
|
+
carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
26
|
+
|
|
27
|
+
- rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
|
|
28
|
+
deprecated alias.
|
|
29
|
+
- rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
|
|
30
|
+
e-mail addresses and UUIDs.
|
|
31
|
+
- rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
|
|
32
|
+
to its code.
|
|
33
|
+
- rn-ai: an error message quoted from the gateway is pattern-redacted.
|
|
34
|
+
- rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
|
|
35
|
+
- rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
|
|
36
|
+
`code: 'contrast'` (new `ThemeErrorCode` type).
|
|
37
|
+
- rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
|
|
38
|
+
`code: 'unit_conversion'` (new `I18nErrorCode` type).
|
|
39
|
+
|
|
40
|
+
- Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
|
|
41
|
+
and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
42
|
+
|
|
43
|
+
- rn-auth: creating `auth` no longer loads react-native-app-auth or
|
|
44
|
+
react-native-keychain; each loads on first use. New entrypoints
|
|
45
|
+
`@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
|
|
46
|
+
rn-core.
|
|
47
|
+
- rn-camera: a preview whose VisionCamera feed cannot load fails the session with
|
|
48
|
+
`cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
|
|
49
|
+
showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
|
|
50
|
+
- rn-notification: `createMmkvStore` reports a fallback to memory as
|
|
51
|
+
`STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
|
|
52
|
+
exposes `durable`.
|
|
53
|
+
- rn-payment: an iOS app validates its catalog against the iOS product ids; the
|
|
54
|
+
default platform used to be Android everywhere. A missing react-native-iap is
|
|
55
|
+
`PAYMENT_UNAVAILABLE`.
|
|
56
|
+
- rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
|
|
57
|
+
importing the package no longer loads Unistyles; `applyThemeMode` loads it on
|
|
58
|
+
first use.
|
|
59
|
+
- rn-ads, rn-local-db: the same loading, with the same error codes as before.
|
|
60
|
+
|
|
61
|
+
- Hooks read through `useSyncExternalStore` (rn-core's `useExternalStore`) and
|
|
62
|
+
re-render only when what they return changes. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
63
|
+
|
|
64
|
+
- rn-auth: `useProfile` and `useAccessTier` no longer re-render for a status change,
|
|
65
|
+
and the provider's context no longer changes on every transition. New
|
|
66
|
+
`useAuthSelector`. `auth.getState()` returns the same frozen object until the state
|
|
67
|
+
changes, instead of a copy per call.
|
|
68
|
+
- rn-payment: `useEntitlement` re-renders only when its own entitlement changes; the
|
|
69
|
+
engine keeps unchanged entitlement objects across syncs and does not notify for a
|
|
70
|
+
sync that changed nothing.
|
|
71
|
+
- rn-ads: `usePlacementState` and `useAdsStatus` read tear-free; `useAdsEvents` keeps
|
|
72
|
+
one subscription when given an inline listener.
|
|
73
|
+
- rn-i18n: `useTranslation` returns the same `t` until the locale changes, and
|
|
74
|
+
`useLocale`'s `setLocale` is stable.
|
|
75
|
+
- rn-ai: new `useAiBehaviorState`, with `client.subscribe` and a stable
|
|
76
|
+
`client.getBehaviorState()`.
|
|
77
|
+
|
|
78
|
+
- Every package's own copy is an rn-core message catalog that an app can translate. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
79
|
+
|
|
80
|
+
- rn-i18n: new `runtimeEntriesFromCatalog(catalog)` registers any SDK catalog in one
|
|
81
|
+
line, converting `{name}` variables to `{{name}}`.
|
|
82
|
+
- rn-ai: new `messageKeyForError(error)` gives the copy for every error code, and
|
|
83
|
+
`aiMessageSet`. The input placeholder default is neutral ("Type a message…"), and
|
|
84
|
+
two keys were added for errors that had none (`ai.error.signIn`, `ai.error.generic`).
|
|
85
|
+
- rn-auth: new `authMessages`, the catalog split into user and developer copy.
|
|
86
|
+
- rn-payment: new `paymentMessages`.
|
|
87
|
+
- rn-chart: summaries, announcements, adjustable-action labels, built-in state text
|
|
88
|
+
and legend hints come from `chartStrings`; pass `strings` to translate them. The
|
|
89
|
+
`accessibility.summaryFormatter` prop, declared before, now replaces the summary.
|
|
90
|
+
|
|
91
|
+
- Port conformance suites: one list of checks that a package's fake and its real adapter
|
|
92
|
+
both pass, so a test written against the fake proves something about the device. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
93
|
+
|
|
94
|
+
- rn-core: `@chipmobilesdk/rn-core/testing` adds `runChecks`, `expectConformance`,
|
|
95
|
+
`ensure` and `createFaultRegistry` (deterministic fault injection by named point).
|
|
96
|
+
- rn-camera: a `./testing` entrypoint with `runPermissionPortConformance`,
|
|
97
|
+
`runCameraEngineConformance` and the test adapter, which stays on the main
|
|
98
|
+
entrypoint too until the next major. `createTestCamera(...).prompts()` counts system
|
|
99
|
+
prompts.
|
|
100
|
+
- rn-payment: `runProviderPortConformance` in `./testing`, passed by the fake store and
|
|
101
|
+
the Play adapter.
|
|
102
|
+
- rn-ads: `runAdsEngineConformance` in `./testing`, passed by the fake engine and the
|
|
103
|
+
AdMob adapter.
|
|
104
|
+
- rn-auth: a `./testing` entrypoint with `createMemorySecureStore`,
|
|
105
|
+
`runSecureTokenStoreConformance` and `runOidcAuthClientConformance`, passed by the
|
|
106
|
+
in-memory doubles and the keychain and app-auth adapters.
|
|
107
|
+
|
|
108
|
+
### Patch Changes
|
|
109
|
+
|
|
110
|
+
- Optional properties in the public types (`AuthState`, `Profile`, `UseAccessTokenResult`
|
|
111
|
+
and others) now accept an explicit `undefined`, so apps compiling with
|
|
112
|
+
`exactOptionalPropertyTypes` type-check against the package. No runtime change. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
113
|
+
- Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
|
|
114
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
115
|
+
|
|
3
116
|
## 0.3.1
|
|
4
117
|
|
|
5
118
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -145,7 +145,9 @@ can sign users in:
|
|
|
145
145
|
Keystore). Access tokens are kept in memory only.
|
|
146
146
|
- Access-tier decisions prefer stable group **object ids**; display names never
|
|
147
147
|
grant access; missing/overage claims fail closed.
|
|
148
|
-
- Diagnostics (`AuthError`) never contain secrets or token values.
|
|
148
|
+
- Diagnostics (`AuthError`) never contain secrets or token values. `AuthError` extends
|
|
149
|
+
`SdkError` from `@chipmobilesdk/rn-core`: `isSdkError(error)` recognises it, `error.pkg`
|
|
150
|
+
is `rn-auth`, and `error.retryable` is true only for `network-unavailable`.
|
|
149
151
|
- Authentication and profile data are collected and transmitted to the
|
|
150
152
|
identity providers and configured Azure resources — update Apple App Privacy
|
|
151
153
|
and Google Play Data Safety before release.
|
|
@@ -163,11 +165,40 @@ are unsupported.
|
|
|
163
165
|
`PremiumGateResult`, `evaluateAccessTier`, `failClosedTier`
|
|
164
166
|
- Ports and default adapters: `SecureTokenStore`, `createKeychainSecureStore`,
|
|
165
167
|
`AUTH_STORAGE_KEY`, `OidcAuthClient`, `createAppAuthClient` and the public OIDC
|
|
166
|
-
request/token types
|
|
168
|
+
request/token types. The two default adapters also have their own entrypoints,
|
|
169
|
+
`@chipmobilesdk/rn-auth/app-auth` and `@chipmobilesdk/rn-auth/keychain`, so an app
|
|
170
|
+
that injects its own client or store can skip them. Neither loads its native
|
|
171
|
+
module until it is used: react-native-app-auth on the first sign-in, refresh or
|
|
172
|
+
sign-out, react-native-keychain on the first read or write. Creating `auth` at
|
|
173
|
+
startup loads neither. A missing module is an `AuthConfigError` at that moment.
|
|
167
174
|
- React: `AuthProvider`, `useAuth`, `useProfile`, `useAccessTier`,
|
|
168
|
-
`useAccessToken`, `useSignInProvider`, `useSignInProviders`
|
|
175
|
+
`useAccessToken`, `useSignInProvider`, `useSignInProviders`, `useAuthSelector`.
|
|
176
|
+
Each hook reads the state through `useSyncExternalStore` and re-renders only when
|
|
177
|
+
what it returns changes: `useProfile` does not render for a status change. Use
|
|
178
|
+
`useAuthSelector(state => …, isEqual?)` for any other slice. `auth.getState()`
|
|
179
|
+
returns the same frozen object until the state changes.
|
|
169
180
|
- Diagnostics and copy: `AuthError`, `AuthConfigError`, `isAuthError`, `redact`,
|
|
170
|
-
`DEFAULT_MESSAGES`, `resolveMessages` and their public code/message types
|
|
181
|
+
`DEFAULT_MESSAGES`, `resolveMessages` and their public code/message types, and
|
|
182
|
+
`authMessages`, the catalog split into `user` and `developer` copy. Register the user
|
|
183
|
+
copy with rn-i18n in one line: `runtimeEntriesFromCatalog(authMessages.user)`.
|
|
184
|
+
|
|
185
|
+
## Testing
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { createAuth } from '@chipmobilesdk/rn-auth';
|
|
189
|
+
import { createMemorySecureStore } from '@chipmobilesdk/rn-auth/testing';
|
|
190
|
+
|
|
191
|
+
const auth = createAuth({ ...config, secureStore: createMemorySecureStore(), authClient });
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`@chipmobilesdk/rn-auth/testing` is a separate entrypoint, so nothing in it reaches a
|
|
195
|
+
production bundle. Besides `createMemorySecureStore(seed?)` it holds the checks every
|
|
196
|
+
adapter must pass: `runSecureTokenStoreConformance` (null for an unknown key, last write
|
|
197
|
+
wins, keys kept apart, safe removal, a long non-ASCII artifact intact) and
|
|
198
|
+
`runOidcAuthClientConformance` (sign-in with claims and a future expiry, refresh, a
|
|
199
|
+
closed sheet as `provider-cancelled`, a rejected refresh token as `session-expired`, and
|
|
200
|
+
sign-out). The package runs them against its doubles and the keychain and app-auth
|
|
201
|
+
adapters; run them against a store or client you inject.
|
|
171
202
|
|
|
172
203
|
## Compatibility and validation
|
|
173
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -10,10 +10,27 @@
|
|
|
10
10
|
"types": "./src/index.ts",
|
|
11
11
|
"react-native": "./src/index.ts",
|
|
12
12
|
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./app-auth": {
|
|
15
|
+
"types": "./src/app-auth.ts",
|
|
16
|
+
"react-native": "./src/app-auth.ts",
|
|
17
|
+
"default": "./src/app-auth.ts"
|
|
18
|
+
},
|
|
19
|
+
"./keychain": {
|
|
20
|
+
"types": "./src/keychain.ts",
|
|
21
|
+
"react-native": "./src/keychain.ts",
|
|
22
|
+
"default": "./src/keychain.ts"
|
|
23
|
+
},
|
|
24
|
+
"./testing": {
|
|
25
|
+
"types": "./src/testing/index.ts",
|
|
26
|
+
"react-native": "./src/testing/index.ts",
|
|
27
|
+
"default": "./src/testing/index.ts"
|
|
13
28
|
}
|
|
14
29
|
},
|
|
15
30
|
"files": [
|
|
16
31
|
"src/index.ts",
|
|
32
|
+
"src/app-auth.ts",
|
|
33
|
+
"src/keychain.ts",
|
|
17
34
|
"src/config",
|
|
18
35
|
"src/core",
|
|
19
36
|
"src/tokens",
|
|
@@ -21,15 +38,19 @@
|
|
|
21
38
|
"src/access",
|
|
22
39
|
"src/diagnostics",
|
|
23
40
|
"src/react",
|
|
41
|
+
"src/testing",
|
|
24
42
|
"skills",
|
|
25
43
|
"README.md",
|
|
26
44
|
"CHANGELOG.md"
|
|
27
45
|
],
|
|
28
46
|
"sideEffects": false,
|
|
29
47
|
"scripts": {
|
|
30
|
-
"typecheck": "tsc -p tsconfig.json
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
|
|
31
49
|
"pack:check": "npm pack --dry-run"
|
|
32
50
|
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@chipmobilesdk/rn-core": "^0.1.0"
|
|
53
|
+
},
|
|
33
54
|
"peerDependencies": {
|
|
34
55
|
"react": ">=19",
|
|
35
56
|
"react-native": ">=0.85",
|
|
@@ -42,6 +42,9 @@ Runtime:
|
|
|
42
42
|
- `evaluateAccessTier(...)`, `failClosedTier` — feature gating
|
|
43
43
|
- `AuthProvider` — React context provider
|
|
44
44
|
|
|
45
|
+
Testing (`@chipmobilesdk/rn-auth/testing`): `createMemorySecureStore`,
|
|
46
|
+
`runSecureTokenStoreConformance`, `runOidcAuthClientConformance` — README `## Testing`.
|
|
47
|
+
|
|
45
48
|
Types: `SecureTokenStore`, `AuthProviderProps`, `AuthErrorCode`, `AuthErrorAudience`,
|
|
46
49
|
`AuthMessageMap`, `AuthMessageKey`
|
|
47
50
|
|
package/src/app-auth.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@chipmobilesdk/rn-auth/app-auth`: the default OIDC client over
|
|
3
|
+
* react-native-app-auth (AUTH-09). Its own entrypoint, so an app that injects its
|
|
4
|
+
* own client never reaches this module.
|
|
5
|
+
*/
|
|
6
|
+
export { createAppAuthClient, type AppAuthClientOptions } from './oidc/authClient';
|
package/src/config/types.ts
CHANGED
|
@@ -78,8 +78,8 @@ export type RestoreStatus = 'pending' | 'restored' | 'unavailable' | 'offline';
|
|
|
78
78
|
/** Customer User / app-visible profile (FR-006). */
|
|
79
79
|
export interface Profile {
|
|
80
80
|
tenantUserId: string;
|
|
81
|
-
displayName?: string;
|
|
82
|
-
email?: string;
|
|
81
|
+
displayName?: string | undefined;
|
|
82
|
+
email?: string | undefined;
|
|
83
83
|
provider: string;
|
|
84
84
|
claims: Record<string, unknown>;
|
|
85
85
|
}
|
|
@@ -112,10 +112,10 @@ export interface AccessTokenResult {
|
|
|
112
112
|
/** Single active app-visible authenticated state (FR-005/FR-022). */
|
|
113
113
|
export interface AuthState {
|
|
114
114
|
status: AuthStatus;
|
|
115
|
-
profile?: Profile;
|
|
116
|
-
accessTier?: AccessTier;
|
|
115
|
+
profile?: Profile | undefined;
|
|
116
|
+
accessTier?: AccessTier | undefined;
|
|
117
117
|
restore: RestoreStatus;
|
|
118
|
-
error?: AuthError;
|
|
118
|
+
error?: AuthError | undefined;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/** Result of a premium-only gate check (US3). */
|
package/src/core/authMachine.ts
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
* can never be undone by a request that was already in flight.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
createDiagnosticChannel,
|
|
14
|
+
createListenerSet,
|
|
15
|
+
currentPlatform,
|
|
16
|
+
shallowEqual,
|
|
17
|
+
} from '@chipmobilesdk/rn-core';
|
|
18
|
+
|
|
13
19
|
import type {
|
|
14
20
|
Auth,
|
|
15
21
|
AuthConfig,
|
|
@@ -34,7 +40,7 @@ import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
|
|
|
34
40
|
import { restoreSession, type PersistedSession } from './session';
|
|
35
41
|
import { AUTH_STORAGE_KEY } from '../tokens/secureStore';
|
|
36
42
|
import { createAccessTokenManager } from '../tokens/accessToken';
|
|
37
|
-
import { AuthError, toAuthError } from '../diagnostics/errors';
|
|
43
|
+
import { AuthError, toAuthError, type AuthErrorCode } from '../diagnostics/errors';
|
|
38
44
|
|
|
39
45
|
const SIGNED_OUT: Partial<AuthState> = {
|
|
40
46
|
status: 'signed-out',
|
|
@@ -48,17 +54,21 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
48
54
|
// Throws AuthConfigError before anything else (FR-005, US4 scenario 2).
|
|
49
55
|
validateConfig(config);
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
// rn-core's one platform source (CORE-09). Outside Android and iOS (tests on
|
|
58
|
+
// Node, web) the providers are resolved as for iOS, as before.
|
|
59
|
+
const detected = currentPlatform();
|
|
60
|
+
const platform: DevicePlatform =
|
|
61
|
+
options.platform ?? (detected === 'unsupported' ? 'ios' : detected);
|
|
52
62
|
const secureStore = config.secureStore;
|
|
53
63
|
const authClient = config.authClient ?? createAppAuthClient();
|
|
54
64
|
const resources = config.resources ?? [];
|
|
55
65
|
|
|
56
|
-
let state: AuthState = { status: 'loading', restore: 'pending' };
|
|
66
|
+
let state: AuthState = Object.freeze({ status: 'loading', restore: 'pending' });
|
|
57
67
|
let refreshToken: string | null = null;
|
|
58
68
|
/** Kept for the end-session `id_token_hint`; without it a global sign-out is a no-op. */
|
|
59
69
|
let idToken: string | null = null;
|
|
60
70
|
let sessionEpoch = 0;
|
|
61
|
-
const listeners =
|
|
71
|
+
const listeners = createListenerSet<[AuthState]>();
|
|
62
72
|
/**
|
|
63
73
|
* Every Keychain write and delete, in order. A rotation persisted while a
|
|
64
74
|
* sign-out runs must not land after the sign-out's delete and resurrect the
|
|
@@ -114,21 +124,30 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
114
124
|
},
|
|
115
125
|
});
|
|
116
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The current snapshot, the same object until the state changes, so React reads
|
|
129
|
+
* it through `useSyncExternalStore` without re-rendering for nothing (PERF-02).
|
|
130
|
+
* Frozen: a consumer cannot change the machine's state by mutating it.
|
|
131
|
+
*/
|
|
117
132
|
function getState(): AuthState {
|
|
118
|
-
return
|
|
133
|
+
return state;
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
function setState(patch: Partial<AuthState>): void {
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
const next = { ...state, ...patch };
|
|
138
|
+
// No change, no new snapshot and no notification.
|
|
139
|
+
if (shallowEqual(state, next)) return;
|
|
140
|
+
state = Object.freeze(next);
|
|
141
|
+
if (patch.error) {
|
|
142
|
+
// The code and audience only; `detail` is already redacted but stays local.
|
|
143
|
+
channel.emit(patch.error.code, patch.error.audience === 'user' ? 'info' : 'warn', {
|
|
144
|
+
status: state.status,
|
|
145
|
+
retryable: patch.error.retryable,
|
|
146
|
+
});
|
|
131
147
|
}
|
|
148
|
+
// Isolated: a subscriber that throws neither stops the others nor turns a
|
|
149
|
+
// completed sign-in into an error; rn-core reports it instead (CORE-03).
|
|
150
|
+
listeners.notify(getState());
|
|
132
151
|
}
|
|
133
152
|
|
|
134
153
|
/** Ends the local session: memory, cached access tokens, and the stored artifact. */
|
|
@@ -143,12 +162,7 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
143
162
|
return {
|
|
144
163
|
getState,
|
|
145
164
|
|
|
146
|
-
subscribe(listener)
|
|
147
|
-
listeners.add(listener);
|
|
148
|
-
return () => {
|
|
149
|
-
listeners.delete(listener);
|
|
150
|
-
};
|
|
151
|
-
},
|
|
165
|
+
subscribe: listener => listeners.add(listener),
|
|
152
166
|
|
|
153
167
|
async restore() {
|
|
154
168
|
sessionEpoch += 1;
|
|
@@ -362,3 +376,5 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
362
376
|
}
|
|
363
377
|
|
|
364
378
|
export type { Profile };
|
|
379
|
+
/** Every error the machine lands in also goes to the app's SDK sinks (CORE-08). */
|
|
380
|
+
const channel = createDiagnosticChannel<AuthErrorCode>('rn-auth');
|
package/src/core/session.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface PersistedSession {
|
|
|
26
26
|
refreshToken: string;
|
|
27
27
|
profile: Profile;
|
|
28
28
|
/** The id token hint for end-session (AUTH-04). Absent in artifacts written before 0.2.2. */
|
|
29
|
-
idToken?: string;
|
|
29
|
+
idToken?: string | undefined;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export interface RestoreDeps {
|
|
@@ -44,7 +44,7 @@ export interface RestoreResult {
|
|
|
44
44
|
profile?: Profile;
|
|
45
45
|
accessTier?: AccessTier;
|
|
46
46
|
refreshToken?: string;
|
|
47
|
-
idToken?: string;
|
|
47
|
+
idToken?: string | undefined;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export function parsePersistedSession(raw: string | null): PersistedSession | null {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* in logs or error objects (FR-014).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { COMMON_PATTERNS, SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
|
|
9
|
+
|
|
8
10
|
export type AuthErrorCode =
|
|
9
11
|
| 'configuration-error'
|
|
10
12
|
| 'provider-cancelled'
|
|
@@ -21,39 +23,49 @@ export interface AuthErrorArgs {
|
|
|
21
23
|
code: AuthErrorCode;
|
|
22
24
|
messageKey: string;
|
|
23
25
|
audience?: AuthErrorAudience;
|
|
24
|
-
detail?: string;
|
|
26
|
+
detail?: string | undefined;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/** JWT-shaped values: three base64url segments separated by dots. */
|
|
30
|
+
const JWT_PATTERN = /[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g;
|
|
31
|
+
/** Any other long opaque token-like run; shorter than rn-core's payload run on purpose. */
|
|
32
|
+
const TOKEN_PATTERN = /[A-Za-z0-9_-]{24,}/g;
|
|
33
|
+
|
|
27
34
|
/**
|
|
28
|
-
* Replace token-like substrings
|
|
29
|
-
*
|
|
35
|
+
* Replace token-like substrings with a placeholder so diagnostics never leak
|
|
36
|
+
* secrets or token values (FR-014): JWTs become `[redacted-token]`; URLs, e-mail
|
|
37
|
+
* addresses, UUIDs and other opaque runs become `[redacted]` (rn-core's patterns).
|
|
30
38
|
*/
|
|
31
39
|
export function redact(value: string | undefined): string | undefined {
|
|
32
40
|
if (!value) {
|
|
33
41
|
return value;
|
|
34
42
|
}
|
|
35
|
-
return (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// Any other long opaque token-like run.
|
|
40
|
-
.replace(/[A-Za-z0-9_-]{24,}/g, '[redacted]')
|
|
41
|
-
);
|
|
43
|
+
return redactPatterns(value.replace(JWT_PATTERN, '[redacted-token]'), [
|
|
44
|
+
...COMMON_PATTERNS,
|
|
45
|
+
TOKEN_PATTERN,
|
|
46
|
+
]);
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
/** Retrying can only help when the network was the problem. */
|
|
50
|
+
const RETRYABLE: ReadonlySet<AuthErrorCode> = new Set<AuthErrorCode>(['network-unavailable']);
|
|
51
|
+
|
|
52
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-auth'`. */
|
|
53
|
+
export class AuthError extends SdkError<AuthErrorCode> {
|
|
46
54
|
readonly audience: AuthErrorAudience;
|
|
47
55
|
readonly messageKey: string;
|
|
48
|
-
readonly detail?: string;
|
|
56
|
+
readonly detail?: string | undefined;
|
|
49
57
|
|
|
50
58
|
constructor(args: AuthErrorArgs) {
|
|
51
|
-
|
|
59
|
+
const detail = redact(args.detail);
|
|
60
|
+
super('rn-auth', args.code, {
|
|
61
|
+
message: args.messageKey,
|
|
62
|
+
retryable: RETRYABLE.has(args.code),
|
|
63
|
+
redactedCause: detail,
|
|
64
|
+
});
|
|
52
65
|
this.name = 'AuthError';
|
|
53
|
-
this.code = args.code;
|
|
54
66
|
this.audience = args.audience ?? 'developer';
|
|
55
67
|
this.messageKey = args.messageKey;
|
|
56
|
-
this.detail =
|
|
68
|
+
this.detail = detail;
|
|
57
69
|
}
|
|
58
70
|
}
|
|
59
71
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* developer/tenant-configuration copy are kept separate (UX-005).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { defineMessages } from '@chipmobilesdk/rn-core';
|
|
9
|
+
|
|
8
10
|
export interface AuthMessageMap {
|
|
9
11
|
// User-facing actions and states
|
|
10
12
|
'auth.action.continueGoogle': string;
|
|
@@ -32,28 +34,39 @@ export interface AuthMessageMap {
|
|
|
32
34
|
|
|
33
35
|
export type AuthMessageKey = keyof AuthMessageMap;
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The catalog, as an rn-core message set: `user` copy may reach an end user,
|
|
39
|
+
* `developer` copy describes a tenant or configuration fault (UX-005). Register
|
|
40
|
+
* `authMessages.user` with rn-i18n to translate it like app copy.
|
|
41
|
+
*/
|
|
42
|
+
export const authMessages = defineMessages({
|
|
43
|
+
user: {
|
|
44
|
+
'auth.action.continueGoogle': 'Continue with Google',
|
|
45
|
+
'auth.action.continueApple': 'Continue with Apple',
|
|
46
|
+
'auth.action.continueFacebook': 'Continue with Facebook',
|
|
47
|
+
'auth.state.signedOut': 'Sign in to continue',
|
|
48
|
+
'auth.state.authenticating': 'Signing you in…',
|
|
49
|
+
'auth.state.signedIn': 'You are signed in',
|
|
50
|
+
'auth.state.signingOut': 'Signing out…',
|
|
51
|
+
'auth.tier.free': 'Free',
|
|
52
|
+
'auth.tier.premium': 'Premium',
|
|
53
|
+
'auth.tier.unverified': 'Membership could not be verified',
|
|
54
|
+
'auth.error.cancelled': 'Sign-in was cancelled. You can try again.',
|
|
55
|
+
'auth.error.sessionExpired': 'Your session expired. Please sign in again.',
|
|
56
|
+
'auth.error.entitlement': 'This feature requires a verified premium membership.',
|
|
57
|
+
'auth.error.network': 'No connection. Some features are limited until you reconnect.',
|
|
58
|
+
},
|
|
59
|
+
developer: {
|
|
60
|
+
'auth.error.configuration': 'Authentication is not configured correctly.',
|
|
61
|
+
'auth.error.redirectMismatch': 'The sign-in redirect does not match the app configuration.',
|
|
62
|
+
'auth.error.token': 'We could not complete the request. Please try again.',
|
|
63
|
+
'auth.error.consentDenied': 'Access to this resource was denied.',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const DEFAULT_MESSAGES: AuthMessageMap = authMessages.defaults;
|
|
55
68
|
|
|
56
69
|
/** Resolve a message, preferring app overrides over the English defaults. */
|
|
57
70
|
export function resolveMessages(overrides?: Partial<AuthMessageMap>): AuthMessageMap {
|
|
58
|
-
return
|
|
71
|
+
return authMessages.resolve(overrides);
|
|
59
72
|
}
|
package/src/index.ts
CHANGED
|
@@ -54,11 +54,12 @@ export { evaluateAccessTier, failClosedTier } from './access/accessTier';
|
|
|
54
54
|
// Diagnostics
|
|
55
55
|
export { AuthError, AuthConfigError, isAuthError, redact } from './diagnostics/errors';
|
|
56
56
|
export type { AuthErrorCode, AuthErrorAudience } from './diagnostics/errors';
|
|
57
|
-
export { DEFAULT_MESSAGES, resolveMessages } from './diagnostics/messageKeys';
|
|
57
|
+
export { DEFAULT_MESSAGES, authMessages, resolveMessages } from './diagnostics/messageKeys';
|
|
58
58
|
export type { AuthMessageMap, AuthMessageKey } from './diagnostics/messageKeys';
|
|
59
59
|
|
|
60
60
|
// React bindings
|
|
61
61
|
export { AuthProvider } from './react/AuthProvider';
|
|
62
|
+
export { useAuthSelector } from './react/hooks';
|
|
62
63
|
export type { AuthProviderProps } from './react/AuthProvider';
|
|
63
64
|
export {
|
|
64
65
|
useAuth,
|
package/src/keychain.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@chipmobilesdk/rn-auth/keychain`: the default secure token store over
|
|
3
|
+
* react-native-keychain (AUTH-09). Its own entrypoint, so an app with its own
|
|
4
|
+
* secure storage never reaches this module.
|
|
5
|
+
*/
|
|
6
|
+
export { createKeychainSecureStore, type KeychainSecureStoreOptions } from './tokens/secureStore';
|
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 {};
|
|
@@ -117,13 +121,50 @@ export interface AppAuthClientOptions {
|
|
|
117
121
|
useDiscovery?: boolean;
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
interface AppAuthModule {
|
|
125
|
+
authorize(config: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
126
|
+
refresh(
|
|
127
|
+
config: Record<string, unknown>,
|
|
128
|
+
params: { refreshToken: string },
|
|
129
|
+
): Promise<Record<string, unknown>>;
|
|
130
|
+
logout(config: Record<string, unknown>, params: Record<string, unknown>): Promise<unknown>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Loaded on the first sign-in, refresh or sign-out, never at startup (PERF-05):
|
|
135
|
+
* creating the client, or the whole auth instance, loads no native code.
|
|
136
|
+
*/
|
|
137
|
+
const appAuthPeer = lazyPeer('react-native-app-auth', () => {
|
|
138
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
139
|
+
try {
|
|
140
|
+
return require('react-native-app-auth') as AppAuthModule;
|
|
141
|
+
} catch (cause) {
|
|
142
|
+
throw cause;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function appAuth(): AppAuthModule {
|
|
147
|
+
return appAuthPeer.require(
|
|
148
|
+
peer =>
|
|
149
|
+
new AuthConfigError(
|
|
150
|
+
'auth.error.configuration',
|
|
151
|
+
`${peer} is not installed or its native module is not linked`,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The module's calls, resolved only when one is made. */
|
|
157
|
+
const AppAuth: AppAuthModule = {
|
|
158
|
+
authorize: config => appAuth().authorize(config),
|
|
159
|
+
refresh: (config, params) => appAuth().refresh(config, params),
|
|
160
|
+
logout: (config, params) => appAuth().logout(config, params),
|
|
161
|
+
};
|
|
162
|
+
|
|
120
163
|
/**
|
|
121
164
|
* Default OidcAuthClient adapter wrapping react-native-app-auth (PKCE public
|
|
122
|
-
* client).
|
|
165
|
+
* client). Validated on device (R8).
|
|
123
166
|
*/
|
|
124
167
|
export function createAppAuthClient(options: AppAuthClientOptions = {}): OidcAuthClient {
|
|
125
|
-
// Lazy require so injecting a fake client never loads native code.
|
|
126
|
-
const AppAuth = require('react-native-app-auth');
|
|
127
168
|
const useDiscovery = options.useDiscovery ?? true;
|
|
128
169
|
|
|
129
170
|
const baseConfig = (
|
|
@@ -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
|
|
|
7
|
+
import { useExternalStore } from '@chipmobilesdk/rn-core/react';
|
|
6
8
|
import { useCallback, useState } from 'react';
|
|
7
|
-
import {
|
|
9
|
+
import { useAuthInstance } from './AuthProvider';
|
|
8
10
|
import type {
|
|
9
11
|
AccessTier,
|
|
10
12
|
AccessTokenResult,
|
|
@@ -26,8 +28,25 @@ export interface UseAuthResult {
|
|
|
26
28
|
requirePremium(): PremiumGateResult;
|
|
27
29
|
}
|
|
28
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
|
+
|
|
29
47
|
export function useAuth(): UseAuthResult {
|
|
30
|
-
const
|
|
48
|
+
const auth = useAuthInstance();
|
|
49
|
+
const state = useAuthSelector(selectState);
|
|
31
50
|
return {
|
|
32
51
|
state,
|
|
33
52
|
signIn: useCallback((providerId: ProviderId) => auth.signIn(providerId), [auth]),
|
|
@@ -39,24 +58,22 @@ export function useAuth(): UseAuthResult {
|
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
export function useProfile(): Profile | undefined {
|
|
42
|
-
|
|
43
|
-
return state.profile;
|
|
61
|
+
return useAuthSelector(selectProfile);
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
export function useAccessTier(): AccessTier {
|
|
47
|
-
|
|
48
|
-
return state.accessTier ?? failClosedTier();
|
|
65
|
+
return useAuthSelector(selectAccessTier) ?? failClosedTier();
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
/** The first provider to present on the current platform (UX-004), or undefined. */
|
|
52
69
|
export function useSignInProvider(): ProviderConfig | undefined {
|
|
53
|
-
const
|
|
70
|
+
const auth = useAuthInstance();
|
|
54
71
|
return auth.getPlatformProvider();
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
/** All available providers for the current platform, in configuration order (FR-003/FR-004). */
|
|
58
75
|
export function useSignInProviders(): ProviderConfig[] {
|
|
59
|
-
const
|
|
76
|
+
const auth = useAuthInstance();
|
|
60
77
|
return auth.getPlatformProviders();
|
|
61
78
|
}
|
|
62
79
|
|
|
@@ -65,12 +82,12 @@ export type UseAccessTokenStatus =
|
|
|
65
82
|
|
|
66
83
|
export interface UseAccessTokenResult {
|
|
67
84
|
status: UseAccessTokenStatus;
|
|
68
|
-
token?: string;
|
|
85
|
+
token?: string | undefined;
|
|
69
86
|
request(): Promise<AccessTokenResult>;
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
export function useAccessToken(resourceKey: string): UseAccessTokenResult {
|
|
73
|
-
const
|
|
90
|
+
const auth = useAuthInstance();
|
|
74
91
|
const [status, setStatus] = useState<UseAccessTokenStatus>('idle');
|
|
75
92
|
const [token, setToken] = useState<string | undefined>(undefined);
|
|
76
93
|
|
|
@@ -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';
|
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
* Refresh tokens and the session-restore artifact persist ONLY through this
|
|
5
5
|
* port, backed by platform secure storage (iOS Keychain / Android Keystore).
|
|
6
6
|
* Access tokens are never written here — they stay in memory (see accessToken).
|
|
7
|
-
* The default adapter
|
|
8
|
-
* inject a fake
|
|
7
|
+
* The default adapter loads the native module on its first read or write, so
|
|
8
|
+
* creating it, and unit tests that inject a fake, load no native code.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { lazyPeer } from '@chipmobilesdk/rn-core';
|
|
12
|
+
|
|
13
|
+
import { AuthConfigError } from '../diagnostics/errors';
|
|
14
|
+
|
|
11
15
|
/** Single key under which the persisted session artifact is stored. */
|
|
12
16
|
export const AUTH_STORAGE_KEY = '@chipmobilesdk/rn-auth/session';
|
|
13
17
|
|
|
@@ -29,23 +33,53 @@ export interface KeychainSecureStoreOptions {
|
|
|
29
33
|
export function createKeychainSecureStore(
|
|
30
34
|
options: KeychainSecureStoreOptions = {},
|
|
31
35
|
): SecureTokenStore {
|
|
32
|
-
//
|
|
33
|
-
const
|
|
34
|
-
|
|
36
|
+
// The native module loads on the first read or write, never at startup (PERF-05).
|
|
37
|
+
const accessible = () =>
|
|
38
|
+
options.accessible ?? keychain().ACCESSIBLE?.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
|
|
35
39
|
|
|
36
40
|
return {
|
|
37
41
|
async getItem(key) {
|
|
38
|
-
const result = await
|
|
42
|
+
const result = await keychain().getGenericPassword({ service: key });
|
|
39
43
|
return result ? result.password : null;
|
|
40
44
|
},
|
|
41
45
|
async setItem(key, value) {
|
|
42
|
-
await
|
|
46
|
+
await keychain().setGenericPassword('token', value, {
|
|
43
47
|
service: key,
|
|
44
|
-
accessible,
|
|
48
|
+
accessible: accessible(),
|
|
45
49
|
});
|
|
46
50
|
},
|
|
47
51
|
async removeItem(key) {
|
|
48
|
-
await
|
|
52
|
+
await keychain().resetGenericPassword({ service: key });
|
|
49
53
|
},
|
|
50
54
|
};
|
|
51
55
|
}
|
|
56
|
+
|
|
57
|
+
interface KeychainModule {
|
|
58
|
+
ACCESSIBLE?: { WHEN_UNLOCKED_THIS_DEVICE_ONLY?: string };
|
|
59
|
+
getGenericPassword(options: { service: string }): Promise<false | { password: string }>;
|
|
60
|
+
setGenericPassword(
|
|
61
|
+
username: string,
|
|
62
|
+
password: string,
|
|
63
|
+
options: { service: string; accessible: string | undefined },
|
|
64
|
+
): Promise<unknown>;
|
|
65
|
+
resetGenericPassword(options: { service: string }): Promise<unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const keychainPeer = lazyPeer('react-native-keychain', () => {
|
|
69
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
70
|
+
try {
|
|
71
|
+
return require('react-native-keychain') as KeychainModule;
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
throw cause;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function keychain(): KeychainModule {
|
|
78
|
+
return keychainPeer.require(
|
|
79
|
+
peer =>
|
|
80
|
+
new AuthConfigError(
|
|
81
|
+
'auth.error.configuration',
|
|
82
|
+
`${peer} is not installed or its native module is not linked`,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|