@chipmobilesdk/rn-auth 0.4.0 → 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 +28 -0
- package/README.md +66 -9
- package/package.json +1 -1
- package/skills/sdk-rn-auth/SKILL.md +35 -2
- package/src/config/types.ts +53 -5
- package/src/config/validateConfig.ts +32 -1
- package/src/core/authMachine.ts +40 -6
- package/src/core/profile.ts +6 -1
- package/src/core/session.ts +49 -3
- package/src/diagnostics/errors.ts +7 -5
- package/src/index.ts +7 -0
- package/src/oidc/authClient.ts +19 -1
- package/src/oidc/classifyError.ts +2 -1
- package/src/oidc/socialRouting.ts +3 -1
- package/src/react/hooks.ts +53 -14
- package/src/tokens/accessToken.ts +20 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
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
|
+
|
|
3
31
|
## 0.4.0
|
|
4
32
|
|
|
5
33
|
### Minor 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
|
|
|
@@ -160,19 +208,28 @@ are unsupported.
|
|
|
160
208
|
|
|
161
209
|
- Controller and configuration: `createAuth`, `Auth`, `AuthConfig`,
|
|
162
210
|
`CreateAuthOptions`, `AuthState`, `AuthStatus`, `RestoreStatus`
|
|
163
|
-
- Identity and access: `Profile`, `ProviderConfig`, `
|
|
164
|
-
`
|
|
165
|
-
`
|
|
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`)
|
|
166
217
|
- Ports and default adapters: `SecureTokenStore`, `createKeychainSecureStore`,
|
|
167
218
|
`AUTH_STORAGE_KEY`, `OidcAuthClient`, `createAppAuthClient` and the public OIDC
|
|
168
|
-
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,
|
|
169
223
|
`@chipmobilesdk/rn-auth/app-auth` and `@chipmobilesdk/rn-auth/keychain`, so an app
|
|
170
224
|
that injects its own client or store can skip them. Neither loads its native
|
|
171
225
|
module until it is used: react-native-app-auth on the first sign-in, refresh or
|
|
172
226
|
sign-out, react-native-keychain on the first read or write. Creating `auth` at
|
|
173
227
|
startup loads neither. A missing module is an `AuthConfigError` at that moment.
|
|
174
228
|
- React: `AuthProvider`, `useAuth`, `useProfile`, `useAccessTier`,
|
|
175
|
-
`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.
|
|
176
233
|
Each hook reads the state through `useSyncExternalStore` and re-renders only when
|
|
177
234
|
what it returns changes: `useProfile` does not render for a status change. Use
|
|
178
235
|
`useAuthSelector(state => …, isEqual?)` for any other slice. `auth.getState()`
|
package/package.json
CHANGED
|
@@ -35,12 +35,27 @@ 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`
|
|
44
59
|
|
|
45
60
|
Testing (`@chipmobilesdk/rn-auth/testing`): `createMemorySecureStore`,
|
|
46
61
|
`runSecureTokenStoreConformance`, `runOidcAuthClientConformance` — README `## Testing`.
|
|
@@ -51,6 +66,24 @@ Types: `SecureTokenStore`, `AuthProviderProps`, `AuthErrorCode`, `AuthErrorAudie
|
|
|
51
66
|
`AuthErrorAudience` distinguishes what may be shown to a user from what is diagnostic
|
|
52
67
|
only — never render a diagnostic-audience message in the UI.
|
|
53
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
|
+
|
|
54
87
|
## Where the detail lives
|
|
55
88
|
|
|
56
89
|
Read one section of `node_modules/@chipmobilesdk/rn-auth/README.md`:
|
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
|
|
|
@@ -81,6 +107,11 @@ export interface Profile {
|
|
|
81
107
|
displayName?: string | undefined;
|
|
82
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
|
|
|
@@ -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
|
package/src/core/authMachine.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
shallowEqual,
|
|
17
17
|
} from '@chipmobilesdk/rn-core';
|
|
18
18
|
|
|
19
|
+
import type { AuthMessageMap } from '../diagnostics/messageKeys';
|
|
20
|
+
import { resolveMessages } from '../diagnostics/messageKeys';
|
|
19
21
|
import type {
|
|
20
22
|
Auth,
|
|
21
23
|
AuthConfig,
|
|
@@ -37,7 +39,7 @@ import {
|
|
|
37
39
|
} from '../oidc/socialRouting';
|
|
38
40
|
import { mapClaimsToProfile } from './profile';
|
|
39
41
|
import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
|
|
40
|
-
import { restoreSession, type PersistedSession } from './session';
|
|
42
|
+
import { restoreSession, sameSession, storedProfileFrom, type PersistedSession } from './session';
|
|
41
43
|
import { AUTH_STORAGE_KEY } from '../tokens/secureStore';
|
|
42
44
|
import { createAccessTokenManager } from '../tokens/accessToken';
|
|
43
45
|
import { AuthError, toAuthError, type AuthErrorCode } from '../diagnostics/errors';
|
|
@@ -62,6 +64,8 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
62
64
|
const secureStore = config.secureStore;
|
|
63
65
|
const authClient = config.authClient ?? createAppAuthClient();
|
|
64
66
|
const resources = config.resources ?? [];
|
|
67
|
+
// Declared since 0.1.0 and read by nothing until now (AUTH-15).
|
|
68
|
+
const messages = resolveMessages(config.messages);
|
|
65
69
|
|
|
66
70
|
let state: AuthState = Object.freeze({ status: 'loading', restore: 'pending' });
|
|
67
71
|
let refreshToken: string | null = null;
|
|
@@ -84,12 +88,29 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
84
88
|
return next;
|
|
85
89
|
};
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
/** The artifact as last written or read, so an identical write is skipped. */
|
|
92
|
+
let storedSession: PersistedSession | null = null;
|
|
93
|
+
|
|
94
|
+
const writeSession = (
|
|
95
|
+
session: { refreshToken: string; profile: Profile; idToken?: string | undefined },
|
|
96
|
+
epoch: number,
|
|
97
|
+
): Promise<void> =>
|
|
88
98
|
enqueueStoreOp(async () => {
|
|
89
99
|
if (epoch !== sessionEpoch) {
|
|
90
100
|
return; // The session this belonged to has already ended.
|
|
91
101
|
}
|
|
92
|
-
|
|
102
|
+
const artifact: PersistedSession = {
|
|
103
|
+
refreshToken: session.refreshToken,
|
|
104
|
+
profile: storedProfileFrom(session.profile),
|
|
105
|
+
idToken: session.idToken,
|
|
106
|
+
};
|
|
107
|
+
// A restore that rotated nothing used to write the Keychain again on
|
|
108
|
+
// every launch, on the startup path (AUTH-23).
|
|
109
|
+
if (storedSession && sameSession(storedSession, artifact)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await secureStore.setItem(AUTH_STORAGE_KEY, JSON.stringify(artifact));
|
|
113
|
+
storedSession = artifact;
|
|
93
114
|
});
|
|
94
115
|
|
|
95
116
|
const tokenManager = createAccessTokenManager({
|
|
@@ -155,6 +176,7 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
155
176
|
sessionEpoch += 1;
|
|
156
177
|
refreshToken = null;
|
|
157
178
|
idToken = null;
|
|
179
|
+
storedSession = null;
|
|
158
180
|
tokenManager.clear();
|
|
159
181
|
await enqueueStoreOp(() => secureStore.removeItem(AUTH_STORAGE_KEY));
|
|
160
182
|
}
|
|
@@ -193,6 +215,8 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
193
215
|
return getState();
|
|
194
216
|
}
|
|
195
217
|
|
|
218
|
+
storedSession = result.persisted ?? null;
|
|
219
|
+
|
|
196
220
|
if (result.outcome === 'restored' && result.profile && result.refreshToken) {
|
|
197
221
|
refreshToken = result.refreshToken;
|
|
198
222
|
idToken = result.idToken ?? null;
|
|
@@ -334,16 +358,17 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
334
358
|
return result;
|
|
335
359
|
},
|
|
336
360
|
|
|
337
|
-
async requestAccessToken(resourceKey) {
|
|
361
|
+
async requestAccessToken(resourceKey, tokenOptions) {
|
|
338
362
|
const epoch = sessionEpoch;
|
|
339
363
|
// Only a real network refresh is a `token-refreshing` phase; a cache hit
|
|
340
364
|
// would otherwise flash every subscriber twice for nothing.
|
|
341
365
|
const refreshing =
|
|
342
|
-
state.status === 'authenticated' &&
|
|
366
|
+
state.status === 'authenticated' &&
|
|
367
|
+
(tokenOptions?.forceRefresh === true || !tokenManager.hasFreshToken(resourceKey));
|
|
343
368
|
if (refreshing) {
|
|
344
369
|
setState({ status: 'token-refreshing' });
|
|
345
370
|
}
|
|
346
|
-
const result = await tokenManager.request(resourceKey);
|
|
371
|
+
const result = await tokenManager.request(resourceKey, tokenOptions);
|
|
347
372
|
// Restored only if nothing else moved the session meanwhile: a sign-out
|
|
348
373
|
// during the refresh used to be overwritten back to `authenticated`.
|
|
349
374
|
if (refreshing && epoch === sessionEpoch && state.status === 'token-refreshing') {
|
|
@@ -356,6 +381,11 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
356
381
|
return state.accessTier ?? failClosedTier();
|
|
357
382
|
},
|
|
358
383
|
|
|
384
|
+
canAccessPremium(): PremiumGateResult {
|
|
385
|
+
const tier = state.accessTier ?? failClosedTier();
|
|
386
|
+
return { allowed: tier.tier === 'premium' && tier.verification === 'verified', tier };
|
|
387
|
+
},
|
|
388
|
+
|
|
359
389
|
requirePremium(): PremiumGateResult {
|
|
360
390
|
const tier = state.accessTier ?? failClosedTier();
|
|
361
391
|
const allowed = tier.tier === 'premium' && tier.verification === 'verified';
|
|
@@ -365,6 +395,10 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
365
395
|
return { allowed, tier };
|
|
366
396
|
},
|
|
367
397
|
|
|
398
|
+
getMessages(): AuthMessageMap {
|
|
399
|
+
return messages;
|
|
400
|
+
},
|
|
401
|
+
|
|
368
402
|
getPlatformProvider() {
|
|
369
403
|
return defaultProviderForPlatform(config, platform);
|
|
370
404
|
},
|
package/src/core/profile.ts
CHANGED
|
@@ -41,7 +41,12 @@ export function mapClaimsToProfile(
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Describe the external provider identity behind the profile (FR-004 edge
|
|
46
|
+
* cases). `emailBehavior` is what a screen needs to explain an address the user
|
|
47
|
+
* did not choose: Apple's private relay forwards mail and is not the address
|
|
48
|
+
* they type anywhere else.
|
|
49
|
+
*/
|
|
45
50
|
export function describeProviderIdentity(
|
|
46
51
|
claims: Record<string, unknown>,
|
|
47
52
|
providerId?: ProviderId,
|
package/src/core/session.ts
CHANGED
|
@@ -22,13 +22,50 @@ import { mapClaimsToProfile } from './profile';
|
|
|
22
22
|
import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
|
|
23
23
|
import { isAuthError } from '../diagnostics/errors';
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The identity in the stored artifact: who, not what the token said.
|
|
27
|
+
*
|
|
28
|
+
* The whole claim set used to be written to the Keychain on every launch — all
|
|
29
|
+
* of it, groups included — for a value nothing reads back: the access tier is
|
|
30
|
+
* recomputed from fresh claims, and a restore that cannot reach the provider
|
|
31
|
+
* fails closed anyway (AUTH-23).
|
|
32
|
+
*/
|
|
33
|
+
export type StoredProfile = Omit<Profile, 'claims'>;
|
|
34
|
+
|
|
25
35
|
export interface PersistedSession {
|
|
26
36
|
refreshToken: string;
|
|
27
|
-
profile:
|
|
37
|
+
profile: StoredProfile;
|
|
28
38
|
/** The id token hint for end-session (AUTH-04). Absent in artifacts written before 0.2.2. */
|
|
29
39
|
idToken?: string | undefined;
|
|
30
40
|
}
|
|
31
41
|
|
|
42
|
+
/** The artifact's identity as a profile. Artifacts written before 0.4.0 carry claims; they are kept. */
|
|
43
|
+
export function profileFromStored(stored: StoredProfile | Profile): Profile {
|
|
44
|
+
return { ...stored, claims: 'claims' in stored ? (stored.claims ?? {}) : {} };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What goes to the store: everything but the claims. */
|
|
48
|
+
export function storedProfileFrom(profile: Profile): StoredProfile {
|
|
49
|
+
return {
|
|
50
|
+
tenantUserId: profile.tenantUserId,
|
|
51
|
+
displayName: profile.displayName,
|
|
52
|
+
email: profile.email,
|
|
53
|
+
provider: profile.provider,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when the artifact would be byte-identical, so the write can be skipped. */
|
|
58
|
+
export function sameSession(a: PersistedSession, b: PersistedSession): boolean {
|
|
59
|
+
return (
|
|
60
|
+
a.refreshToken === b.refreshToken &&
|
|
61
|
+
a.idToken === b.idToken &&
|
|
62
|
+
a.profile.tenantUserId === b.profile.tenantUserId &&
|
|
63
|
+
a.profile.displayName === b.profile.displayName &&
|
|
64
|
+
a.profile.email === b.profile.email &&
|
|
65
|
+
a.profile.provider === b.profile.provider
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
32
69
|
export interface RestoreDeps {
|
|
33
70
|
secureStore: SecureTokenStore;
|
|
34
71
|
authority: string;
|
|
@@ -42,6 +79,8 @@ export interface RestoreDeps {
|
|
|
42
79
|
export interface RestoreResult {
|
|
43
80
|
outcome: RestoreStatus;
|
|
44
81
|
profile?: Profile;
|
|
82
|
+
/** The artifact this restore read, so a rewrite that changes nothing can be skipped. */
|
|
83
|
+
persisted?: PersistedSession;
|
|
45
84
|
accessTier?: AccessTier;
|
|
46
85
|
refreshToken?: string;
|
|
47
86
|
idToken?: string | undefined;
|
|
@@ -82,7 +121,13 @@ export async function restoreSession(deps: RestoreDeps): Promise<RestoreResult>
|
|
|
82
121
|
const hasClaims = Object.keys(tokens.claims).length > 0;
|
|
83
122
|
return {
|
|
84
123
|
outcome: 'restored',
|
|
85
|
-
|
|
124
|
+
persisted,
|
|
125
|
+
// The provider id came from the sign-in and is not in the claims: a
|
|
126
|
+
// refresh answered `idp: 'google.com'`, so a restored profile used to
|
|
127
|
+
// report a different provider than the same session did before (AUTH-10).
|
|
128
|
+
profile: hasClaims
|
|
129
|
+
? mapClaimsToProfile(tokens.claims, persisted.profile.provider)
|
|
130
|
+
: profileFromStored(persisted.profile),
|
|
86
131
|
accessTier: hasClaims ? evaluateAccessTier(tokens.claims, deps.mapping) : failClosedTier(),
|
|
87
132
|
refreshToken: tokens.refreshToken || persisted.refreshToken,
|
|
88
133
|
idToken: tokens.idToken || persisted.idToken,
|
|
@@ -96,7 +141,8 @@ export async function restoreSession(deps: RestoreDeps): Promise<RestoreResult>
|
|
|
96
141
|
// read-only and fail closed on entitlement; the next restore tries again.
|
|
97
142
|
return {
|
|
98
143
|
outcome: 'offline',
|
|
99
|
-
|
|
144
|
+
persisted,
|
|
145
|
+
profile: profileFromStored(persisted.profile),
|
|
100
146
|
accessTier: failClosedTier(),
|
|
101
147
|
refreshToken: persisted.refreshToken,
|
|
102
148
|
idToken: persisted.idToken,
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { COMMON_PATTERNS, SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
|
|
9
9
|
|
|
10
|
+
import type { AuthMessageKey } from './messageKeys';
|
|
11
|
+
|
|
10
12
|
export type AuthErrorCode =
|
|
11
13
|
| 'configuration-error'
|
|
12
14
|
| 'provider-cancelled'
|
|
@@ -14,14 +16,14 @@ export type AuthErrorCode =
|
|
|
14
16
|
| 'token-acquisition-failed'
|
|
15
17
|
| 'session-expired'
|
|
16
18
|
| 'consent-denied'
|
|
17
|
-
| 'entitlement-unverified'
|
|
18
19
|
| 'network-unavailable';
|
|
19
20
|
|
|
20
21
|
export type AuthErrorAudience = 'user' | 'developer';
|
|
21
22
|
|
|
22
23
|
export interface AuthErrorArgs {
|
|
23
24
|
code: AuthErrorCode;
|
|
24
|
-
|
|
25
|
+
/** A key of this package's catalogue: the copy an app shows for this error. */
|
|
26
|
+
messageKey: AuthMessageKey;
|
|
25
27
|
audience?: AuthErrorAudience;
|
|
26
28
|
detail?: string | undefined;
|
|
27
29
|
}
|
|
@@ -52,7 +54,7 @@ const RETRYABLE: ReadonlySet<AuthErrorCode> = new Set<AuthErrorCode>(['network-u
|
|
|
52
54
|
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-auth'`. */
|
|
53
55
|
export class AuthError extends SdkError<AuthErrorCode> {
|
|
54
56
|
readonly audience: AuthErrorAudience;
|
|
55
|
-
readonly messageKey:
|
|
57
|
+
readonly messageKey: AuthMessageKey;
|
|
56
58
|
readonly detail?: string | undefined;
|
|
57
59
|
|
|
58
60
|
constructor(args: AuthErrorArgs) {
|
|
@@ -70,7 +72,7 @@ export class AuthError extends SdkError<AuthErrorCode> {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export class AuthConfigError extends AuthError {
|
|
73
|
-
constructor(messageKey:
|
|
75
|
+
constructor(messageKey: AuthMessageKey, detail?: string) {
|
|
74
76
|
super({ code: 'configuration-error', messageKey, audience: 'developer', detail });
|
|
75
77
|
this.name = 'AuthConfigError';
|
|
76
78
|
}
|
|
@@ -84,7 +86,7 @@ export function isAuthError(value: unknown): value is AuthError {
|
|
|
84
86
|
export function toAuthError(
|
|
85
87
|
value: unknown,
|
|
86
88
|
fallbackCode: AuthErrorCode = 'token-acquisition-failed',
|
|
87
|
-
fallbackMessageKey = 'auth.error.token',
|
|
89
|
+
fallbackMessageKey: AuthMessageKey = 'auth.error.token',
|
|
88
90
|
): AuthError {
|
|
89
91
|
if (isAuthError(value)) {
|
|
90
92
|
return value;
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type {
|
|
|
25
25
|
ProviderIdentity,
|
|
26
26
|
ProviderConfig,
|
|
27
27
|
ProviderId,
|
|
28
|
+
KnownProviderId,
|
|
28
29
|
DevicePlatform,
|
|
29
30
|
ResourceConfig,
|
|
30
31
|
ClaimMatcher,
|
|
@@ -37,11 +38,16 @@ export type {
|
|
|
37
38
|
} from './config/types';
|
|
38
39
|
|
|
39
40
|
// Ports + default adapters
|
|
41
|
+
export { describeProviderIdentity, mapClaimsToProfile } from './core/profile';
|
|
42
|
+
|
|
43
|
+
export type { AccessTokenRequestOptions } from './tokens/accessToken';
|
|
40
44
|
export type { SecureTokenStore } from './tokens/secureStore';
|
|
41
45
|
export { createKeychainSecureStore, AUTH_STORAGE_KEY } from './tokens/secureStore';
|
|
42
46
|
export type {
|
|
43
47
|
OidcAuthClient,
|
|
44
48
|
OidcTokens,
|
|
49
|
+
OidcServiceConfiguration,
|
|
50
|
+
AppAuthClientOptions,
|
|
45
51
|
AuthorizeParams,
|
|
46
52
|
RefreshParams,
|
|
47
53
|
EndSessionParams,
|
|
@@ -63,6 +69,7 @@ export { useAuthSelector } from './react/hooks';
|
|
|
63
69
|
export type { AuthProviderProps } from './react/AuthProvider';
|
|
64
70
|
export {
|
|
65
71
|
useAuth,
|
|
72
|
+
useAuthMessages,
|
|
66
73
|
useProfile,
|
|
67
74
|
useAccessTier,
|
|
68
75
|
useAccessToken,
|
package/src/oidc/authClient.ts
CHANGED
|
@@ -116,9 +116,24 @@ function base64UrlDecode(input: string): string {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
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
|
+
|
|
119
128
|
export interface AppAuthClientOptions {
|
|
120
129
|
/** When true, the OIDC discovery document is fetched from `${authority}`. */
|
|
121
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;
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
interface AppAuthModule {
|
|
@@ -165,7 +180,9 @@ const AppAuth: AppAuthModule = {
|
|
|
165
180
|
* client). Validated on device (R8).
|
|
166
181
|
*/
|
|
167
182
|
export function createAppAuthClient(options: AppAuthClientOptions = {}): OidcAuthClient {
|
|
168
|
-
const
|
|
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);
|
|
169
186
|
|
|
170
187
|
const baseConfig = (
|
|
171
188
|
authority: string,
|
|
@@ -174,6 +191,7 @@ export function createAppAuthClient(options: AppAuthClientOptions = {}): OidcAut
|
|
|
174
191
|
scopes: string[],
|
|
175
192
|
) => ({
|
|
176
193
|
issuer: useDiscovery ? authority : undefined,
|
|
194
|
+
...(serviceConfiguration ? { serviceConfiguration } : {}),
|
|
177
195
|
clientId,
|
|
178
196
|
redirectUrl,
|
|
179
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
|
/**
|
package/src/react/hooks.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useExternalStore } from '@chipmobilesdk/rn-core/react';
|
|
8
|
-
import { useCallback, useState } from 'react';
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
9
9
|
import { useAuthInstance } from './AuthProvider';
|
|
10
10
|
import type {
|
|
11
11
|
AccessTier,
|
|
@@ -18,6 +18,8 @@ import type {
|
|
|
18
18
|
ProviderId,
|
|
19
19
|
} from '../config/types';
|
|
20
20
|
import { failClosedTier } from '../access/accessTier';
|
|
21
|
+
import type { AccessTokenRequestOptions } from '../tokens/accessToken';
|
|
22
|
+
import type { AuthMessageMap } from '../diagnostics/messageKeys';
|
|
21
23
|
|
|
22
24
|
export interface UseAuthResult {
|
|
23
25
|
state: AuthState;
|
|
@@ -25,6 +27,9 @@ export interface UseAuthResult {
|
|
|
25
27
|
signOut(): Promise<void>;
|
|
26
28
|
signOutGlobal(): Promise<GlobalSignOutResult>;
|
|
27
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). */
|
|
28
33
|
requirePremium(): PremiumGateResult;
|
|
29
34
|
}
|
|
30
35
|
|
|
@@ -53,10 +58,16 @@ export function useAuth(): UseAuthResult {
|
|
|
53
58
|
signOut: useCallback(() => auth.signOut(), [auth]),
|
|
54
59
|
signOutGlobal: useCallback(() => auth.signOutGlobal(), [auth]),
|
|
55
60
|
restore: useCallback(() => auth.restore(), [auth]),
|
|
61
|
+
canAccessPremium: useCallback(() => auth.canAccessPremium(), [auth]),
|
|
56
62
|
requirePremium: useCallback(() => auth.requirePremium(), [auth]),
|
|
57
63
|
};
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
/** This package's copy, with the app's `messages` overrides applied. */
|
|
67
|
+
export function useAuthMessages(): AuthMessageMap {
|
|
68
|
+
return useAuthInstance().getMessages();
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
export function useProfile(): Profile | undefined {
|
|
61
72
|
return useAuthSelector(selectProfile);
|
|
62
73
|
}
|
|
@@ -83,21 +94,49 @@ export type UseAccessTokenStatus =
|
|
|
83
94
|
export interface UseAccessTokenResult {
|
|
84
95
|
status: UseAccessTokenStatus;
|
|
85
96
|
token?: string | undefined;
|
|
86
|
-
|
|
97
|
+
/** `{ forceRefresh: true }` after a server refused the token this returned. */
|
|
98
|
+
request(options?: AccessTokenRequestOptions): Promise<AccessTokenResult>;
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
export function useAccessToken(resourceKey: string): UseAccessTokenResult {
|
|
90
102
|
const auth = useAuthInstance();
|
|
91
|
-
const [
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 };
|
|
103
142
|
}
|
|
@@ -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,
|