@chipmobilesdk/rn-auth 0.3.0 → 0.4.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.
@@ -1,364 +1,380 @@
1
- /**
2
- * Auth state machine / orchestrator (FR-005, FR-022, R5, R7). Holds the single
3
- * active session, wires the OidcAuthClient + SecureTokenStore ports, profile
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.
10
- */
11
-
12
- import { Platform } from 'react-native';
13
- import type {
14
- Auth,
15
- AuthConfig,
16
- AuthState,
17
- CreateAuthOptions,
18
- DevicePlatform,
19
- GlobalSignOutResult,
20
- PremiumGateResult,
21
- Profile,
22
- ProviderId,
23
- } from '../config/types';
24
- import { validateConfig } from '../config/validateConfig';
25
- import { createAppAuthClient } from '../oidc/authClient';
26
- import {
27
- availableProvidersForPlatform,
28
- buildAdditionalParameters,
29
- defaultProviderForPlatform,
30
- resolveProvider,
31
- } from '../oidc/socialRouting';
32
- import { mapClaimsToProfile } from './profile';
33
- import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
34
- import { restoreSession, type PersistedSession } from './session';
35
- import { AUTH_STORAGE_KEY } from '../tokens/secureStore';
36
- import { createAccessTokenManager } from '../tokens/accessToken';
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
- };
46
-
47
- export function createAuthMachine(
48
- config: AuthConfig,
49
- options: CreateAuthOptions = {},
50
- ): Auth {
51
- // Throws AuthConfigError before anything else (FR-005, US4 scenario 2).
52
- validateConfig(config);
53
-
54
- const platform: DevicePlatform =
55
- options.platform ?? ((Platform?.OS as DevicePlatform) ?? 'ios');
56
- const secureStore = config.secureStore;
57
- const authClient = config.authClient ?? createAppAuthClient();
58
- const resources = config.resources ?? [];
59
-
60
- let state: AuthState = { status: 'loading', restore: 'pending' };
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;
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
- });
88
-
89
- const tokenManager = createAccessTokenManager({
90
- authority: config.authority,
91
- clientId: config.clientId,
92
- redirectUrl: config.redirectUrl,
93
- resources,
94
- authClient,
95
- getRefreshToken: () => refreshToken,
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.
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
- }
118
- },
119
- });
120
-
121
- function getState(): AuthState {
122
- return { ...state };
123
- }
124
-
125
- function setState(patch: Partial<AuthState>): void {
126
- state = { ...state, ...patch };
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
- }
135
- }
136
- }
137
-
138
- /** Ends the local session: memory, cached access tokens, and the stored artifact. */
139
- async function clearPersisted(): Promise<void> {
140
- sessionEpoch += 1;
141
- refreshToken = null;
142
- idToken = null;
143
- tokenManager.clear();
144
- await enqueueStoreOp(() => secureStore.removeItem(AUTH_STORAGE_KEY));
145
- }
146
-
147
- return {
148
- getState,
149
-
150
- subscribe(listener) {
151
- listeners.add(listener);
152
- return () => {
153
- listeners.delete(listener);
154
- };
155
- },
156
-
157
- async restore() {
158
- sessionEpoch += 1;
159
- const epoch = sessionEpoch;
160
- setState({ status: 'loading', restore: 'pending', error: undefined });
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
- }
201
- setState({
202
- status: 'authenticated',
203
- profile: result.profile,
204
- accessTier: result.accessTier,
205
- restore: 'restored',
206
- error: undefined,
207
- });
208
- } else if (result.outcome === 'offline' && result.profile) {
209
- refreshToken = result.refreshToken ?? null;
210
- idToken = result.idToken ?? null;
211
- setState({
212
- status: 'authenticated',
213
- profile: result.profile,
214
- accessTier: result.accessTier ?? failClosedTier(),
215
- restore: 'offline',
216
- error: undefined,
217
- });
218
- } else {
219
- await clearPersisted();
220
- setState(SIGNED_OUT);
221
- }
222
- return getState();
223
- },
224
-
225
- async signIn(providerId: ProviderId) {
226
- sessionEpoch += 1;
227
- const epoch = sessionEpoch;
228
- setState({ status: 'authenticating', error: undefined });
229
- let provider;
230
- try {
231
- provider = resolveProvider(config, providerId, platform);
232
- } catch (error) {
233
- setState({ status: 'error', error: toAuthError(error, 'configuration-error', 'auth.error.configuration') });
234
- return getState();
235
- }
236
-
237
- try {
238
- const tokens = await authClient.authorize({
239
- authority: config.authority,
240
- clientId: config.clientId,
241
- redirectUrl: config.redirectUrl,
242
- scopes: config.scopes,
243
- additionalParameters: buildAdditionalParameters(provider),
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
- }
274
- // Single active account: a new sign-in replaces any prior session.
275
- refreshToken = tokens.refreshToken;
276
- idToken = tokens.idToken ?? null;
277
- tokenManager.clear();
278
- setState({
279
- status: 'authenticated',
280
- profile,
281
- accessTier,
282
- restore: 'restored',
283
- error: undefined,
284
- });
285
- } catch (error) {
286
- if (epoch !== sessionEpoch) {
287
- return getState();
288
- }
289
- const authError = toAuthError(error);
290
- if (authError.code === 'provider-cancelled') {
291
- setState({ status: 'cancelled', error: authError });
292
- } else {
293
- setState({ status: 'error', error: authError });
294
- }
295
- }
296
- return getState();
297
- },
298
-
299
- async signOut() {
300
- await clearPersisted();
301
- setState(SIGNED_OUT);
302
- },
303
-
304
- async signOutGlobal(): Promise<GlobalSignOutResult> {
305
- let result: GlobalSignOutResult = { global: 'ended' };
306
- try {
307
- await authClient.endSession({
308
- authority: config.authority,
309
- clientId: config.clientId,
310
- redirectUrl: config.redirectUrl,
311
- idToken: idToken ?? undefined,
312
- });
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) };
317
- }
318
- await clearPersisted();
319
- setState(SIGNED_OUT);
320
- return result;
321
- },
322
-
323
- async requestAccessToken(resourceKey) {
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) {
330
- setState({ status: 'token-refreshing' });
331
- }
332
- const result = await tokenManager.request(resourceKey);
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') {
336
- setState({ status: 'authenticated' });
337
- }
338
- return result;
339
- },
340
-
341
- getAccessTier() {
342
- return state.accessTier ?? failClosedTier();
343
- },
344
-
345
- requirePremium(): PremiumGateResult {
346
- const tier = state.accessTier ?? failClosedTier();
347
- const allowed = tier.tier === 'premium' && tier.verification === 'verified';
348
- if (!allowed) {
349
- setState({ status: 'access-denied' });
350
- }
351
- return { allowed, tier };
352
- },
353
-
354
- getPlatformProvider() {
355
- return defaultProviderForPlatform(config, platform);
356
- },
357
-
358
- getPlatformProviders() {
359
- return availableProvidersForPlatform(config, platform);
360
- },
361
- };
362
- }
363
-
364
- export type { Profile };
1
+ /**
2
+ * Auth state machine / orchestrator (FR-005, FR-022, R5, R7). Holds the single
3
+ * active session, wires the OidcAuthClient + SecureTokenStore ports, profile
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.
10
+ */
11
+
12
+ import {
13
+ createDiagnosticChannel,
14
+ createListenerSet,
15
+ currentPlatform,
16
+ shallowEqual,
17
+ } from '@chipmobilesdk/rn-core';
18
+
19
+ import type {
20
+ Auth,
21
+ AuthConfig,
22
+ AuthState,
23
+ CreateAuthOptions,
24
+ DevicePlatform,
25
+ GlobalSignOutResult,
26
+ PremiumGateResult,
27
+ Profile,
28
+ ProviderId,
29
+ } from '../config/types';
30
+ import { validateConfig } from '../config/validateConfig';
31
+ import { createAppAuthClient } from '../oidc/authClient';
32
+ import {
33
+ availableProvidersForPlatform,
34
+ buildAdditionalParameters,
35
+ defaultProviderForPlatform,
36
+ resolveProvider,
37
+ } from '../oidc/socialRouting';
38
+ import { mapClaimsToProfile } from './profile';
39
+ import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
40
+ import { restoreSession, type PersistedSession } from './session';
41
+ import { AUTH_STORAGE_KEY } from '../tokens/secureStore';
42
+ import { createAccessTokenManager } from '../tokens/accessToken';
43
+ import { AuthError, toAuthError, type AuthErrorCode } from '../diagnostics/errors';
44
+
45
+ const SIGNED_OUT: Partial<AuthState> = {
46
+ status: 'signed-out',
47
+ profile: undefined,
48
+ accessTier: undefined,
49
+ restore: 'unavailable',
50
+ error: undefined,
51
+ };
52
+
53
+ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions = {}): Auth {
54
+ // Throws AuthConfigError before anything else (FR-005, US4 scenario 2).
55
+ validateConfig(config);
56
+
57
+ // rn-core's one platform source (CORE-09). Outside Android and iOS (tests on
58
+ // Node, web) the providers are resolved as for iOS, as before.
59
+ const detected = currentPlatform();
60
+ const platform: DevicePlatform =
61
+ options.platform ?? (detected === 'unsupported' ? 'ios' : detected);
62
+ const secureStore = config.secureStore;
63
+ const authClient = config.authClient ?? createAppAuthClient();
64
+ const resources = config.resources ?? [];
65
+
66
+ let state: AuthState = Object.freeze({ status: 'loading', restore: 'pending' });
67
+ let refreshToken: string | null = null;
68
+ /** Kept for the end-session `id_token_hint`; without it a global sign-out is a no-op. */
69
+ let idToken: string | null = null;
70
+ let sessionEpoch = 0;
71
+ const listeners = createListenerSet<[AuthState]>();
72
+ /**
73
+ * Every Keychain write and delete, in order. A rotation persisted while a
74
+ * sign-out runs must not land after the sign-out's delete and resurrect the
75
+ * session on the next launch.
76
+ */
77
+ let storeQueue: Promise<void> = Promise.resolve();
78
+
79
+ const enqueueStoreOp = (op: () => Promise<void>): Promise<void> => {
80
+ const next = storeQueue.then(op);
81
+ // The queue itself never rejects, so one failed write does not poison the
82
+ // writes after it; the caller still sees its own failure through `next`.
83
+ storeQueue = next.catch(() => undefined);
84
+ return next;
85
+ };
86
+
87
+ const writeSession = (session: PersistedSession, epoch: number): Promise<void> =>
88
+ enqueueStoreOp(async () => {
89
+ if (epoch !== sessionEpoch) {
90
+ return; // The session this belonged to has already ended.
91
+ }
92
+ await secureStore.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
93
+ });
94
+
95
+ const tokenManager = createAccessTokenManager({
96
+ authority: config.authority,
97
+ clientId: config.clientId,
98
+ redirectUrl: config.redirectUrl,
99
+ resources,
100
+ authClient,
101
+ getRefreshToken: () => refreshToken,
102
+ sessionEpoch: () => sessionEpoch,
103
+ async onRefreshTokenRotated(token, epoch) {
104
+ if (epoch !== sessionEpoch) {
105
+ return;
106
+ }
107
+ // Live immediately whatever happens to the write: the provider may
108
+ // already have retired the old token, so memory must not keep using it.
109
+ refreshToken = token;
110
+ if (!state.profile) {
111
+ return;
112
+ }
113
+ try {
114
+ await writeSession(
115
+ { refreshToken: token, profile: state.profile, idToken: idToken ?? undefined },
116
+ epoch,
117
+ );
118
+ } catch {
119
+ // The session stays usable in memory. The stored artifact keeps the
120
+ // previous token until the next successful write; a restart before
121
+ // then restores through it if the provider still accepts it, and
122
+ // otherwise lands on sign-in — never on a corrupted state.
123
+ }
124
+ },
125
+ });
126
+
127
+ /**
128
+ * The current snapshot, the same object until the state changes, so React reads
129
+ * it through `useSyncExternalStore` without re-rendering for nothing (PERF-02).
130
+ * Frozen: a consumer cannot change the machine's state by mutating it.
131
+ */
132
+ function getState(): AuthState {
133
+ return state;
134
+ }
135
+
136
+ function setState(patch: Partial<AuthState>): void {
137
+ const next = { ...state, ...patch };
138
+ // No change, no new snapshot and no notification.
139
+ if (shallowEqual(state, next)) return;
140
+ state = Object.freeze(next);
141
+ if (patch.error) {
142
+ // The code and audience only; `detail` is already redacted but stays local.
143
+ channel.emit(patch.error.code, patch.error.audience === 'user' ? 'info' : 'warn', {
144
+ status: state.status,
145
+ retryable: patch.error.retryable,
146
+ });
147
+ }
148
+ // Isolated: a subscriber that throws neither stops the others nor turns a
149
+ // completed sign-in into an error; rn-core reports it instead (CORE-03).
150
+ listeners.notify(getState());
151
+ }
152
+
153
+ /** Ends the local session: memory, cached access tokens, and the stored artifact. */
154
+ async function clearPersisted(): Promise<void> {
155
+ sessionEpoch += 1;
156
+ refreshToken = null;
157
+ idToken = null;
158
+ tokenManager.clear();
159
+ await enqueueStoreOp(() => secureStore.removeItem(AUTH_STORAGE_KEY));
160
+ }
161
+
162
+ return {
163
+ getState,
164
+
165
+ subscribe: listener => listeners.add(listener),
166
+
167
+ async restore() {
168
+ sessionEpoch += 1;
169
+ const epoch = sessionEpoch;
170
+ setState({ status: 'loading', restore: 'pending', error: undefined });
171
+
172
+ let result;
173
+ try {
174
+ result = await restoreSession({
175
+ secureStore,
176
+ authority: config.authority,
177
+ clientId: config.clientId,
178
+ redirectUrl: config.redirectUrl,
179
+ scopes: config.scopes,
180
+ authClient,
181
+ mapping: config.accessTierMapping,
182
+ });
183
+ } catch (error) {
184
+ // The store could not be read. Nothing is known, nothing is deleted.
185
+ if (epoch === sessionEpoch) {
186
+ setState({ ...SIGNED_OUT, status: 'error', error: toAuthError(error) });
187
+ }
188
+ return getState();
189
+ }
190
+
191
+ if (epoch !== sessionEpoch) {
192
+ // A sign-in or sign-out ran meanwhile; its outcome stands.
193
+ return getState();
194
+ }
195
+
196
+ if (result.outcome === 'restored' && result.profile && result.refreshToken) {
197
+ refreshToken = result.refreshToken;
198
+ idToken = result.idToken ?? null;
199
+ try {
200
+ await writeSession(
201
+ { refreshToken: result.refreshToken, profile: result.profile, idToken: result.idToken },
202
+ epoch,
203
+ );
204
+ } catch {
205
+ // The refresh succeeded, so the user is signed in for this run. Only
206
+ // the next launch is affected, and it falls back to sign-in.
207
+ }
208
+ if (epoch !== sessionEpoch) {
209
+ return getState();
210
+ }
211
+ setState({
212
+ status: 'authenticated',
213
+ profile: result.profile,
214
+ accessTier: result.accessTier,
215
+ restore: 'restored',
216
+ error: undefined,
217
+ });
218
+ } else if (result.outcome === 'offline' && result.profile) {
219
+ refreshToken = result.refreshToken ?? null;
220
+ idToken = result.idToken ?? null;
221
+ setState({
222
+ status: 'authenticated',
223
+ profile: result.profile,
224
+ accessTier: result.accessTier ?? failClosedTier(),
225
+ restore: 'offline',
226
+ error: undefined,
227
+ });
228
+ } else {
229
+ await clearPersisted();
230
+ setState(SIGNED_OUT);
231
+ }
232
+ return getState();
233
+ },
234
+
235
+ async signIn(providerId: ProviderId) {
236
+ sessionEpoch += 1;
237
+ const epoch = sessionEpoch;
238
+ setState({ status: 'authenticating', error: undefined });
239
+ let provider;
240
+ try {
241
+ provider = resolveProvider(config, providerId, platform);
242
+ } catch (error) {
243
+ setState({
244
+ status: 'error',
245
+ error: toAuthError(error, 'configuration-error', 'auth.error.configuration'),
246
+ });
247
+ return getState();
248
+ }
249
+
250
+ try {
251
+ const tokens = await authClient.authorize({
252
+ authority: config.authority,
253
+ clientId: config.clientId,
254
+ redirectUrl: config.redirectUrl,
255
+ scopes: config.scopes,
256
+ additionalParameters: buildAdditionalParameters(provider),
257
+ });
258
+ if (epoch !== sessionEpoch) {
259
+ return getState();
260
+ }
261
+ if (!tokens.refreshToken) {
262
+ // `offline_access` is required by configuration, so its absence is the
263
+ // tenant's doing. Signing in anyway would give a session that silently
264
+ // never survives a restart.
265
+ setState({
266
+ status: 'error',
267
+ error: new AuthError({
268
+ code: 'configuration-error',
269
+ messageKey: 'auth.error.configuration',
270
+ audience: 'developer',
271
+ detail:
272
+ 'authorize returned no refresh token; check that the app registration grants offline_access',
273
+ }),
274
+ });
275
+ return getState();
276
+ }
277
+ const profile = mapClaimsToProfile(tokens.claims, providerId);
278
+ const accessTier = evaluateAccessTier(tokens.claims, config.accessTierMapping);
279
+ // Written first, committed to memory second: a failed write leaves no
280
+ // half-signed-in session holding a refresh token that was never stored.
281
+ await writeSession(
282
+ { refreshToken: tokens.refreshToken, profile, idToken: tokens.idToken },
283
+ epoch,
284
+ );
285
+ if (epoch !== sessionEpoch) {
286
+ return getState();
287
+ }
288
+ // Single active account: a new sign-in replaces any prior session.
289
+ refreshToken = tokens.refreshToken;
290
+ idToken = tokens.idToken ?? null;
291
+ tokenManager.clear();
292
+ setState({
293
+ status: 'authenticated',
294
+ profile,
295
+ accessTier,
296
+ restore: 'restored',
297
+ error: undefined,
298
+ });
299
+ } catch (error) {
300
+ if (epoch !== sessionEpoch) {
301
+ return getState();
302
+ }
303
+ const authError = toAuthError(error);
304
+ if (authError.code === 'provider-cancelled') {
305
+ setState({ status: 'cancelled', error: authError });
306
+ } else {
307
+ setState({ status: 'error', error: authError });
308
+ }
309
+ }
310
+ return getState();
311
+ },
312
+
313
+ async signOut() {
314
+ await clearPersisted();
315
+ setState(SIGNED_OUT);
316
+ },
317
+
318
+ async signOutGlobal(): Promise<GlobalSignOutResult> {
319
+ let result: GlobalSignOutResult = { global: 'ended' };
320
+ try {
321
+ await authClient.endSession({
322
+ authority: config.authority,
323
+ clientId: config.clientId,
324
+ redirectUrl: config.redirectUrl,
325
+ idToken: idToken ?? undefined,
326
+ });
327
+ } catch (error) {
328
+ // Reported, not swallowed: the tenant session may still be alive. It
329
+ // still must not block clearing local state.
330
+ result = { global: 'failed', error: toAuthError(error) };
331
+ }
332
+ await clearPersisted();
333
+ setState(SIGNED_OUT);
334
+ return result;
335
+ },
336
+
337
+ async requestAccessToken(resourceKey) {
338
+ const epoch = sessionEpoch;
339
+ // Only a real network refresh is a `token-refreshing` phase; a cache hit
340
+ // would otherwise flash every subscriber twice for nothing.
341
+ const refreshing =
342
+ state.status === 'authenticated' && !tokenManager.hasFreshToken(resourceKey);
343
+ if (refreshing) {
344
+ setState({ status: 'token-refreshing' });
345
+ }
346
+ const result = await tokenManager.request(resourceKey);
347
+ // Restored only if nothing else moved the session meanwhile: a sign-out
348
+ // during the refresh used to be overwritten back to `authenticated`.
349
+ if (refreshing && epoch === sessionEpoch && state.status === 'token-refreshing') {
350
+ setState({ status: 'authenticated' });
351
+ }
352
+ return result;
353
+ },
354
+
355
+ getAccessTier() {
356
+ return state.accessTier ?? failClosedTier();
357
+ },
358
+
359
+ requirePremium(): PremiumGateResult {
360
+ const tier = state.accessTier ?? failClosedTier();
361
+ const allowed = tier.tier === 'premium' && tier.verification === 'verified';
362
+ if (!allowed) {
363
+ setState({ status: 'access-denied' });
364
+ }
365
+ return { allowed, tier };
366
+ },
367
+
368
+ getPlatformProvider() {
369
+ return defaultProviderForPlatform(config, platform);
370
+ },
371
+
372
+ getPlatformProviders() {
373
+ return availableProvidersForPlatform(config, platform);
374
+ },
375
+ };
376
+ }
377
+
378
+ export type { Profile };
379
+ /** Every error the machine lands in also goes to the app's SDK sinks (CORE-08). */
380
+ const channel = createDiagnosticChannel<AuthErrorCode>('rn-auth');