@chipmobilesdk/rn-auth 0.2.0 → 0.3.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
@@ -3,6 +3,48 @@
3
3
  All notable changes to `@chipmobilesdk/rn-auth` are documented here. This project
4
4
  adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.3.0] - 2026-11-01
7
+
8
+ Minor release fixing session integrity. Two type changes can need a one-line code
9
+ change (see Changed): `signOutGlobal()` resolves to a result instead of `void`, and
10
+ `OidcTokens.refreshToken` / `idToken` are optional.
11
+
12
+ ### Fixed
13
+
14
+ - **A sign-out during a token refresh no longer revives the session.** The refresh
15
+ wrote its refresh token back into memory and set the state to `authenticated`
16
+ after the sign-out. Every session start and end now bumps a session generation,
17
+ and work that crossed one is discarded.
18
+ - **Rotated refresh tokens are persisted.** A rotation only reached memory, so a
19
+ restart came back with a token the provider may already have retired. Concurrent
20
+ `requestAccessToken` calls for one resource now share one network refresh, and a
21
+ cache hit no longer flashes `token-refreshing`.
22
+ - **Restore deletes the stored session only on an `invalid_grant` verdict.** Any
23
+ error other than a recognised network failure used to delete it, so a timeout or a
24
+ 5xx signed users out. Errors are classified by OAuth and AADSTS codes in an ordered
25
+ table (`oidc/classifyError.ts`) instead of loose substrings; unclassified errors
26
+ restore `offline`.
27
+ - **Restore no longer stays `loading`** when the Keychain write fails, and keeps the
28
+ stored profile when a refresh returns no id token.
29
+ - **Sign-in commits only what it stored.** A failed Keychain write left a refresh
30
+ token in memory under an `error` state; an authorize without a refresh token now
31
+ reports a developer-facing `configuration-error` instead of a session that never
32
+ survives a restart.
33
+ - **`signOutGlobal()` sends the id token hint** and reports a failed end-session
34
+ instead of swallowing it.
35
+ - **A throwing state listener no longer turns a completed sign-in into an error.**
36
+ - **The development-only `console.warn` of the raw native error is removed.** It
37
+ bypassed redaction.
38
+
39
+ ### Changed
40
+
41
+ - `signOutGlobal()` resolves to `GlobalSignOutResult` (`{ global: 'ended' | 'failed',
42
+ error? }`) instead of `void`.
43
+ - `OidcTokens.refreshToken` and `OidcTokens.idToken` are optional; a provider that
44
+ omits them is reported as absent rather than as an empty string. The persisted
45
+ session artifact gains an optional `idToken`; artifacts written by 0.2.1 still
46
+ restore.
47
+
6
48
  ## [0.2.0] - 2026-06-30
7
49
 
8
50
  Add Facebook as a social sign-in provider (additive, backward compatible).
package/README.md CHANGED
@@ -6,16 +6,16 @@ Code + PKCE, **no backend, no client secret**). Android presents **Continue with
6
6
  Google**, iOS presents **Continue with Apple**, and either platform can also
7
7
  present **Continue with Facebook**; all start the same External ID
8
8
  sign-up/sign-in user flow, routed to the right social provider with a
9
- `domain_hint`. Google, Apple, and Facebook are federated **inside the External ID
10
- tenant**, so the app embeds no native social SDK and no Sign in with Apple
11
- entitlement. Each platform renders all of its configured providers as separate
12
- buttons in configuration order.
13
-
14
- > **License:** `UNLICENSED`. Published publicly for use by the owner's
15
- > applications; no open-source license is granted.
16
-
17
- - **Version**: `0.2.0`
18
- - **Entrypoint**: `@chipmobilesdk/rn-auth`
9
+ `domain_hint`. Google, Apple, and Facebook are federated **inside the External ID
10
+ tenant**, so the app embeds no native social SDK and no Sign in with Apple
11
+ entitlement. Each platform renders all of its configured providers as separate
12
+ buttons in configuration order.
13
+
14
+ > **License:** `UNLICENSED`. Published publicly for use by the owner's
15
+ > applications; no open-source license is granted.
16
+
17
+ - **Version**: `0.3.0`
18
+ - **Entrypoint**: `@chipmobilesdk/rn-auth`
19
19
 
20
20
  - Single active account, session restore, local + global sign-out.
21
21
  - Per-resource delegated access tokens for Azure resources.
@@ -79,6 +79,19 @@ Hooks: `useAuth()` (state + `signIn`/`signOut`/`signOutGlobal`/`restore`/`requir
79
79
  `useSignInProviders()` (all platform providers, in config order — map to one
80
80
  button each) and `useSignInProvider()` (first available, for a single-button UI).
81
81
 
82
+ ### Session lifecycle guarantees
83
+
84
+ - **Restore deletes the stored session only when the provider rejects the refresh
85
+ token** (`invalid_grant` and the equivalent AADSTS codes). A timeout, a 5xx or an
86
+ error the package cannot classify restores `offline`: cached profile, tier
87
+ unverified, retried on the next `restore()`.
88
+ - **A sign-out wins.** A token refresh still in flight when the user signs out is
89
+ discarded; it cannot write a token back or return the state to `authenticated`.
90
+ - **Rotated refresh tokens are persisted** as soon as they arrive, and concurrent
91
+ `requestAccessToken` calls for one resource share one network refresh.
92
+ - **`signOutGlobal()` returns `{ global: 'ended' | 'failed', error? }`.** Local state
93
+ is cleared either way; `failed` means the tenant session may still be alive.
94
+
82
95
  ## Configuration contract (LIB-010)
83
96
 
84
97
  The library accepts **public values only**. Provider secrets, Apple keys, and
@@ -126,7 +139,7 @@ can sign users in:
126
139
  - [ ] iOS: because Facebook is a third-party login, keep Sign in with Apple offered on iOS (Apple guideline 4.8).
127
140
  - [ ] Update Apple App Privacy and Google Play Data Safety to include Facebook as an identity provider before release.
128
141
 
129
- ## Security & privacy
142
+ ## Security & privacy
130
143
 
131
144
  - Tokens and refresh artifacts persist only in `SecureTokenStore` (Keychain /
132
145
  Keystore). Access tokens are kept in memory only.
@@ -135,43 +148,43 @@ can sign users in:
135
148
  - Diagnostics (`AuthError`) never contain secrets or token values.
136
149
  - Authentication and profile data are collected and transmitted to the
137
150
  identity providers and configured Azure resources — update Apple App Privacy
138
- and Google Play Data Safety before release.
139
-
140
- ## Public API
141
-
142
- Everything below is exported from `@chipmobilesdk/rn-auth`; deep imports into
143
- `config/`, `core/`, `tokens/`, `oidc/`, `access/`, `diagnostics/`, or `react/`
144
- are unsupported.
145
-
146
- - Controller and configuration: `createAuth`, `Auth`, `AuthConfig`,
147
- `CreateAuthOptions`, `AuthState`, `AuthStatus`, `RestoreStatus`
148
- - Identity and access: `Profile`, `ProviderConfig`, `ProviderIdentity`,
149
- `ResourceConfig`, `AccessTier`, `AccessTierMapping`, `AccessTokenResult`,
150
- `PremiumGateResult`, `evaluateAccessTier`, `failClosedTier`
151
- - Ports and default adapters: `SecureTokenStore`, `createKeychainSecureStore`,
152
- `AUTH_STORAGE_KEY`, `OidcAuthClient`, `createAppAuthClient` and the public OIDC
153
- request/token types
154
- - React: `AuthProvider`, `useAuth`, `useProfile`, `useAccessTier`,
155
- `useAccessToken`, `useSignInProvider`, `useSignInProviders`
156
- - Diagnostics and copy: `AuthError`, `AuthConfigError`, `isAuthError`, `redact`,
157
- `DEFAULT_MESSAGES`, `resolveMessages` and their public code/message types
158
-
159
- ## Compatibility and validation
160
-
161
- React ≥19 · React Native ≥0.85 · `react-native-app-auth` ≥8 ·
162
- `react-native-keychain` ≥10 · iOS + Android · Node ≥22.11 for tooling.
163
-
164
- The demo integration lives in `src/screens/AuthDemoScreen.tsx`. Run from the
165
- repository root:
166
-
167
- ```bash
168
- npm run typecheck:auth
169
- npm run pack:auth
170
- npm run validate:auth
171
- ```
172
-
173
- ## Distribution and license
174
-
175
- Published as a public npm package (`publishConfig.access: public`) for use by
176
- the owner's applications. License: `UNLICENSED`; no open-source license is
177
- granted.
151
+ and Google Play Data Safety before release.
152
+
153
+ ## Public API
154
+
155
+ Everything below is exported from `@chipmobilesdk/rn-auth`; deep imports into
156
+ `config/`, `core/`, `tokens/`, `oidc/`, `access/`, `diagnostics/`, or `react/`
157
+ are unsupported.
158
+
159
+ - Controller and configuration: `createAuth`, `Auth`, `AuthConfig`,
160
+ `CreateAuthOptions`, `AuthState`, `AuthStatus`, `RestoreStatus`
161
+ - Identity and access: `Profile`, `ProviderConfig`, `ProviderIdentity`,
162
+ `ResourceConfig`, `AccessTier`, `AccessTierMapping`, `AccessTokenResult`,
163
+ `PremiumGateResult`, `evaluateAccessTier`, `failClosedTier`
164
+ - Ports and default adapters: `SecureTokenStore`, `createKeychainSecureStore`,
165
+ `AUTH_STORAGE_KEY`, `OidcAuthClient`, `createAppAuthClient` and the public OIDC
166
+ request/token types
167
+ - React: `AuthProvider`, `useAuth`, `useProfile`, `useAccessTier`,
168
+ `useAccessToken`, `useSignInProvider`, `useSignInProviders`
169
+ - Diagnostics and copy: `AuthError`, `AuthConfigError`, `isAuthError`, `redact`,
170
+ `DEFAULT_MESSAGES`, `resolveMessages` and their public code/message types
171
+
172
+ ## Compatibility and validation
173
+
174
+ React ≥19 · React Native ≥0.85 · `react-native-app-auth` ≥8 ·
175
+ `react-native-keychain` ≥10 · iOS + Android · Node ≥22.11 for tooling.
176
+
177
+ The demo integration lives in `src/screens/AuthDemoScreen.tsx`. Run from the
178
+ repository root:
179
+
180
+ ```bash
181
+ npm run typecheck:auth
182
+ npm run pack:auth
183
+ npm run validate:auth
184
+ ```
185
+
186
+ ## Distribution and license
187
+
188
+ Published as a public npm package (`publishConfig.access: public`) for use by
189
+ the owner's applications. License: `UNLICENSED`; no open-source license is
190
+ granted.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-auth",
3
- "version": "0.2.0",
4
- "private": false,
5
- "license": "UNLICENSED",
6
- "main": "src/index.ts",
3
+ "version": "0.3.0",
4
+ "private": false,
5
+ "license": "UNLICENSED",
6
+ "main": "src/index.ts",
7
7
  "types": "src/index.ts",
8
8
  "exports": {
9
9
  ".": {
@@ -21,6 +21,7 @@
21
21
  "src/access",
22
22
  "src/diagnostics",
23
23
  "src/react",
24
+ "skills",
24
25
  "README.md",
25
26
  "CHANGELOG.md"
26
27
  ],
@@ -46,8 +47,8 @@
46
47
  "engines": {
47
48
  "node": ">=22.11.0"
48
49
  },
49
- "publishConfig": {
50
- "access": "public",
51
- "registry": "https://registry.npmjs.org/"
52
- }
53
- }
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "registry": "https://registry.npmjs.org/"
53
+ }
54
+ }
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: sdk-rn-auth
3
+ description: "Authentication via @chipmobilesdk/rn-auth: OIDC/AppAuth login, keychain token storage, access tiers, auth error handling. Use when wiring sign-in, storing tokens securely, gating features by tier, or configuring a tenant/provider."
4
+ metadata:
5
+ package: "@chipmobilesdk/rn-auth"
6
+ packageVersion: "0.2.0"
7
+ user-invocable: true
8
+ disable-model-invocation: false
9
+ ---
10
+
11
+ # @chipmobilesdk/rn-auth
12
+
13
+ OIDC authentication with secure token storage and an access-tier model for
14
+ feature gating.
15
+
16
+ ## Use this when
17
+
18
+ - Wiring sign-in / sign-out
19
+ - Persisting tokens (must be keychain, never plain storage)
20
+ - Gating a feature on the user's access tier
21
+ - Mapping an auth failure to a user-facing message
22
+ - Onboarding a new tenant or identity provider
23
+
24
+ ## Configuration is a contract, not options
25
+
26
+ Tenant and provider setup has prerequisites that fail at runtime, not at build time.
27
+ Before configuring anything, walk `## Configuration contract (LIB-010)` and
28
+ `## Tenant / provider prerequisite checklist (LIB-018)` in the README. Skipping the
29
+ checklist is the usual cause of "login works locally, fails in the new environment".
30
+
31
+ ## Fail closed
32
+
33
+ `failClosedTier` exists because an unknown tier must deny access, not grant it. When
34
+ tier evaluation is unavailable, deny. Never default an unresolved tier to a permissive
35
+ value.
36
+
37
+ ## Public API (v0.3.0)
38
+
39
+ Runtime:
40
+
41
+ - `createAppAuthClient(...)` — the OIDC client
42
+ - `createKeychainSecureStore(...)` — token persistence; `AUTH_STORAGE_KEY`
43
+ - `evaluateAccessTier(...)`, `failClosedTier` — feature gating
44
+ - `AuthProvider` — React context provider
45
+
46
+ Types: `SecureTokenStore`, `AuthProviderProps`, `AuthErrorCode`, `AuthErrorAudience`,
47
+ `AuthMessageMap`, `AuthMessageKey`
48
+
49
+ `AuthErrorAudience` distinguishes what may be shown to a user from what is diagnostic
50
+ only — never render a diagnostic-audience message in the UI.
51
+
52
+ ## Where the detail lives
53
+
54
+ Read one section of `node_modules/@chipmobilesdk/rn-auth/README.md`:
55
+
56
+ | Need | Section |
57
+ |---|---|
58
+ | First integration | `## Install (consuming app)`, `## Quick start` |
59
+ | Required config fields | `## Configuration contract (LIB-010)` |
60
+ | New tenant or IdP | `## Tenant / provider prerequisite checklist (LIB-018)` |
61
+ | Token handling, logging rules | `## Security & privacy` |
62
+ | Full export list | `## Public API` |
63
+ | Version/peer constraints | `## Compatibility and validation` |
@@ -124,6 +124,17 @@ export interface PremiumGateResult {
124
124
  tier: AccessTier;
125
125
  }
126
126
 
127
+ /**
128
+ * What `signOutGlobal` did beyond the device (added in 0.2.2). Local state is
129
+ * cleared either way; `failed` means the tenant session may still be alive, so
130
+ * the next interactive sign-in can complete without a password prompt.
131
+ */
132
+ export interface GlobalSignOutResult {
133
+ global: 'ended' | 'failed';
134
+ /** Present when `global` is `failed`. Redacted like every AuthError. */
135
+ error?: AuthError;
136
+ }
137
+
127
138
  /** The auth controller returned by `createAuth` (single active account). */
128
139
  export interface Auth {
129
140
  getState(): AuthState;
@@ -131,7 +142,7 @@ export interface Auth {
131
142
  restore(): Promise<AuthState>;
132
143
  signIn(providerId: ProviderId): Promise<AuthState>;
133
144
  signOut(): Promise<void>;
134
- signOutGlobal(): Promise<void>;
145
+ signOutGlobal(): Promise<GlobalSignOutResult>;
135
146
  requestAccessToken(resourceKey: string): Promise<AccessTokenResult>;
136
147
  getAccessTier(): AccessTier;
137
148
  /** Gate a premium-only path; enters `access-denied` when unverified (FR-011). */
@@ -2,6 +2,11 @@
2
2
  * Auth state machine / orchestrator (FR-005, FR-022, R5, R7). Holds the single
3
3
  * active session, wires the OidcAuthClient + SecureTokenStore ports, profile
4
4
  * mapping, access-tier evaluation, per-resource tokens, restore, and sign-out.
5
+ *
6
+ * Every operation that starts or ends a session bumps `sessionEpoch`. Work that
7
+ * awaited across such a change — a token refresh, a restore, an authorize —
8
+ * compares the epoch it started with before writing anything back, so a sign-out
9
+ * can never be undone by a request that was already in flight.
5
10
  */
6
11
 
7
12
  import { Platform } from 'react-native';
@@ -11,6 +16,7 @@ import type {
11
16
  AuthState,
12
17
  CreateAuthOptions,
13
18
  DevicePlatform,
19
+ GlobalSignOutResult,
14
20
  PremiumGateResult,
15
21
  Profile,
16
22
  ProviderId,
@@ -25,10 +31,18 @@ import {
25
31
  } from '../oidc/socialRouting';
26
32
  import { mapClaimsToProfile } from './profile';
27
33
  import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
28
- import { restoreSession } from './session';
34
+ import { restoreSession, type PersistedSession } from './session';
29
35
  import { AUTH_STORAGE_KEY } from '../tokens/secureStore';
30
36
  import { createAccessTokenManager } from '../tokens/accessToken';
31
- import { toAuthError } from '../diagnostics/errors';
37
+ import { AuthError, toAuthError } from '../diagnostics/errors';
38
+
39
+ const SIGNED_OUT: Partial<AuthState> = {
40
+ status: 'signed-out',
41
+ profile: undefined,
42
+ accessTier: undefined,
43
+ restore: 'unavailable',
44
+ error: undefined,
45
+ };
32
46
 
33
47
  export function createAuthMachine(
34
48
  config: AuthConfig,
@@ -45,7 +59,32 @@ export function createAuthMachine(
45
59
 
46
60
  let state: AuthState = { status: 'loading', restore: 'pending' };
47
61
  let refreshToken: string | null = null;
62
+ /** Kept for the end-session `id_token_hint`; without it a global sign-out is a no-op. */
63
+ let idToken: string | null = null;
64
+ let sessionEpoch = 0;
48
65
  const listeners = new Set<(state: AuthState) => void>();
66
+ /**
67
+ * Every Keychain write and delete, in order. A rotation persisted while a
68
+ * sign-out runs must not land after the sign-out's delete and resurrect the
69
+ * session on the next launch.
70
+ */
71
+ let storeQueue: Promise<void> = Promise.resolve();
72
+
73
+ const enqueueStoreOp = (op: () => Promise<void>): Promise<void> => {
74
+ const next = storeQueue.then(op);
75
+ // The queue itself never rejects, so one failed write does not poison the
76
+ // writes after it; the caller still sees its own failure through `next`.
77
+ storeQueue = next.catch(() => undefined);
78
+ return next;
79
+ };
80
+
81
+ const writeSession = (session: PersistedSession, epoch: number): Promise<void> =>
82
+ enqueueStoreOp(async () => {
83
+ if (epoch !== sessionEpoch) {
84
+ return; // The session this belonged to has already ended.
85
+ }
86
+ await secureStore.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
87
+ });
49
88
 
50
89
  const tokenManager = createAccessTokenManager({
51
90
  authority: config.authority,
@@ -54,8 +93,28 @@ export function createAuthMachine(
54
93
  resources,
55
94
  authClient,
56
95
  getRefreshToken: () => refreshToken,
57
- setRefreshToken: token => {
96
+ sessionEpoch: () => sessionEpoch,
97
+ async onRefreshTokenRotated(token, epoch) {
98
+ if (epoch !== sessionEpoch) {
99
+ return;
100
+ }
101
+ // Live immediately whatever happens to the write: the provider may
102
+ // already have retired the old token, so memory must not keep using it.
58
103
  refreshToken = token;
104
+ if (!state.profile) {
105
+ return;
106
+ }
107
+ try {
108
+ await writeSession(
109
+ { refreshToken: token, profile: state.profile, idToken: idToken ?? undefined },
110
+ epoch,
111
+ );
112
+ } catch {
113
+ // The session stays usable in memory. The stored artifact keeps the
114
+ // previous token until the next successful write; a restart before
115
+ // then restores through it if the provider still accepts it, and
116
+ // otherwise lands on sign-in — never on a corrupted state.
117
+ }
59
118
  },
60
119
  });
61
120
 
@@ -65,25 +124,24 @@ export function createAuthMachine(
65
124
 
66
125
  function setState(patch: Partial<AuthState>): void {
67
126
  state = { ...state, ...patch };
68
- for (const listener of listeners) {
69
- listener(getState());
70
- }
71
- }
72
-
73
- async function persist(profile: Profile): Promise<void> {
74
- if (!refreshToken) {
75
- return;
127
+ // Snapshot, and isolate each listener: a subscriber that throws must not
128
+ // stop the others or turn a completed sign-in into an error (CORE-03).
129
+ for (const listener of [...listeners]) {
130
+ try {
131
+ listener(getState());
132
+ } catch {
133
+ /* consumer bug; the state transition itself already happened */
134
+ }
76
135
  }
77
- await secureStore.setItem(
78
- AUTH_STORAGE_KEY,
79
- JSON.stringify({ refreshToken, profile }),
80
- );
81
136
  }
82
137
 
138
+ /** Ends the local session: memory, cached access tokens, and the stored artifact. */
83
139
  async function clearPersisted(): Promise<void> {
140
+ sessionEpoch += 1;
84
141
  refreshToken = null;
142
+ idToken = null;
85
143
  tokenManager.clear();
86
- await secureStore.removeItem(AUTH_STORAGE_KEY);
144
+ await enqueueStoreOp(() => secureStore.removeItem(AUTH_STORAGE_KEY));
87
145
  }
88
146
 
89
147
  return {
@@ -97,20 +155,49 @@ export function createAuthMachine(
97
155
  },
98
156
 
99
157
  async restore() {
158
+ sessionEpoch += 1;
159
+ const epoch = sessionEpoch;
100
160
  setState({ status: 'loading', restore: 'pending', error: undefined });
101
- const result = await restoreSession({
102
- secureStore,
103
- authority: config.authority,
104
- clientId: config.clientId,
105
- redirectUrl: config.redirectUrl,
106
- scopes: config.scopes,
107
- authClient,
108
- mapping: config.accessTierMapping,
109
- });
110
-
111
- if (result.outcome === 'restored' && result.profile) {
112
- refreshToken = result.refreshToken ?? null;
113
- await persist(result.profile);
161
+
162
+ let result;
163
+ try {
164
+ result = await restoreSession({
165
+ secureStore,
166
+ authority: config.authority,
167
+ clientId: config.clientId,
168
+ redirectUrl: config.redirectUrl,
169
+ scopes: config.scopes,
170
+ authClient,
171
+ mapping: config.accessTierMapping,
172
+ });
173
+ } catch (error) {
174
+ // The store could not be read. Nothing is known, nothing is deleted.
175
+ if (epoch === sessionEpoch) {
176
+ setState({ ...SIGNED_OUT, status: 'error', error: toAuthError(error) });
177
+ }
178
+ return getState();
179
+ }
180
+
181
+ if (epoch !== sessionEpoch) {
182
+ // A sign-in or sign-out ran meanwhile; its outcome stands.
183
+ return getState();
184
+ }
185
+
186
+ if (result.outcome === 'restored' && result.profile && result.refreshToken) {
187
+ refreshToken = result.refreshToken;
188
+ idToken = result.idToken ?? null;
189
+ try {
190
+ await writeSession(
191
+ { refreshToken: result.refreshToken, profile: result.profile, idToken: result.idToken },
192
+ epoch,
193
+ );
194
+ } catch {
195
+ // The refresh succeeded, so the user is signed in for this run. Only
196
+ // the next launch is affected, and it falls back to sign-in.
197
+ }
198
+ if (epoch !== sessionEpoch) {
199
+ return getState();
200
+ }
114
201
  setState({
115
202
  status: 'authenticated',
116
203
  profile: result.profile,
@@ -120,6 +207,7 @@ export function createAuthMachine(
120
207
  });
121
208
  } else if (result.outcome === 'offline' && result.profile) {
122
209
  refreshToken = result.refreshToken ?? null;
210
+ idToken = result.idToken ?? null;
123
211
  setState({
124
212
  status: 'authenticated',
125
213
  profile: result.profile,
@@ -129,18 +217,14 @@ export function createAuthMachine(
129
217
  });
130
218
  } else {
131
219
  await clearPersisted();
132
- setState({
133
- status: 'signed-out',
134
- profile: undefined,
135
- accessTier: undefined,
136
- restore: 'unavailable',
137
- error: undefined,
138
- });
220
+ setState(SIGNED_OUT);
139
221
  }
140
222
  return getState();
141
223
  },
142
224
 
143
225
  async signIn(providerId: ProviderId) {
226
+ sessionEpoch += 1;
227
+ const epoch = sessionEpoch;
144
228
  setState({ status: 'authenticating', error: undefined });
145
229
  let provider;
146
230
  try {
@@ -158,12 +242,39 @@ export function createAuthMachine(
158
242
  scopes: config.scopes,
159
243
  additionalParameters: buildAdditionalParameters(provider),
160
244
  });
245
+ if (epoch !== sessionEpoch) {
246
+ return getState();
247
+ }
248
+ if (!tokens.refreshToken) {
249
+ // `offline_access` is required by configuration, so its absence is the
250
+ // tenant's doing. Signing in anyway would give a session that silently
251
+ // never survives a restart.
252
+ setState({
253
+ status: 'error',
254
+ error: new AuthError({
255
+ code: 'configuration-error',
256
+ messageKey: 'auth.error.configuration',
257
+ audience: 'developer',
258
+ detail: 'authorize returned no refresh token; check that the app registration grants offline_access',
259
+ }),
260
+ });
261
+ return getState();
262
+ }
263
+ const profile = mapClaimsToProfile(tokens.claims, providerId);
264
+ const accessTier = evaluateAccessTier(tokens.claims, config.accessTierMapping);
265
+ // Written first, committed to memory second: a failed write leaves no
266
+ // half-signed-in session holding a refresh token that was never stored.
267
+ await writeSession(
268
+ { refreshToken: tokens.refreshToken, profile, idToken: tokens.idToken },
269
+ epoch,
270
+ );
271
+ if (epoch !== sessionEpoch) {
272
+ return getState();
273
+ }
161
274
  // Single active account: a new sign-in replaces any prior session.
162
275
  refreshToken = tokens.refreshToken;
276
+ idToken = tokens.idToken ?? null;
163
277
  tokenManager.clear();
164
- const profile = mapClaimsToProfile(tokens.claims, providerId);
165
- const accessTier = evaluateAccessTier(tokens.claims, config.accessTierMapping);
166
- await persist(profile);
167
278
  setState({
168
279
  status: 'authenticated',
169
280
  profile,
@@ -172,6 +283,9 @@ export function createAuthMachine(
172
283
  error: undefined,
173
284
  });
174
285
  } catch (error) {
286
+ if (epoch !== sessionEpoch) {
287
+ return getState();
288
+ }
175
289
  const authError = toAuthError(error);
176
290
  if (authError.code === 'provider-cancelled') {
177
291
  setState({ status: 'cancelled', error: authError });
@@ -184,42 +298,41 @@ export function createAuthMachine(
184
298
 
185
299
  async signOut() {
186
300
  await clearPersisted();
187
- setState({
188
- status: 'signed-out',
189
- profile: undefined,
190
- accessTier: undefined,
191
- restore: 'unavailable',
192
- error: undefined,
193
- });
301
+ setState(SIGNED_OUT);
194
302
  },
195
303
 
196
- async signOutGlobal() {
304
+ async signOutGlobal(): Promise<GlobalSignOutResult> {
305
+ let result: GlobalSignOutResult = { global: 'ended' };
197
306
  try {
198
307
  await authClient.endSession({
199
308
  authority: config.authority,
200
309
  clientId: config.clientId,
201
310
  redirectUrl: config.redirectUrl,
311
+ idToken: idToken ?? undefined,
202
312
  });
203
- } catch {
204
- // End-session failure must not block clearing local state.
313
+ } catch (error) {
314
+ // Reported, not swallowed: the tenant session may still be alive. It
315
+ // still must not block clearing local state.
316
+ result = { global: 'failed', error: toAuthError(error) };
205
317
  }
206
318
  await clearPersisted();
207
- setState({
208
- status: 'signed-out',
209
- profile: undefined,
210
- accessTier: undefined,
211
- restore: 'unavailable',
212
- error: undefined,
213
- });
319
+ setState(SIGNED_OUT);
320
+ return result;
214
321
  },
215
322
 
216
323
  async requestAccessToken(resourceKey) {
217
- const wasAuthenticated = state.status === 'authenticated';
218
- if (wasAuthenticated) {
324
+ const epoch = sessionEpoch;
325
+ // Only a real network refresh is a `token-refreshing` phase; a cache hit
326
+ // would otherwise flash every subscriber twice for nothing.
327
+ const refreshing =
328
+ state.status === 'authenticated' && !tokenManager.hasFreshToken(resourceKey);
329
+ if (refreshing) {
219
330
  setState({ status: 'token-refreshing' });
220
331
  }
221
332
  const result = await tokenManager.request(resourceKey);
222
- if (wasAuthenticated) {
333
+ // Restored only if nothing else moved the session meanwhile: a sign-out
334
+ // during the refresh used to be overwritten back to `authenticated`.
335
+ if (refreshing && epoch === sessionEpoch && state.status === 'token-refreshing') {
223
336
  setState({ status: 'authenticated' });
224
337
  }
225
338
  return result;
@@ -247,3 +360,5 @@ export function createAuthMachine(
247
360
  },
248
361
  };
249
362
  }
363
+
364
+ export type { Profile };
@@ -2,9 +2,16 @@
2
2
  * Session restoration (FR-008, R5). Reads the persisted artifact, attempts a
3
3
  * silent refresh, and classifies the outcome:
4
4
  * - restored: fresh tokens + claims → authenticated
5
- * - offline: network unavailable → cached profile read-only, tier unverified
6
- * (fail closed) — clarify Q3
7
- * - unavailable: no artifact or invalid/expired refresh → signed-out reauth
5
+ * - offline: the refresh could not complete for any reason other than the
6
+ * session being invalid — no network, a timeout, a 5xx, an error we
7
+ * cannot classify → cached profile read-only, tier unverified (fail
8
+ * closed) — clarify Q3
9
+ * - unavailable: no artifact, or the provider said the refresh token is no
10
+ * longer valid → signed-out reauth
11
+ *
12
+ * Only `unavailable` deletes the stored session, so it is reserved for a verdict
13
+ * the provider actually gave. Treating every unclassified error as expiry signed
14
+ * users out whenever the token endpoint had a bad moment.
8
15
  */
9
16
 
10
17
  import type {
@@ -23,6 +30,8 @@ import { isAuthError } from '../diagnostics/errors';
23
30
  export interface PersistedSession {
24
31
  refreshToken: string;
25
32
  profile: Profile;
33
+ /** The id token hint for end-session (AUTH-04). Absent in artifacts written before 0.2.2. */
34
+ idToken?: string;
26
35
  }
27
36
 
28
37
  export interface RestoreDeps {
@@ -40,6 +49,7 @@ export interface RestoreResult {
40
49
  profile?: Profile;
41
50
  accessTier?: AccessTier;
42
51
  refreshToken?: string;
52
+ idToken?: string;
43
53
  }
44
54
 
45
55
  export function parsePersistedSession(raw: string | null): PersistedSession | null {
@@ -72,25 +82,31 @@ export async function restoreSession(deps: RestoreDeps): Promise<RestoreResult>
72
82
  refreshToken: persisted.refreshToken,
73
83
  scopes: deps.scopes,
74
84
  });
75
- const profile = mapClaimsToProfile(tokens.claims);
76
- const accessTier = evaluateAccessTier(tokens.claims, deps.mapping);
85
+ // A refresh response may carry no id token, and so no claims. The stored
86
+ // profile is then still the best identity there is; an empty one is not.
87
+ const hasClaims = Object.keys(tokens.claims).length > 0;
77
88
  return {
78
89
  outcome: 'restored',
79
- profile,
80
- accessTier,
90
+ profile: hasClaims ? mapClaimsToProfile(tokens.claims) : persisted.profile,
91
+ accessTier: hasClaims
92
+ ? evaluateAccessTier(tokens.claims, deps.mapping)
93
+ : failClosedTier(),
81
94
  refreshToken: tokens.refreshToken || persisted.refreshToken,
95
+ idToken: tokens.idToken || persisted.idToken,
82
96
  };
83
97
  } catch (error) {
84
- // Offline: keep the cached identity read-only, fail closed on entitlement.
85
- if (isAuthError(error) && error.code === 'network-unavailable') {
86
- return {
87
- outcome: 'offline',
88
- profile: persisted.profile,
89
- accessTier: failClosedTier(),
90
- refreshToken: persisted.refreshToken,
91
- };
98
+ if (isAuthError(error) && error.code === 'session-expired') {
99
+ // The provider rejected the refresh token: reauthentication is required.
100
+ return { outcome: 'unavailable' };
92
101
  }
93
- // Invalid/expired refresh → require reauthentication.
94
- return { outcome: 'unavailable' };
102
+ // Anything else is no verdict on the session. Keep the cached identity
103
+ // read-only and fail closed on entitlement; the next restore tries again.
104
+ return {
105
+ outcome: 'offline',
106
+ profile: persisted.profile,
107
+ accessTier: failClosedTier(),
108
+ refreshToken: persisted.refreshToken,
109
+ idToken: persisted.idToken,
110
+ };
95
111
  }
96
112
  }
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export type {
33
33
  AccessTokenResult,
34
34
  AccessTokenStatus,
35
35
  PremiumGateResult,
36
+ GlobalSignOutResult,
36
37
  } from './config/types';
37
38
 
38
39
  // Ports + default adapters
@@ -6,15 +6,19 @@
6
6
  * requires react-native-app-auth and is validated on device.
7
7
  */
8
8
 
9
- import { AuthError, type AuthErrorCode } from '../diagnostics/errors';
9
+ import { classifyAppAuthError } from './classifyError';
10
10
 
11
11
  export interface OidcTokens {
12
12
  /** Resource/audience access token (present for authorize and resource refresh). */
13
13
  accessToken?: string;
14
- /** Refresh token used for silent acquisition and restore. */
15
- refreshToken: string;
16
- /** Raw id token (JWT). */
17
- idToken: string;
14
+ /**
15
+ * Refresh token used for silent acquisition and restore. Absent when the
16
+ * provider issued none — a refresh that does not rotate, or an authorize the
17
+ * tenant answered without `offline_access`.
18
+ */
19
+ refreshToken?: string;
20
+ /** Raw id token (JWT). Absent from a refresh requested without `openid`. */
21
+ idToken?: string;
18
22
  /** Decoded id-token (or access-token) claims used for profile + access tier. */
19
23
  claims: Record<string, unknown>;
20
24
  /** Access-token expiry in epoch milliseconds, when available. */
@@ -52,7 +56,10 @@ export interface OidcAuthClient {
52
56
  }
53
57
 
54
58
  /** Decode the payload segment of a JWT without any dependency (device-safe). */
55
- export function decodeJwtClaims(jwt: string): Record<string, unknown> {
59
+ export function decodeJwtClaims(jwt: string | null | undefined): Record<string, unknown> {
60
+ if (typeof jwt !== 'string') {
61
+ return {};
62
+ }
56
63
  const parts = jwt.split('.');
57
64
  if (parts.length < 2) {
58
65
  return {};
@@ -106,40 +113,6 @@ function base64UrlDecode(input: string): string {
106
113
  }
107
114
  }
108
115
 
109
- /** Map a native app-auth error to a classified AuthError without leaking tokens. */
110
- function classifyAppAuthError(error: unknown): AuthError {
111
- const message = error instanceof Error ? error.message : String(error);
112
- // DEV-only: surface the RAW native error so the actual failure (AADSTS code,
113
- // issuer/redirect mismatch, token-endpoint error) is visible while debugging.
114
- // Remove before shipping — this intentionally bypasses redaction.
115
- if (typeof __DEV__ !== 'undefined' && __DEV__) {
116
- console.warn('[rn-auth] raw app-auth error:', {
117
- message,
118
- code: (error as { code?: unknown })?.code,
119
- error,
120
- });
121
- }
122
- const lower = message.toLowerCase();
123
- let code: AuthErrorCode = 'token-acquisition-failed';
124
- let messageKey = 'auth.error.token';
125
- if (lower.includes('cancel')) {
126
- code = 'provider-cancelled';
127
- messageKey = 'auth.error.cancelled';
128
- } else if (lower.includes('network') || lower.includes('connection') || lower.includes('offline')) {
129
- code = 'network-unavailable';
130
- messageKey = 'auth.error.network';
131
- } else if (lower.includes('redirect')) {
132
- code = 'redirect-mismatch';
133
- messageKey = 'auth.error.redirectMismatch';
134
- } else if (lower.includes('consent') || lower.includes('denied') || lower.includes('access_denied')) {
135
- code = 'consent-denied';
136
- messageKey = 'auth.error.consentDenied';
137
- } else if (lower.includes('invalid_grant') || lower.includes('expired')) {
138
- code = 'session-expired';
139
- messageKey = 'auth.error.sessionExpired';
140
- }
141
- return new AuthError({ code, messageKey, audience: code === 'redirect-mismatch' ? 'developer' : 'user', detail: message });
142
- }
143
116
 
144
117
  export interface AppAuthClientOptions {
145
118
  /** When true, the OIDC discovery document is fetched from `${authority}`. */
@@ -171,13 +144,15 @@ export function createAppAuthClient(
171
144
 
172
145
  const toTokens = (result: {
173
146
  accessToken?: string;
174
- refreshToken?: string;
175
- idToken: string;
147
+ refreshToken?: string | null;
148
+ idToken?: string | null;
176
149
  accessTokenExpirationDate?: string;
177
150
  }): OidcTokens => ({
178
151
  accessToken: result.accessToken,
179
- refreshToken: result.refreshToken ?? '',
180
- idToken: result.idToken,
152
+ // Absent stays absent: an empty string was indistinguishable from a real
153
+ // token by type, and silently disabled session persistence.
154
+ refreshToken: result.refreshToken || undefined,
155
+ idToken: result.idToken || undefined,
181
156
  claims: decodeJwtClaims(result.idToken),
182
157
  accessTokenExpiresAt: result.accessTokenExpirationDate
183
158
  ? new Date(result.accessTokenExpirationDate).getTime()
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Classifies a native OIDC error into an `AuthErrorCode`.
3
+ *
4
+ * The rules are an ordered table rather than a chain of substring checks, so a
5
+ * platform adapter (the iOS one, later) adds its own rows without touching the
6
+ * engine. Order matters: the first matching rule wins.
7
+ *
8
+ * The default is deliberately not "session expired". Restore clears the stored
9
+ * session only on `session-expired`, so anything this table cannot name — a
10
+ * timeout, a 5xx, a discovery failure, a localized message — must keep the
11
+ * session rather than sign the user out.
12
+ */
13
+
14
+ import { AuthError, type AuthErrorCode } from '../diagnostics/errors';
15
+
16
+ export interface ClassificationRule {
17
+ readonly code: AuthErrorCode;
18
+ readonly messageKey: string;
19
+ /** Matched against the native error's `code` and `message`, case-insensitively. */
20
+ readonly pattern: RegExp;
21
+ }
22
+
23
+ /**
24
+ * OAuth error codes (RFC 6749 §5.2, OIDC Core §3.1.2.6) and the Entra ID AADSTS
25
+ * codes that mean the same thing, plus the transport failures app-auth reports.
26
+ */
27
+ export const DEFAULT_CLASSIFICATION_RULES: readonly ClassificationRule[] = [
28
+ {
29
+ code: 'provider-cancelled',
30
+ messageKey: 'auth.error.cancelled',
31
+ pattern: /cancel|user_cancel|authorization flow (was )?dismissed/i,
32
+ },
33
+ {
34
+ code: 'session-expired',
35
+ messageKey: 'auth.error.sessionExpired',
36
+ // invalid_grant is the refresh-token verdict; the AADSTS codes are Entra's
37
+ // expired, revoked, or inactive refresh token and password-changed cases.
38
+ pattern:
39
+ /\binvalid_grant\b|\binteraction_required\b|\blogin_required\b|AADSTS(700082|700084|50173|70043)\b/i,
40
+ },
41
+ {
42
+ code: 'redirect-mismatch',
43
+ messageKey: 'auth.error.redirectMismatch',
44
+ pattern: /redirect_uri|redirect uri|redirect_mismatch|AADSTS50011\b/i,
45
+ },
46
+ {
47
+ code: 'consent-denied',
48
+ messageKey: 'auth.error.consentDenied',
49
+ pattern: /\baccess_denied\b|\bconsent_required\b|AADSTS65001\b/i,
50
+ },
51
+ {
52
+ code: 'network-unavailable',
53
+ messageKey: 'auth.error.network',
54
+ pattern:
55
+ /network|connection|offline|timed? ?out|unable to resolve host|unreachable|\bserver_error\b|\btemporarily_unavailable\b|\b5\d\d\b/i,
56
+ },
57
+ ];
58
+
59
+ /** Maps a native app-auth error to a classified AuthError without leaking tokens. */
60
+ export function classifyAppAuthError(
61
+ error: unknown,
62
+ rules: readonly ClassificationRule[] = DEFAULT_CLASSIFICATION_RULES,
63
+ ): AuthError {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ const nativeCode = (error as { code?: unknown } | undefined)?.code;
66
+ const haystack = `${typeof nativeCode === 'string' ? nativeCode : ''} ${message}`;
67
+ const rule = rules.find(candidate => candidate.pattern.test(haystack));
68
+ const code = rule?.code ?? 'token-acquisition-failed';
69
+ return new AuthError({
70
+ code,
71
+ messageKey: rule?.messageKey ?? 'auth.error.token',
72
+ audience: code === 'redirect-mismatch' ? 'developer' : 'user',
73
+ // AuthError redacts `detail`, so token-shaped values never survive.
74
+ detail: message,
75
+ });
76
+ }
@@ -9,6 +9,7 @@ import type {
9
9
  AccessTier,
10
10
  AccessTokenResult,
11
11
  AuthState,
12
+ GlobalSignOutResult,
12
13
  PremiumGateResult,
13
14
  Profile,
14
15
  ProviderConfig,
@@ -20,7 +21,7 @@ export interface UseAuthResult {
20
21
  state: AuthState;
21
22
  signIn(providerId: ProviderId): Promise<AuthState>;
22
23
  signOut(): Promise<void>;
23
- signOutGlobal(): Promise<void>;
24
+ signOutGlobal(): Promise<GlobalSignOutResult>;
24
25
  restore(): Promise<AuthState>;
25
26
  requirePremium(): PremiumGateResult;
26
27
  }
@@ -26,80 +26,139 @@ export interface AccessTokenManagerDeps {
26
26
  resources: ResourceConfig[];
27
27
  authClient: OidcAuthClient;
28
28
  getRefreshToken: () => string | null;
29
- setRefreshToken: (token: string) => void;
29
+ /**
30
+ * The session generation, bumped by every sign-in, sign-out and restore. A
31
+ * refresh that started under one generation and finished under another
32
+ * belongs to a session that no longer exists, so its result is discarded.
33
+ */
34
+ sessionEpoch: () => number;
35
+ /**
36
+ * The provider rotated the refresh token. Resolves once the new token is
37
+ * live, and persisted where it can be: the old one may already be dead, so a
38
+ * restart must not come back with it.
39
+ */
40
+ onRefreshTokenRotated: (token: string, epoch: number) => Promise<void>;
30
41
  }
31
42
 
32
43
  export interface AccessTokenManager {
33
44
  request(resourceKey: string): Promise<AccessTokenResult>;
45
+ /** Whether `request` would answer from the cache, without touching the network. */
46
+ hasFreshToken(resourceKey: string): boolean;
34
47
  clear(): void;
35
48
  }
36
49
 
50
+ const SESSION_ENDED: Omit<AccessTokenResult, 'resourceKey'> = {
51
+ status: 'unavailable',
52
+ errorCode: 'session-expired',
53
+ };
54
+
37
55
  export function createAccessTokenManager(
38
56
  deps: AccessTokenManagerDeps,
39
57
  ): AccessTokenManager {
40
58
  const cache = new Map<string, CachedToken>();
59
+ /**
60
+ * Refreshes in progress, by resource. Concurrent callers for one resource
61
+ * share a single network call; with a rotating refresh token, a second call
62
+ * would race the first with a token the first may have just retired.
63
+ */
64
+ const inFlight = new Map<string, Promise<AccessTokenResult>>();
65
+
66
+ const fresh = (resourceKey: string): CachedToken | undefined => {
67
+ const cached = cache.get(resourceKey);
68
+ return cached && cached.expiresAt - EXPIRY_SKEW_MS > Date.now() ? cached : undefined;
69
+ };
70
+
71
+ const refresh = async (resource: ResourceConfig): Promise<AccessTokenResult> => {
72
+ const resourceKey = resource.key;
73
+ const epoch = deps.sessionEpoch();
74
+ const refreshToken = deps.getRefreshToken();
75
+ if (!refreshToken) {
76
+ return { resourceKey, ...SESSION_ENDED };
77
+ }
78
+
79
+ try {
80
+ const tokens = await deps.authClient.refresh({
81
+ authority: deps.authority,
82
+ clientId: deps.clientId,
83
+ redirectUrl: deps.redirectUrl,
84
+ refreshToken,
85
+ scopes: resource.scopes,
86
+ });
87
+ // Signed out, or signed in as someone else, while this was in flight:
88
+ // nothing from it may be written back, and its token is not handed out.
89
+ if (deps.sessionEpoch() !== epoch) {
90
+ return { resourceKey, ...SESSION_ENDED };
91
+ }
92
+ if (tokens.refreshToken && tokens.refreshToken !== refreshToken) {
93
+ await deps.onRefreshTokenRotated(tokens.refreshToken, epoch);
94
+ if (deps.sessionEpoch() !== epoch) {
95
+ return { resourceKey, ...SESSION_ENDED };
96
+ }
97
+ }
98
+ if (!tokens.accessToken) {
99
+ return { resourceKey, status: 'error', errorCode: 'token-acquisition-failed' };
100
+ }
101
+ const expiresAt = tokens.accessTokenExpiresAt ?? Date.now() + 3_600_000;
102
+ cache.set(resourceKey, {
103
+ token: tokens.accessToken,
104
+ expiresAt,
105
+ scopes: resource.scopes,
106
+ });
107
+ return { resourceKey, status: 'success', token: tokens.accessToken, expiresAt };
108
+ } catch (error) {
109
+ if (isAuthError(error)) {
110
+ if (error.code === 'consent-denied') {
111
+ return { resourceKey, status: 'denied', errorCode: error.code };
112
+ }
113
+ if (error.code === 'network-unavailable') {
114
+ return { resourceKey, status: 'unavailable', errorCode: error.code };
115
+ }
116
+ if (error.code === 'session-expired') {
117
+ return { resourceKey, status: 'unavailable', errorCode: error.code };
118
+ }
119
+ return { resourceKey, status: 'error', errorCode: error.code };
120
+ }
121
+ return { resourceKey, status: 'error', errorCode: 'token-acquisition-failed' };
122
+ }
123
+ };
41
124
 
42
125
  return {
43
126
  clear() {
44
127
  cache.clear();
128
+ inFlight.clear();
129
+ },
130
+
131
+ hasFreshToken(resourceKey) {
132
+ return fresh(resourceKey) !== undefined;
45
133
  },
46
- async request(resourceKey): Promise<AccessTokenResult> {
134
+
135
+ request(resourceKey): Promise<AccessTokenResult> {
47
136
  const resource = deps.resources.find(entry => entry.key === resourceKey);
48
137
  if (!resource) {
49
- return { resourceKey, status: 'error', errorCode: 'configuration-error' };
138
+ return Promise.resolve({ resourceKey, status: 'error', errorCode: 'configuration-error' });
50
139
  }
51
140
 
52
- const cached = cache.get(resourceKey);
53
- if (cached && cached.expiresAt - EXPIRY_SKEW_MS > Date.now()) {
54
- return {
141
+ const cached = fresh(resourceKey);
142
+ if (cached) {
143
+ return Promise.resolve({
55
144
  resourceKey,
56
145
  status: 'success',
57
146
  token: cached.token,
58
147
  expiresAt: cached.expiresAt,
59
- };
148
+ });
60
149
  }
61
150
 
62
- const refreshToken = deps.getRefreshToken();
63
- if (!refreshToken) {
64
- return { resourceKey, status: 'unavailable', errorCode: 'session-expired' };
151
+ const running = inFlight.get(resourceKey);
152
+ if (running) {
153
+ return running;
65
154
  }
66
-
67
- try {
68
- const tokens = await deps.authClient.refresh({
69
- authority: deps.authority,
70
- clientId: deps.clientId,
71
- redirectUrl: deps.redirectUrl,
72
- refreshToken,
73
- scopes: resource.scopes,
74
- });
75
- if (tokens.refreshToken) {
76
- deps.setRefreshToken(tokens.refreshToken);
77
- }
78
- if (!tokens.accessToken) {
79
- return { resourceKey, status: 'error', errorCode: 'token-acquisition-failed' };
80
- }
81
- const expiresAt = tokens.accessTokenExpiresAt ?? Date.now() + 3_600_000;
82
- cache.set(resourceKey, {
83
- token: tokens.accessToken,
84
- expiresAt,
85
- scopes: resource.scopes,
86
- });
87
- return { resourceKey, status: 'success', token: tokens.accessToken, expiresAt };
88
- } catch (error) {
89
- if (isAuthError(error)) {
90
- if (error.code === 'consent-denied') {
91
- return { resourceKey, status: 'denied', errorCode: error.code };
92
- }
93
- if (error.code === 'network-unavailable') {
94
- return { resourceKey, status: 'unavailable', errorCode: error.code };
95
- }
96
- if (error.code === 'session-expired') {
97
- return { resourceKey, status: 'unavailable', errorCode: error.code };
98
- }
99
- return { resourceKey, status: 'error', errorCode: error.code };
155
+ const flight = refresh(resource).finally(() => {
156
+ if (inFlight.get(resourceKey) === flight) {
157
+ inFlight.delete(resourceKey);
100
158
  }
101
- return { resourceKey, status: 'error', errorCode: 'token-acquisition-failed' };
102
- }
159
+ });
160
+ inFlight.set(resourceKey, flight);
161
+ return flight;
103
162
  },
104
163
  };
105
164
  }