@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 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`/`requirePremium`),
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
- Full contract: [auth-config.md](../../specs/003-mobile-entra-auth/contracts/auth-config.md)
112
- and [public-api.md](../../specs/003-mobile-entra-auth/contracts/public-api.md).
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`, `ProviderIdentity`,
164
- `ResourceConfig`, `AccessTier`, `AccessTierMapping`, `AccessTokenResult`,
165
- `PremiumGateResult`, `evaluateAccessTier`, `failClosedTier`
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. The two default adapters also have their own entrypoints,
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`, `useAuthSelector`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-auth",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -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
- - `createAppAuthClient(...)` — the OIDC client
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`:
@@ -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
- export type ProviderId = 'google' | 'apple' | 'facebook';
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
- /** Message key for the button label (Continue with Google / Apple). */
21
- labelKey: AuthMessageKey;
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
- requestAccessToken(resourceKey: string): Promise<AccessTokenResult>;
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
- /** Gate a premium-only path; enters `access-denied` when unverified (FR-011). */
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 { AuthConfig } from './types';
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
@@ -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
- const writeSession = (session: PersistedSession, epoch: number): Promise<void> =>
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
- await secureStore.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
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' && !tokenManager.hasFreshToken(resourceKey);
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
  },
@@ -41,7 +41,12 @@ export function mapClaimsToProfile(
41
41
  };
42
42
  }
43
43
 
44
- /** Describe the external provider identity behind the profile (FR-004 edge cases). */
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,
@@ -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: 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
- profile: hasClaims ? mapClaimsToProfile(tokens.claims) : persisted.profile,
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
- profile: persisted.profile,
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
- messageKey: string;
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: string;
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: string, detail?: string) {
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,
@@ -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 useDiscovery = options.useDiscovery ?? true;
183
+ const serviceConfiguration = options.serviceConfiguration;
184
+ // Discovery is pointless once the endpoints are given, whatever was asked for.
185
+ const useDiscovery = serviceConfiguration ? false : (options.useDiscovery ?? true);
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: string;
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
- return { domain_hint: provider.domainHint };
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
  /**
@@ -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
- request(): Promise<AccessTokenResult>;
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 [status, setStatus] = useState<UseAccessTokenStatus>('idle');
92
- const [token, setToken] = useState<string | undefined>(undefined);
93
-
94
- const request = useCallback(async () => {
95
- setStatus('loading');
96
- const result = await auth.requestAccessToken(resourceKey);
97
- setStatus(result.status);
98
- setToken(result.token);
99
- return result;
100
- }, [auth, resourceKey]);
101
-
102
- return { status, token, request };
103
+ 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
- const cached = fresh(resourceKey);
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,