@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 +42 -0
- package/README.md +64 -51
- package/package.json +10 -9
- package/skills/sdk-rn-auth/SKILL.md +63 -0
- package/src/config/types.ts +12 -1
- package/src/core/authMachine.ts +174 -59
- package/src/core/session.ts +33 -17
- package/src/index.ts +1 -0
- package/src/oidc/authClient.ts +19 -44
- package/src/oidc/classifyError.ts +76 -0
- package/src/react/hooks.ts +2 -1
- package/src/tokens/accessToken.ts +105 -46
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.
|
|
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.
|
|
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` |
|
package/src/config/types.ts
CHANGED
|
@@ -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<
|
|
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). */
|
package/src/core/authMachine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
218
|
-
|
|
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
|
|
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 };
|
package/src/core/session.ts
CHANGED
|
@@ -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:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
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
package/src/oidc/authClient.ts
CHANGED
|
@@ -6,15 +6,19 @@
|
|
|
6
6
|
* requires react-native-app-auth and is validated on device.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
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
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
147
|
+
refreshToken?: string | null;
|
|
148
|
+
idToken?: string | null;
|
|
176
149
|
accessTokenExpirationDate?: string;
|
|
177
150
|
}): OidcTokens => ({
|
|
178
151
|
accessToken: result.accessToken,
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
}
|
package/src/react/hooks.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
53
|
-
if (cached
|
|
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
|
|
63
|
-
if (
|
|
64
|
-
return
|
|
151
|
+
const running = inFlight.get(resourceKey);
|
|
152
|
+
if (running) {
|
|
153
|
+
return running;
|
|
65
154
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
102
|
-
|
|
159
|
+
});
|
|
160
|
+
inFlight.set(resourceKey, flight);
|
|
161
|
+
return flight;
|
|
103
162
|
},
|
|
104
163
|
};
|
|
105
164
|
}
|