@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,146 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-auth
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- A restored session reports the provider the user signed in with. A refresh answers with the tenant's own `idp` (`google.com`), so after a restart the same session used to describe itself differently — `profile.provider` changed from `google` to `google.com`. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
|
|
8
|
+
|
|
9
|
+
`auth.canAccessPremium()` answers whether a premium path is open and changes nothing. `requirePremium()`, which also moves the whole controller to `access-denied` — a status every screen reading `state.status` then sees, and one only a sign-in or `restore()` leaves — is deprecated and will be removed in 0.5.0. Both are on `useAuth()`.
|
|
10
|
+
|
|
11
|
+
`ProviderId` is open (`KnownProviderId | (string & {})`), `labelKey` takes an app's own message key, and `ProviderConfig.additionalParameters` passes extra OIDC parameters such as `prompt`, so federating another identity provider is a change to an app's config rather than a release of this package. A provider's own parameters cannot override `domain_hint`.
|
|
12
|
+
|
|
13
|
+
`config.messages` is read at last: `auth.getMessages()` and `useAuthMessages()` return this package's copy with the app's overrides applied. Configuration validation refuses a plaintext `authority` (http is accepted only for localhost), a provider platform other than `ios`/`android`, and a provider with no `labelKey`. `createAppAuthClient({ serviceConfiguration })` takes the endpoints directly, so an authorize, refresh or sign-out no longer costs a `.well-known` round-trip. `mapClaimsToProfile` and `describeProviderIdentity` are exported, which is what the already-public `ProviderIdentity` type describes.
|
|
14
|
+
|
|
15
|
+
`useAccessToken(resourceKey)` drops a result that arrives after the component unmounted or moved to another resource, and starts from `idle` when the resource changes.
|
|
16
|
+
|
|
17
|
+
The stored session artifact holds an identity — tenant user id, display name, email, provider — and no longer the whole id-token claim set, group ids included; a restore that rotated nothing leaves the Keychain untouched. After an `offline` restore, `profile.claims` is empty and the tier fails closed, as before. The never-emitted `entitlement-unverified` error code is gone from `AuthErrorCode`, and `AuthError.messageKey` is typed as `AuthMessageKey` rather than `string`.
|
|
18
|
+
|
|
19
|
+
- A 401 retry now gets a token the provider had to fetch again. `TokenProvider.requestAccessToken` takes `{ forceRefresh }`, which rn-auth implements by dropping the cached token for that resource, so the retry no longer re-sends the token the gateway had just refused — a cached token is still unexpired by the device's clock when the server rejects it. ([#19](https://github.com/operationd8979/chip-mobile-sdk/pull/19))
|
|
20
|
+
|
|
21
|
+
Failures an app can tell apart. `network` is a request that never arrived (no connection, a timeout, a dropped stream) and is retryable; `session-expired` is a session the user must renew. `AiError.cause` carries the code from the layer underneath — the gateway's own code, or rn-auth's — so a screen can explain *why*, and `retryAfterSeconds` is read from the `Retry-After` header when the body omits it. Both new codes have user copy, and the gateway's full code list is mapped rather than guessed from the status.
|
|
22
|
+
|
|
23
|
+
The remote behavior config is checked before it is trusted. `configUrl` and the document's `api.baseUrl` must be https (localhost excepted), the document must name the app it is for, its `schemaVersion` must be one this release understands, and `allowedHosts` pins where the client may send its bearer token. A broken TTL no longer means a revalidation on every request, and a retry count is bounded. A not-modified answer against a cache that is no longer there asks again without the ETag instead of reporting the 304 as a failure.
|
|
24
|
+
|
|
25
|
+
`configCacheFromKeyValue(port)` makes a durable config cache out of rn-core's `KeyValuePort`, so a second launch uses the last-good document instead of waiting for the network.
|
|
26
|
+
|
|
27
|
+
One quota figure: a chat's `done` event and `getQuota()` both record it on the client (`lastQuota()`), so `useAiQuota` and `useAiChat` cannot disagree, and a quota response that does not match the contract is refused rather than displayed. `getQuota()` recovers like a chat: a designated 404/410 reloads the configuration and retries, and a 401 retries once.
|
|
28
|
+
|
|
29
|
+
Streamed text reaches the screen on an interval (50 ms by default, `useAiChat(client, { flushIntervalMs })`) rather than once per token, and whatever arrived is flushed with the terminal state, so a half-answer stays on screen when a stream fails. A stream framed with CRLF is parsed incrementally, as the SSE specification allows. The transport seam is a published `StreamTransport` port instead of the internal reader's signature.
|
|
30
|
+
|
|
31
|
+
## 0.4.0
|
|
32
|
+
|
|
33
|
+
### Minor Changes
|
|
34
|
+
|
|
35
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
36
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
37
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
38
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
39
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
40
|
+
|
|
41
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
42
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
43
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
44
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
45
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
46
|
+
builds only. The warn-once memory is bounded.
|
|
47
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
48
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
49
|
+
instead of swallowing it.
|
|
50
|
+
|
|
51
|
+
- The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
|
|
52
|
+
dependency: `isSdkError` recognises them alongside every other package's, and each
|
|
53
|
+
carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
54
|
+
|
|
55
|
+
- rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
|
|
56
|
+
deprecated alias.
|
|
57
|
+
- rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
|
|
58
|
+
e-mail addresses and UUIDs.
|
|
59
|
+
- rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
|
|
60
|
+
to its code.
|
|
61
|
+
- rn-ai: an error message quoted from the gateway is pattern-redacted.
|
|
62
|
+
- rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
|
|
63
|
+
- rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
|
|
64
|
+
`code: 'contrast'` (new `ThemeErrorCode` type).
|
|
65
|
+
- rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
|
|
66
|
+
`code: 'unit_conversion'` (new `I18nErrorCode` type).
|
|
67
|
+
|
|
68
|
+
- Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
|
|
69
|
+
and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
70
|
+
|
|
71
|
+
- rn-auth: creating `auth` no longer loads react-native-app-auth or
|
|
72
|
+
react-native-keychain; each loads on first use. New entrypoints
|
|
73
|
+
`@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
|
|
74
|
+
rn-core.
|
|
75
|
+
- rn-camera: a preview whose VisionCamera feed cannot load fails the session with
|
|
76
|
+
`cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
|
|
77
|
+
showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
|
|
78
|
+
- rn-notification: `createMmkvStore` reports a fallback to memory as
|
|
79
|
+
`STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
|
|
80
|
+
exposes `durable`.
|
|
81
|
+
- rn-payment: an iOS app validates its catalog against the iOS product ids; the
|
|
82
|
+
default platform used to be Android everywhere. A missing react-native-iap is
|
|
83
|
+
`PAYMENT_UNAVAILABLE`.
|
|
84
|
+
- rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
|
|
85
|
+
importing the package no longer loads Unistyles; `applyThemeMode` loads it on
|
|
86
|
+
first use.
|
|
87
|
+
- rn-ads, rn-local-db: the same loading, with the same error codes as before.
|
|
88
|
+
|
|
89
|
+
- Hooks read through `useSyncExternalStore` (rn-core's `useExternalStore`) and
|
|
90
|
+
re-render only when what they return changes. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
91
|
+
|
|
92
|
+
- rn-auth: `useProfile` and `useAccessTier` no longer re-render for a status change,
|
|
93
|
+
and the provider's context no longer changes on every transition. New
|
|
94
|
+
`useAuthSelector`. `auth.getState()` returns the same frozen object until the state
|
|
95
|
+
changes, instead of a copy per call.
|
|
96
|
+
- rn-payment: `useEntitlement` re-renders only when its own entitlement changes; the
|
|
97
|
+
engine keeps unchanged entitlement objects across syncs and does not notify for a
|
|
98
|
+
sync that changed nothing.
|
|
99
|
+
- rn-ads: `usePlacementState` and `useAdsStatus` read tear-free; `useAdsEvents` keeps
|
|
100
|
+
one subscription when given an inline listener.
|
|
101
|
+
- rn-i18n: `useTranslation` returns the same `t` until the locale changes, and
|
|
102
|
+
`useLocale`'s `setLocale` is stable.
|
|
103
|
+
- rn-ai: new `useAiBehaviorState`, with `client.subscribe` and a stable
|
|
104
|
+
`client.getBehaviorState()`.
|
|
105
|
+
|
|
106
|
+
- 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))
|
|
107
|
+
|
|
108
|
+
- rn-i18n: new `runtimeEntriesFromCatalog(catalog)` registers any SDK catalog in one
|
|
109
|
+
line, converting `{name}` variables to `{{name}}`.
|
|
110
|
+
- rn-ai: new `messageKeyForError(error)` gives the copy for every error code, and
|
|
111
|
+
`aiMessageSet`. The input placeholder default is neutral ("Type a message…"), and
|
|
112
|
+
two keys were added for errors that had none (`ai.error.signIn`, `ai.error.generic`).
|
|
113
|
+
- rn-auth: new `authMessages`, the catalog split into user and developer copy.
|
|
114
|
+
- rn-payment: new `paymentMessages`.
|
|
115
|
+
- rn-chart: summaries, announcements, adjustable-action labels, built-in state text
|
|
116
|
+
and legend hints come from `chartStrings`; pass `strings` to translate them. The
|
|
117
|
+
`accessibility.summaryFormatter` prop, declared before, now replaces the summary.
|
|
118
|
+
|
|
119
|
+
- Port conformance suites: one list of checks that a package's fake and its real adapter
|
|
120
|
+
both pass, so a test written against the fake proves something about the device. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
121
|
+
|
|
122
|
+
- rn-core: `@chipmobilesdk/rn-core/testing` adds `runChecks`, `expectConformance`,
|
|
123
|
+
`ensure` and `createFaultRegistry` (deterministic fault injection by named point).
|
|
124
|
+
- rn-camera: a `./testing` entrypoint with `runPermissionPortConformance`,
|
|
125
|
+
`runCameraEngineConformance` and the test adapter, which stays on the main
|
|
126
|
+
entrypoint too until the next major. `createTestCamera(...).prompts()` counts system
|
|
127
|
+
prompts.
|
|
128
|
+
- rn-payment: `runProviderPortConformance` in `./testing`, passed by the fake store and
|
|
129
|
+
the Play adapter.
|
|
130
|
+
- rn-ads: `runAdsEngineConformance` in `./testing`, passed by the fake engine and the
|
|
131
|
+
AdMob adapter.
|
|
132
|
+
- rn-auth: a `./testing` entrypoint with `createMemorySecureStore`,
|
|
133
|
+
`runSecureTokenStoreConformance` and `runOidcAuthClientConformance`, passed by the
|
|
134
|
+
in-memory doubles and the keychain and app-auth adapters.
|
|
135
|
+
|
|
136
|
+
### Patch Changes
|
|
137
|
+
|
|
138
|
+
- Optional properties in the public types (`AuthState`, `Profile`, `UseAccessTokenResult`
|
|
139
|
+
and others) now accept an explicit `undefined`, so apps compiling with
|
|
140
|
+
`exactOptionalPropertyTypes` type-check against the package. No runtime change. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
141
|
+
- 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)):
|
|
142
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
143
|
+
|
|
3
144
|
## 0.3.1
|
|
4
145
|
|
|
5
146
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -74,11 +74,45 @@ export const App = () => (
|
|
|
74
74
|
);
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
Hooks: `useAuth()` (state + `signIn`/`signOut`/`signOutGlobal`/`restore`/`
|
|
77
|
+
Hooks: `useAuth()` (state + `signIn`/`signOut`/`signOutGlobal`/`restore`/`canAccessPremium`),
|
|
78
78
|
`useProfile()`, `useAccessTier()`, `useAccessToken(resourceKey)`,
|
|
79
|
+
`useAuthMessages()` (this package's copy, with your `messages` applied),
|
|
79
80
|
`useSignInProviders()` (all platform providers, in config order — map to one
|
|
80
81
|
button each) and `useSignInProvider()` (first available, for a single-button UI).
|
|
81
82
|
|
|
83
|
+
### Gating a premium path
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
const { canAccessPremium } = useAuth();
|
|
87
|
+
const gate = canAccessPremium(); // { allowed, tier } — reads, changes nothing
|
|
88
|
+
|
|
89
|
+
return gate.allowed ? <PremiumReport /> : <UpgradeNotice tier={gate.tier} />;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`canAccessPremium()` is a question. `requirePremium()`, which answers the same
|
|
93
|
+
question *and* moves the whole controller to `access-denied` — a status every
|
|
94
|
+
screen reading `state.status` then sees, and only a sign-in or `restore()`
|
|
95
|
+
leaves — is deprecated and will go in 0.5.0.
|
|
96
|
+
|
|
97
|
+
### Providers beyond the three with built-in copy
|
|
98
|
+
|
|
99
|
+
A provider is a `domain_hint` and a label, so adding one the tenant federates is
|
|
100
|
+
a change to this config and nothing else:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
providers: [
|
|
104
|
+
{ id: 'microsoft', platforms: ['ios', 'android'], domainHint: 'live.com',
|
|
105
|
+
labelKey: 'app.action.continueMicrosoft', // one of your own keys
|
|
106
|
+
additionalParameters: { prompt: 'select_account' }, // merged into authorize
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`ProviderId` is `KnownProviderId | (string & {})`: `'google' | 'apple' |
|
|
112
|
+
'facebook'` stay in autocomplete, and any other id is accepted. A provider's
|
|
113
|
+
`additionalParameters` cannot override `domain_hint`, so it can never route the
|
|
114
|
+
flow to another IdP.
|
|
115
|
+
|
|
82
116
|
### Session lifecycle guarantees
|
|
83
117
|
|
|
84
118
|
- **Restore deletes the stored session only when the provider rejects the refresh
|
|
@@ -91,6 +125,15 @@ button each) and `useSignInProvider()` (first available, for a single-button UI)
|
|
|
91
125
|
`requestAccessToken` calls for one resource share one network refresh.
|
|
92
126
|
- **`signOutGlobal()` returns `{ global: 'ended' | 'failed', error? }`.** Local state
|
|
93
127
|
is cleared either way; `failed` means the tenant session may still be alive.
|
|
128
|
+
- **A restored profile reports the provider the user signed in with.** The stored
|
|
129
|
+
artifact carries it, because a refresh answers with the tenant's own `idp`
|
|
130
|
+
(`google.com`), which is not the id the app passed to `signIn`.
|
|
131
|
+
- **The stored artifact holds an identity, not a token's claims:** the tenant user
|
|
132
|
+
id, display name, email and provider. After a restore that reached the provider,
|
|
133
|
+
`profile.claims` is the fresh id-token claim set; after an `offline` restore it is
|
|
134
|
+
empty, and the access tier fails closed. Read `state.restore` to tell them apart.
|
|
135
|
+
- **An unchanged session is not rewritten.** A restore that rotated nothing leaves
|
|
136
|
+
the Keychain untouched, off the startup path.
|
|
94
137
|
|
|
95
138
|
## Configuration contract (LIB-010)
|
|
96
139
|
|
|
@@ -106,10 +149,15 @@ client secrets are configured in the External ID tenant, never in the app
|
|
|
106
149
|
| C2 — Facebook (optional) | `providers[facebook]`: `domainHint: 'facebook.com'`, `platforms: ['ios','android']`, `labelKey: 'auth.action.continueFacebook'`, optional `available` |
|
|
107
150
|
| D — Resources | `resources[].key`, `resources[].scopes`, `resources[].displayName?` |
|
|
108
151
|
| E — Access tier | `accessTierMapping.premiumGroupObjectIds` (+ optional role names / claim matchers; display names are diagnostics-only) |
|
|
109
|
-
| F — Ports | `secureStore` (required), `authClient` (optional), `messages` (optional copy override) |
|
|
152
|
+
| F — Ports | `secureStore` (required), `authClient` (optional), `messages` (optional copy override, readable through `useAuthMessages()`) |
|
|
110
153
|
|
|
111
|
-
|
|
112
|
-
|
|
154
|
+
`createAuth` throws `AuthConfigError` before any flow can start when: `authority`
|
|
155
|
+
is missing, relative, or not `https` (plain `http` is accepted only for
|
|
156
|
+
`localhost`); `redirectUrl` has no scheme; `offline_access` is absent from
|
|
157
|
+
`scopes`; no provider is configured, or one repeats an id, or lacks a
|
|
158
|
+
`domainHint` or `labelKey`, or declares a platform other than `ios`/`android`; a
|
|
159
|
+
resource repeats a key or declares no scope; `accessTierMapping` names no premium
|
|
160
|
+
indicator; or no `secureStore` is supplied.
|
|
113
161
|
|
|
114
162
|
## Tenant / provider prerequisite checklist (LIB-018)
|
|
115
163
|
|
|
@@ -145,7 +193,9 @@ can sign users in:
|
|
|
145
193
|
Keystore). Access tokens are kept in memory only.
|
|
146
194
|
- Access-tier decisions prefer stable group **object ids**; display names never
|
|
147
195
|
grant access; missing/overage claims fail closed.
|
|
148
|
-
- Diagnostics (`AuthError`) never contain secrets or token values.
|
|
196
|
+
- Diagnostics (`AuthError`) never contain secrets or token values. `AuthError` extends
|
|
197
|
+
`SdkError` from `@chipmobilesdk/rn-core`: `isSdkError(error)` recognises it, `error.pkg`
|
|
198
|
+
is `rn-auth`, and `error.retryable` is true only for `network-unavailable`.
|
|
149
199
|
- Authentication and profile data are collected and transmitted to the
|
|
150
200
|
identity providers and configured Azure resources — update Apple App Privacy
|
|
151
201
|
and Google Play Data Safety before release.
|
|
@@ -158,16 +208,54 @@ are unsupported.
|
|
|
158
208
|
|
|
159
209
|
- Controller and configuration: `createAuth`, `Auth`, `AuthConfig`,
|
|
160
210
|
`CreateAuthOptions`, `AuthState`, `AuthStatus`, `RestoreStatus`
|
|
161
|
-
- Identity and access: `Profile`, `ProviderConfig`, `
|
|
162
|
-
`
|
|
163
|
-
`
|
|
211
|
+
- Identity and access: `Profile`, `ProviderConfig`, `ProviderId`,
|
|
212
|
+
`KnownProviderId`, `ProviderIdentity`, `ResourceConfig`, `AccessTier`,
|
|
213
|
+
`AccessTierMapping`, `AccessTokenResult`, `PremiumGateResult`,
|
|
214
|
+
`evaluateAccessTier`, `failClosedTier`, `mapClaimsToProfile`,
|
|
215
|
+
`describeProviderIdentity` (what an address is: `direct`, Apple's
|
|
216
|
+
`private-relay`, or `none`)
|
|
164
217
|
- Ports and default adapters: `SecureTokenStore`, `createKeychainSecureStore`,
|
|
165
218
|
`AUTH_STORAGE_KEY`, `OidcAuthClient`, `createAppAuthClient` and the public OIDC
|
|
166
|
-
request/token types
|
|
219
|
+
request/token types. `createAppAuthClient({ serviceConfiguration })` takes the
|
|
220
|
+
authorization, token and end-session endpoints directly; without them the native
|
|
221
|
+
module fetches `.well-known` on every authorize, refresh and sign-out, including
|
|
222
|
+
the refresh on the startup path. The two default adapters also have their own entrypoints,
|
|
223
|
+
`@chipmobilesdk/rn-auth/app-auth` and `@chipmobilesdk/rn-auth/keychain`, so an app
|
|
224
|
+
that injects its own client or store can skip them. Neither loads its native
|
|
225
|
+
module until it is used: react-native-app-auth on the first sign-in, refresh or
|
|
226
|
+
sign-out, react-native-keychain on the first read or write. Creating `auth` at
|
|
227
|
+
startup loads neither. A missing module is an `AuthConfigError` at that moment.
|
|
167
228
|
- React: `AuthProvider`, `useAuth`, `useProfile`, `useAccessTier`,
|
|
168
|
-
`useAccessToken`, `useSignInProvider`, `useSignInProviders
|
|
229
|
+
`useAccessToken`, `useAuthMessages`, `useSignInProvider`, `useSignInProviders`,
|
|
230
|
+
`useAuthSelector`. `useAccessToken(resourceKey)` starts from `idle` again when
|
|
231
|
+
the resource changes, and a result that arrives after the component unmounted
|
|
232
|
+
or moved on is dropped rather than applied.
|
|
233
|
+
Each hook reads the state through `useSyncExternalStore` and re-renders only when
|
|
234
|
+
what it returns changes: `useProfile` does not render for a status change. Use
|
|
235
|
+
`useAuthSelector(state => …, isEqual?)` for any other slice. `auth.getState()`
|
|
236
|
+
returns the same frozen object until the state changes.
|
|
169
237
|
- Diagnostics and copy: `AuthError`, `AuthConfigError`, `isAuthError`, `redact`,
|
|
170
|
-
`DEFAULT_MESSAGES`, `resolveMessages` and their public code/message types
|
|
238
|
+
`DEFAULT_MESSAGES`, `resolveMessages` and their public code/message types, and
|
|
239
|
+
`authMessages`, the catalog split into `user` and `developer` copy. Register the user
|
|
240
|
+
copy with rn-i18n in one line: `runtimeEntriesFromCatalog(authMessages.user)`.
|
|
241
|
+
|
|
242
|
+
## Testing
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
import { createAuth } from '@chipmobilesdk/rn-auth';
|
|
246
|
+
import { createMemorySecureStore } from '@chipmobilesdk/rn-auth/testing';
|
|
247
|
+
|
|
248
|
+
const auth = createAuth({ ...config, secureStore: createMemorySecureStore(), authClient });
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`@chipmobilesdk/rn-auth/testing` is a separate entrypoint, so nothing in it reaches a
|
|
252
|
+
production bundle. Besides `createMemorySecureStore(seed?)` it holds the checks every
|
|
253
|
+
adapter must pass: `runSecureTokenStoreConformance` (null for an unknown key, last write
|
|
254
|
+
wins, keys kept apart, safe removal, a long non-ASCII artifact intact) and
|
|
255
|
+
`runOidcAuthClientConformance` (sign-in with claims and a future expiry, refresh, a
|
|
256
|
+
closed sheet as `provider-cancelled`, a rejected refresh token as `session-expired`, and
|
|
257
|
+
sign-out). The package runs them against its doubles and the keychain and app-auth
|
|
258
|
+
adapters; run them against a store or client you inject.
|
|
171
259
|
|
|
172
260
|
## Compatibility and validation
|
|
173
261
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -35,12 +35,30 @@ value.
|
|
|
35
35
|
|
|
36
36
|
## Public API
|
|
37
37
|
|
|
38
|
+
`createAuth(config)` is the entry point: one controller, one active account, created at
|
|
39
|
+
startup without loading any native module. Wrap the app in `<AuthProvider auth={auth}>`
|
|
40
|
+
and read state through the hooks — never re-implement the orchestration.
|
|
41
|
+
|
|
38
42
|
Runtime:
|
|
39
43
|
|
|
40
|
-
- `
|
|
44
|
+
- `createAuth` — the controller: `getState`, `subscribe`, `restore`, `signIn(providerId)`,
|
|
45
|
+
`signOut`, `signOutGlobal`, `requestAccessToken(resourceKey)`, `getAccessTier`,
|
|
46
|
+
`canAccessPremium`, `getPlatformProvider(s)`, `getMessages`
|
|
47
|
+
- Hooks: `useAuth`, `useProfile`, `useAccessTier`, `useAccessToken`, `useAuthMessages`,
|
|
48
|
+
`useSignInProvider`, `useSignInProviders`, `useAuthSelector`
|
|
49
|
+
- `AuthProvider`
|
|
50
|
+
- `createAppAuthClient(...)` — the OIDC client; pass `serviceConfiguration` to skip a
|
|
51
|
+
`.well-known` fetch on every call
|
|
41
52
|
- `createKeychainSecureStore(...)` — token persistence; `AUTH_STORAGE_KEY`
|
|
42
|
-
- `evaluateAccessTier(...)`, `failClosedTier` — feature gating
|
|
53
|
+
- `evaluateAccessTier(...)`, `failClosedTier` — feature gating, denying when unknown
|
|
54
|
+
- `mapClaimsToProfile`, `describeProviderIdentity` — claims to a profile, and whether an
|
|
55
|
+
address is direct, Apple's private relay, or absent
|
|
43
56
|
- `AuthProvider` — React context provider
|
|
57
|
+
- `AuthError`, `AuthConfigError`, `isAuthError`, `redact`, `authMessages`,
|
|
58
|
+
`resolveMessages`, `DEFAULT_MESSAGES`
|
|
59
|
+
|
|
60
|
+
Testing (`@chipmobilesdk/rn-auth/testing`): `createMemorySecureStore`,
|
|
61
|
+
`runSecureTokenStoreConformance`, `runOidcAuthClientConformance` — README `## Testing`.
|
|
44
62
|
|
|
45
63
|
Types: `SecureTokenStore`, `AuthProviderProps`, `AuthErrorCode`, `AuthErrorAudience`,
|
|
46
64
|
`AuthMessageMap`, `AuthMessageKey`
|
|
@@ -48,6 +66,24 @@ Types: `SecureTokenStore`, `AuthProviderProps`, `AuthErrorCode`, `AuthErrorAudie
|
|
|
48
66
|
`AuthErrorAudience` distinguishes what may be shown to a user from what is diagnostic
|
|
49
67
|
only — never render a diagnostic-audience message in the UI.
|
|
50
68
|
|
|
69
|
+
## Three things that bite
|
|
70
|
+
|
|
71
|
+
- **`signOutGlobal()` can report `{ global: 'failed' }`.** Local state is cleared either
|
|
72
|
+
way, but the tenant session may still be alive, so the next sign-in can complete with
|
|
73
|
+
no password prompt. Say so in the UI rather than claiming the user is fully signed out.
|
|
74
|
+
- **`requestAccessToken(resourceKey)` answers with a status, not a throw.** Handle
|
|
75
|
+
`denied` and `unavailable` as states; only `success` carries a token. `useAccessToken`
|
|
76
|
+
starts from `idle` again when the resource key changes.
|
|
77
|
+
- **iOS needs the native redirect set up** (URL scheme matching `redirectUrl`, and the
|
|
78
|
+
Apple provider configured in the tenant). A `redirect-mismatch` error is that, not a
|
|
79
|
+
code fault — see the prerequisite checklist.
|
|
80
|
+
|
|
81
|
+
## A premium gate is a question
|
|
82
|
+
|
|
83
|
+
Use `canAccessPremium()` and render the denial where it happened.
|
|
84
|
+
`requirePremium()` is deprecated: it also moves the whole controller to
|
|
85
|
+
`access-denied`, which every screen reading `state.status` then sees.
|
|
86
|
+
|
|
51
87
|
## Where the detail lives
|
|
52
88
|
|
|
53
89
|
Read one section of `node_modules/@chipmobilesdk/rn-auth/README.md`:
|
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
|
@@ -3,23 +3,44 @@
|
|
|
3
3
|
* specs/003-mobile-entra-auth/data-model.md.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { AccessTokenRequestOptions } from '../tokens/accessToken';
|
|
6
7
|
import type { SecureTokenStore } from '../tokens/secureStore';
|
|
7
8
|
import type { OidcAuthClient } from '../oidc/authClient';
|
|
8
9
|
import type { AuthError, AuthErrorCode } from '../diagnostics/errors';
|
|
9
10
|
import type { AuthMessageKey, AuthMessageMap } from '../diagnostics/messageKeys';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
/** The providers this package has copy for out of the box. */
|
|
13
|
+
export type KnownProviderId = 'google' | 'apple' | 'facebook';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A provider id. Open on purpose: routing is `domain_hint` and nothing else, so
|
|
17
|
+
* adding an IdP the tenant federates — Microsoft account, an email one-time
|
|
18
|
+
* code — is a change to an app's config, not a release of this package
|
|
19
|
+
* (AUTH-14). `KnownProviderId` keeps the three familiar ones in autocomplete.
|
|
20
|
+
*/
|
|
21
|
+
export type ProviderId = KnownProviderId | (string & {});
|
|
12
22
|
export type DevicePlatform = 'ios' | 'android';
|
|
13
23
|
|
|
24
|
+
export const DEVICE_PLATFORMS: readonly DevicePlatform[] = ['ios', 'android'];
|
|
25
|
+
|
|
14
26
|
/** Provider Setup Configuration — non-secret public values only (FR-016/FR-017). */
|
|
15
27
|
export interface ProviderConfig {
|
|
16
28
|
id: ProviderId;
|
|
17
29
|
platforms: DevicePlatform[];
|
|
18
30
|
/** External ID `domain_hint` routing the user flow to this IdP (R3). */
|
|
19
31
|
domainHint: string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Message key for the button label (Continue with Google / Apple). A key of
|
|
34
|
+
* this package's catalogue, or one of the app's own for a provider it added.
|
|
35
|
+
*/
|
|
36
|
+
labelKey: AuthMessageKey | (string & {});
|
|
22
37
|
available?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Extra OIDC authorize parameters for this provider, merged after
|
|
40
|
+
* `domain_hint` — `prompt`, `login_hint`, whatever the tenant's user flow
|
|
41
|
+
* expects. `domain_hint` itself cannot be overridden here.
|
|
42
|
+
*/
|
|
43
|
+
additionalParameters?: Record<string, string>;
|
|
23
44
|
}
|
|
24
45
|
|
|
25
46
|
/** One configured protected resource for delegated token requests (FR-007). */
|
|
@@ -55,6 +76,11 @@ export interface AuthConfig {
|
|
|
55
76
|
accessTierMapping: AccessTierMapping;
|
|
56
77
|
secureStore: SecureTokenStore;
|
|
57
78
|
authClient?: OidcAuthClient;
|
|
79
|
+
/**
|
|
80
|
+
* Copy overrides for this package's own message keys, resolved over the
|
|
81
|
+
* English defaults and readable through `auth.getMessages()` and
|
|
82
|
+
* `useAuthMessages()`.
|
|
83
|
+
*/
|
|
58
84
|
messages?: Partial<AuthMessageMap>;
|
|
59
85
|
}
|
|
60
86
|
|
|
@@ -78,9 +104,14 @@ export type RestoreStatus = 'pending' | 'restored' | 'unavailable' | 'offline';
|
|
|
78
104
|
/** Customer User / app-visible profile (FR-006). */
|
|
79
105
|
export interface Profile {
|
|
80
106
|
tenantUserId: string;
|
|
81
|
-
displayName?: string;
|
|
82
|
-
email?: string;
|
|
107
|
+
displayName?: string | undefined;
|
|
108
|
+
email?: string | undefined;
|
|
83
109
|
provider: string;
|
|
110
|
+
/**
|
|
111
|
+
* The id-token claims this profile was mapped from. Empty after a restore
|
|
112
|
+
* that could not reach the provider: the stored artifact holds the identity,
|
|
113
|
+
* not the token (AUTH-23). Read `restore` on the state to tell the two apart.
|
|
114
|
+
*/
|
|
84
115
|
claims: Record<string, unknown>;
|
|
85
116
|
}
|
|
86
117
|
|
|
@@ -112,10 +143,10 @@ export interface AccessTokenResult {
|
|
|
112
143
|
/** Single active app-visible authenticated state (FR-005/FR-022). */
|
|
113
144
|
export interface AuthState {
|
|
114
145
|
status: AuthStatus;
|
|
115
|
-
profile?: Profile;
|
|
116
|
-
accessTier?: AccessTier;
|
|
146
|
+
profile?: Profile | undefined;
|
|
147
|
+
accessTier?: AccessTier | undefined;
|
|
117
148
|
restore: RestoreStatus;
|
|
118
|
-
error?: AuthError;
|
|
149
|
+
error?: AuthError | undefined;
|
|
119
150
|
}
|
|
120
151
|
|
|
121
152
|
/** Result of a premium-only gate check (US3). */
|
|
@@ -143,10 +174,27 @@ export interface Auth {
|
|
|
143
174
|
signIn(providerId: ProviderId): Promise<AuthState>;
|
|
144
175
|
signOut(): Promise<void>;
|
|
145
176
|
signOutGlobal(): Promise<GlobalSignOutResult>;
|
|
146
|
-
|
|
177
|
+
/**
|
|
178
|
+
* A delegated token for one configured resource. Answers from the in-memory
|
|
179
|
+
* cache while the token is unexpired; `forceRefresh` skips and drops that
|
|
180
|
+
* cache, which is what a caller whose token was just rejected needs.
|
|
181
|
+
*/
|
|
182
|
+
requestAccessToken(
|
|
183
|
+
resourceKey: string,
|
|
184
|
+
options?: AccessTokenRequestOptions,
|
|
185
|
+
): Promise<AccessTokenResult>;
|
|
147
186
|
getAccessTier(): AccessTier;
|
|
148
|
-
/**
|
|
187
|
+
/** Whether a premium-only path is open, and the tier behind the answer. Reads nothing else and changes nothing (FR-011). */
|
|
188
|
+
canAccessPremium(): PremiumGateResult;
|
|
189
|
+
/**
|
|
190
|
+
* @deprecated Use `canAccessPremium` and show the denial where it happened.
|
|
191
|
+
* This also moves the whole controller to `access-denied`, which every screen
|
|
192
|
+
* reading `status` then sees, and only a `restore` or a sign-in leaves
|
|
193
|
+
* (AUTH-12). Kept for 0.4.x.
|
|
194
|
+
*/
|
|
149
195
|
requirePremium(): PremiumGateResult;
|
|
196
|
+
/** This package's copy: the English defaults with the app's `messages` over them. */
|
|
197
|
+
getMessages(): AuthMessageMap;
|
|
150
198
|
/** The first available provider to present on the current platform (UX-004), or undefined. */
|
|
151
199
|
getPlatformProvider(): ProviderConfig | undefined;
|
|
152
200
|
/** All available providers for the current platform, in configuration order (FR-003/FR-004). */
|
|
@@ -4,13 +4,21 @@
|
|
|
4
4
|
* Covers the auth-config.md Section A–F rules.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { DEVICE_PLATFORMS, type AuthConfig } from './types';
|
|
8
8
|
import { AuthConfigError } from '../diagnostics/errors';
|
|
9
9
|
|
|
10
10
|
function hasScheme(url: string): boolean {
|
|
11
11
|
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** https, or http on a development machine. */
|
|
15
|
+
function isSecureAuthority(url: string): boolean {
|
|
16
|
+
if (/^https:/i.test(url)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(url);
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
export function validateConfig(config: AuthConfig): void {
|
|
15
23
|
// Section A — tenant/client settings
|
|
16
24
|
if (!config.authority || !config.authority.trim()) {
|
|
@@ -19,6 +27,14 @@ export function validateConfig(config: AuthConfig): void {
|
|
|
19
27
|
if (!hasScheme(config.authority)) {
|
|
20
28
|
throw new AuthConfigError('auth.error.configuration', '"authority" must be an absolute URL');
|
|
21
29
|
}
|
|
30
|
+
// Tokens travel over this. A tenant reachable over plaintext http is a
|
|
31
|
+
// configuration mistake, not a deployment choice — except on a dev machine.
|
|
32
|
+
if (!isSecureAuthority(config.authority)) {
|
|
33
|
+
throw new AuthConfigError(
|
|
34
|
+
'auth.error.configuration',
|
|
35
|
+
'"authority" must use https (http is accepted only for localhost)',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
22
38
|
if (!config.clientId || !config.clientId.trim()) {
|
|
23
39
|
throw new AuthConfigError('auth.error.configuration', 'Missing "clientId"');
|
|
24
40
|
}
|
|
@@ -66,6 +82,21 @@ export function validateConfig(config: AuthConfig): void {
|
|
|
66
82
|
`Provider "${provider.id}" must declare at least one platform`,
|
|
67
83
|
);
|
|
68
84
|
}
|
|
85
|
+
for (const platform of provider.platforms) {
|
|
86
|
+
if (!DEVICE_PLATFORMS.includes(platform)) {
|
|
87
|
+
// A typo here hides the provider on every device rather than failing.
|
|
88
|
+
throw new AuthConfigError(
|
|
89
|
+
'auth.error.configuration',
|
|
90
|
+
`Provider "${provider.id}" declares unknown platform "${String(platform)}"`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (typeof provider.labelKey !== 'string' || !provider.labelKey.trim()) {
|
|
95
|
+
throw new AuthConfigError(
|
|
96
|
+
'auth.error.configuration',
|
|
97
|
+
`Provider "${provider.id}" is missing a labelKey`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
// Section D — resources
|