@chipmobilesdk/rn-auth 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +141 -0
- package/README.md +99 -11
- package/package.json +23 -2
- package/skills/sdk-rn-auth/SKILL.md +38 -2
- package/src/app-auth.ts +6 -0
- package/src/config/types.ts +58 -10
- package/src/config/validateConfig.ts +32 -1
- package/src/core/authMachine.ts +77 -27
- package/src/core/profile.ts +6 -1
- package/src/core/session.ts +51 -5
- package/src/diagnostics/errors.ts +35 -21
- package/src/diagnostics/messageKeys.ts +34 -21
- package/src/index.ts +9 -1
- package/src/keychain.ts +6 -0
- package/src/oidc/authClient.ts +71 -12
- package/src/oidc/classifyError.ts +2 -1
- package/src/oidc/socialRouting.ts +3 -1
- package/src/react/AuthProvider.tsx +14 -25
- package/src/react/hooks.ts +82 -26
- package/src/testing/conformance.ts +177 -0
- package/src/testing/index.ts +30 -0
- package/src/tokens/accessToken.ts +20 -9
- package/src/tokens/secureStore.ts +43 -9
package/src/core/authMachine.ts
CHANGED
|
@@ -9,7 +9,15 @@
|
|
|
9
9
|
* can never be undone by a request that was already in flight.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
createDiagnosticChannel,
|
|
14
|
+
createListenerSet,
|
|
15
|
+
currentPlatform,
|
|
16
|
+
shallowEqual,
|
|
17
|
+
} from '@chipmobilesdk/rn-core';
|
|
18
|
+
|
|
19
|
+
import type { AuthMessageMap } from '../diagnostics/messageKeys';
|
|
20
|
+
import { resolveMessages } from '../diagnostics/messageKeys';
|
|
13
21
|
import type {
|
|
14
22
|
Auth,
|
|
15
23
|
AuthConfig,
|
|
@@ -31,10 +39,10 @@ import {
|
|
|
31
39
|
} from '../oidc/socialRouting';
|
|
32
40
|
import { mapClaimsToProfile } from './profile';
|
|
33
41
|
import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
|
|
34
|
-
import { restoreSession, type PersistedSession } from './session';
|
|
42
|
+
import { restoreSession, sameSession, storedProfileFrom, type PersistedSession } from './session';
|
|
35
43
|
import { AUTH_STORAGE_KEY } from '../tokens/secureStore';
|
|
36
44
|
import { createAccessTokenManager } from '../tokens/accessToken';
|
|
37
|
-
import { AuthError, toAuthError } from '../diagnostics/errors';
|
|
45
|
+
import { AuthError, toAuthError, type AuthErrorCode } from '../diagnostics/errors';
|
|
38
46
|
|
|
39
47
|
const SIGNED_OUT: Partial<AuthState> = {
|
|
40
48
|
status: 'signed-out',
|
|
@@ -48,17 +56,23 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
48
56
|
// Throws AuthConfigError before anything else (FR-005, US4 scenario 2).
|
|
49
57
|
validateConfig(config);
|
|
50
58
|
|
|
51
|
-
|
|
59
|
+
// rn-core's one platform source (CORE-09). Outside Android and iOS (tests on
|
|
60
|
+
// Node, web) the providers are resolved as for iOS, as before.
|
|
61
|
+
const detected = currentPlatform();
|
|
62
|
+
const platform: DevicePlatform =
|
|
63
|
+
options.platform ?? (detected === 'unsupported' ? 'ios' : detected);
|
|
52
64
|
const secureStore = config.secureStore;
|
|
53
65
|
const authClient = config.authClient ?? createAppAuthClient();
|
|
54
66
|
const resources = config.resources ?? [];
|
|
67
|
+
// Declared since 0.1.0 and read by nothing until now (AUTH-15).
|
|
68
|
+
const messages = resolveMessages(config.messages);
|
|
55
69
|
|
|
56
|
-
let state: AuthState = { status: 'loading', restore: 'pending' };
|
|
70
|
+
let state: AuthState = Object.freeze({ status: 'loading', restore: 'pending' });
|
|
57
71
|
let refreshToken: string | null = null;
|
|
58
72
|
/** Kept for the end-session `id_token_hint`; without it a global sign-out is a no-op. */
|
|
59
73
|
let idToken: string | null = null;
|
|
60
74
|
let sessionEpoch = 0;
|
|
61
|
-
const listeners =
|
|
75
|
+
const listeners = createListenerSet<[AuthState]>();
|
|
62
76
|
/**
|
|
63
77
|
* Every Keychain write and delete, in order. A rotation persisted while a
|
|
64
78
|
* sign-out runs must not land after the sign-out's delete and resurrect the
|
|
@@ -74,12 +88,29 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
74
88
|
return next;
|
|
75
89
|
};
|
|
76
90
|
|
|
77
|
-
|
|
91
|
+
/** The artifact as last written or read, so an identical write is skipped. */
|
|
92
|
+
let storedSession: PersistedSession | null = null;
|
|
93
|
+
|
|
94
|
+
const writeSession = (
|
|
95
|
+
session: { refreshToken: string; profile: Profile; idToken?: string | undefined },
|
|
96
|
+
epoch: number,
|
|
97
|
+
): Promise<void> =>
|
|
78
98
|
enqueueStoreOp(async () => {
|
|
79
99
|
if (epoch !== sessionEpoch) {
|
|
80
100
|
return; // The session this belonged to has already ended.
|
|
81
101
|
}
|
|
82
|
-
|
|
102
|
+
const artifact: PersistedSession = {
|
|
103
|
+
refreshToken: session.refreshToken,
|
|
104
|
+
profile: storedProfileFrom(session.profile),
|
|
105
|
+
idToken: session.idToken,
|
|
106
|
+
};
|
|
107
|
+
// A restore that rotated nothing used to write the Keychain again on
|
|
108
|
+
// every launch, on the startup path (AUTH-23).
|
|
109
|
+
if (storedSession && sameSession(storedSession, artifact)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await secureStore.setItem(AUTH_STORAGE_KEY, JSON.stringify(artifact));
|
|
113
|
+
storedSession = artifact;
|
|
83
114
|
});
|
|
84
115
|
|
|
85
116
|
const tokenManager = createAccessTokenManager({
|
|
@@ -114,21 +145,30 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
114
145
|
},
|
|
115
146
|
});
|
|
116
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The current snapshot, the same object until the state changes, so React reads
|
|
150
|
+
* it through `useSyncExternalStore` without re-rendering for nothing (PERF-02).
|
|
151
|
+
* Frozen: a consumer cannot change the machine's state by mutating it.
|
|
152
|
+
*/
|
|
117
153
|
function getState(): AuthState {
|
|
118
|
-
return
|
|
154
|
+
return state;
|
|
119
155
|
}
|
|
120
156
|
|
|
121
157
|
function setState(patch: Partial<AuthState>): void {
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
158
|
+
const next = { ...state, ...patch };
|
|
159
|
+
// No change, no new snapshot and no notification.
|
|
160
|
+
if (shallowEqual(state, next)) return;
|
|
161
|
+
state = Object.freeze(next);
|
|
162
|
+
if (patch.error) {
|
|
163
|
+
// The code and audience only; `detail` is already redacted but stays local.
|
|
164
|
+
channel.emit(patch.error.code, patch.error.audience === 'user' ? 'info' : 'warn', {
|
|
165
|
+
status: state.status,
|
|
166
|
+
retryable: patch.error.retryable,
|
|
167
|
+
});
|
|
131
168
|
}
|
|
169
|
+
// Isolated: a subscriber that throws neither stops the others nor turns a
|
|
170
|
+
// completed sign-in into an error; rn-core reports it instead (CORE-03).
|
|
171
|
+
listeners.notify(getState());
|
|
132
172
|
}
|
|
133
173
|
|
|
134
174
|
/** Ends the local session: memory, cached access tokens, and the stored artifact. */
|
|
@@ -136,6 +176,7 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
136
176
|
sessionEpoch += 1;
|
|
137
177
|
refreshToken = null;
|
|
138
178
|
idToken = null;
|
|
179
|
+
storedSession = null;
|
|
139
180
|
tokenManager.clear();
|
|
140
181
|
await enqueueStoreOp(() => secureStore.removeItem(AUTH_STORAGE_KEY));
|
|
141
182
|
}
|
|
@@ -143,12 +184,7 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
143
184
|
return {
|
|
144
185
|
getState,
|
|
145
186
|
|
|
146
|
-
subscribe(listener)
|
|
147
|
-
listeners.add(listener);
|
|
148
|
-
return () => {
|
|
149
|
-
listeners.delete(listener);
|
|
150
|
-
};
|
|
151
|
-
},
|
|
187
|
+
subscribe: listener => listeners.add(listener),
|
|
152
188
|
|
|
153
189
|
async restore() {
|
|
154
190
|
sessionEpoch += 1;
|
|
@@ -179,6 +215,8 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
179
215
|
return getState();
|
|
180
216
|
}
|
|
181
217
|
|
|
218
|
+
storedSession = result.persisted ?? null;
|
|
219
|
+
|
|
182
220
|
if (result.outcome === 'restored' && result.profile && result.refreshToken) {
|
|
183
221
|
refreshToken = result.refreshToken;
|
|
184
222
|
idToken = result.idToken ?? null;
|
|
@@ -320,16 +358,17 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
320
358
|
return result;
|
|
321
359
|
},
|
|
322
360
|
|
|
323
|
-
async requestAccessToken(resourceKey) {
|
|
361
|
+
async requestAccessToken(resourceKey, tokenOptions) {
|
|
324
362
|
const epoch = sessionEpoch;
|
|
325
363
|
// Only a real network refresh is a `token-refreshing` phase; a cache hit
|
|
326
364
|
// would otherwise flash every subscriber twice for nothing.
|
|
327
365
|
const refreshing =
|
|
328
|
-
state.status === 'authenticated' &&
|
|
366
|
+
state.status === 'authenticated' &&
|
|
367
|
+
(tokenOptions?.forceRefresh === true || !tokenManager.hasFreshToken(resourceKey));
|
|
329
368
|
if (refreshing) {
|
|
330
369
|
setState({ status: 'token-refreshing' });
|
|
331
370
|
}
|
|
332
|
-
const result = await tokenManager.request(resourceKey);
|
|
371
|
+
const result = await tokenManager.request(resourceKey, tokenOptions);
|
|
333
372
|
// Restored only if nothing else moved the session meanwhile: a sign-out
|
|
334
373
|
// during the refresh used to be overwritten back to `authenticated`.
|
|
335
374
|
if (refreshing && epoch === sessionEpoch && state.status === 'token-refreshing') {
|
|
@@ -342,6 +381,11 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
342
381
|
return state.accessTier ?? failClosedTier();
|
|
343
382
|
},
|
|
344
383
|
|
|
384
|
+
canAccessPremium(): PremiumGateResult {
|
|
385
|
+
const tier = state.accessTier ?? failClosedTier();
|
|
386
|
+
return { allowed: tier.tier === 'premium' && tier.verification === 'verified', tier };
|
|
387
|
+
},
|
|
388
|
+
|
|
345
389
|
requirePremium(): PremiumGateResult {
|
|
346
390
|
const tier = state.accessTier ?? failClosedTier();
|
|
347
391
|
const allowed = tier.tier === 'premium' && tier.verification === 'verified';
|
|
@@ -351,6 +395,10 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
351
395
|
return { allowed, tier };
|
|
352
396
|
},
|
|
353
397
|
|
|
398
|
+
getMessages(): AuthMessageMap {
|
|
399
|
+
return messages;
|
|
400
|
+
},
|
|
401
|
+
|
|
354
402
|
getPlatformProvider() {
|
|
355
403
|
return defaultProviderForPlatform(config, platform);
|
|
356
404
|
},
|
|
@@ -362,3 +410,5 @@ export function createAuthMachine(config: AuthConfig, options: CreateAuthOptions
|
|
|
362
410
|
}
|
|
363
411
|
|
|
364
412
|
export type { Profile };
|
|
413
|
+
/** Every error the machine lands in also goes to the app's SDK sinks (CORE-08). */
|
|
414
|
+
const channel = createDiagnosticChannel<AuthErrorCode>('rn-auth');
|
package/src/core/profile.ts
CHANGED
|
@@ -41,7 +41,12 @@ export function mapClaimsToProfile(
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Describe the external provider identity behind the profile (FR-004 edge
|
|
46
|
+
* cases). `emailBehavior` is what a screen needs to explain an address the user
|
|
47
|
+
* did not choose: Apple's private relay forwards mail and is not the address
|
|
48
|
+
* they type anywhere else.
|
|
49
|
+
*/
|
|
45
50
|
export function describeProviderIdentity(
|
|
46
51
|
claims: Record<string, unknown>,
|
|
47
52
|
providerId?: ProviderId,
|
package/src/core/session.ts
CHANGED
|
@@ -22,11 +22,48 @@ import { mapClaimsToProfile } from './profile';
|
|
|
22
22
|
import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
|
|
23
23
|
import { isAuthError } from '../diagnostics/errors';
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The identity in the stored artifact: who, not what the token said.
|
|
27
|
+
*
|
|
28
|
+
* The whole claim set used to be written to the Keychain on every launch — all
|
|
29
|
+
* of it, groups included — for a value nothing reads back: the access tier is
|
|
30
|
+
* recomputed from fresh claims, and a restore that cannot reach the provider
|
|
31
|
+
* fails closed anyway (AUTH-23).
|
|
32
|
+
*/
|
|
33
|
+
export type StoredProfile = Omit<Profile, 'claims'>;
|
|
34
|
+
|
|
25
35
|
export interface PersistedSession {
|
|
26
36
|
refreshToken: string;
|
|
27
|
-
profile:
|
|
37
|
+
profile: StoredProfile;
|
|
28
38
|
/** The id token hint for end-session (AUTH-04). Absent in artifacts written before 0.2.2. */
|
|
29
|
-
idToken?: string;
|
|
39
|
+
idToken?: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The artifact's identity as a profile. Artifacts written before 0.4.0 carry claims; they are kept. */
|
|
43
|
+
export function profileFromStored(stored: StoredProfile | Profile): Profile {
|
|
44
|
+
return { ...stored, claims: 'claims' in stored ? (stored.claims ?? {}) : {} };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What goes to the store: everything but the claims. */
|
|
48
|
+
export function storedProfileFrom(profile: Profile): StoredProfile {
|
|
49
|
+
return {
|
|
50
|
+
tenantUserId: profile.tenantUserId,
|
|
51
|
+
displayName: profile.displayName,
|
|
52
|
+
email: profile.email,
|
|
53
|
+
provider: profile.provider,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when the artifact would be byte-identical, so the write can be skipped. */
|
|
58
|
+
export function sameSession(a: PersistedSession, b: PersistedSession): boolean {
|
|
59
|
+
return (
|
|
60
|
+
a.refreshToken === b.refreshToken &&
|
|
61
|
+
a.idToken === b.idToken &&
|
|
62
|
+
a.profile.tenantUserId === b.profile.tenantUserId &&
|
|
63
|
+
a.profile.displayName === b.profile.displayName &&
|
|
64
|
+
a.profile.email === b.profile.email &&
|
|
65
|
+
a.profile.provider === b.profile.provider
|
|
66
|
+
);
|
|
30
67
|
}
|
|
31
68
|
|
|
32
69
|
export interface RestoreDeps {
|
|
@@ -42,9 +79,11 @@ export interface RestoreDeps {
|
|
|
42
79
|
export interface RestoreResult {
|
|
43
80
|
outcome: RestoreStatus;
|
|
44
81
|
profile?: Profile;
|
|
82
|
+
/** The artifact this restore read, so a rewrite that changes nothing can be skipped. */
|
|
83
|
+
persisted?: PersistedSession;
|
|
45
84
|
accessTier?: AccessTier;
|
|
46
85
|
refreshToken?: string;
|
|
47
|
-
idToken?: string;
|
|
86
|
+
idToken?: string | undefined;
|
|
48
87
|
}
|
|
49
88
|
|
|
50
89
|
export function parsePersistedSession(raw: string | null): PersistedSession | null {
|
|
@@ -82,7 +121,13 @@ export async function restoreSession(deps: RestoreDeps): Promise<RestoreResult>
|
|
|
82
121
|
const hasClaims = Object.keys(tokens.claims).length > 0;
|
|
83
122
|
return {
|
|
84
123
|
outcome: 'restored',
|
|
85
|
-
|
|
124
|
+
persisted,
|
|
125
|
+
// The provider id came from the sign-in and is not in the claims: a
|
|
126
|
+
// refresh answered `idp: 'google.com'`, so a restored profile used to
|
|
127
|
+
// report a different provider than the same session did before (AUTH-10).
|
|
128
|
+
profile: hasClaims
|
|
129
|
+
? mapClaimsToProfile(tokens.claims, persisted.profile.provider)
|
|
130
|
+
: profileFromStored(persisted.profile),
|
|
86
131
|
accessTier: hasClaims ? evaluateAccessTier(tokens.claims, deps.mapping) : failClosedTier(),
|
|
87
132
|
refreshToken: tokens.refreshToken || persisted.refreshToken,
|
|
88
133
|
idToken: tokens.idToken || persisted.idToken,
|
|
@@ -96,7 +141,8 @@ export async function restoreSession(deps: RestoreDeps): Promise<RestoreResult>
|
|
|
96
141
|
// read-only and fail closed on entitlement; the next restore tries again.
|
|
97
142
|
return {
|
|
98
143
|
outcome: 'offline',
|
|
99
|
-
|
|
144
|
+
persisted,
|
|
145
|
+
profile: profileFromStored(persisted.profile),
|
|
100
146
|
accessTier: failClosedTier(),
|
|
101
147
|
refreshToken: persisted.refreshToken,
|
|
102
148
|
idToken: persisted.idToken,
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* in logs or error objects (FR-014).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { COMMON_PATTERNS, SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
|
|
9
|
+
|
|
10
|
+
import type { AuthMessageKey } from './messageKeys';
|
|
11
|
+
|
|
8
12
|
export type AuthErrorCode =
|
|
9
13
|
| 'configuration-error'
|
|
10
14
|
| 'provider-cancelled'
|
|
@@ -12,53 +16,63 @@ export type AuthErrorCode =
|
|
|
12
16
|
| 'token-acquisition-failed'
|
|
13
17
|
| 'session-expired'
|
|
14
18
|
| 'consent-denied'
|
|
15
|
-
| 'entitlement-unverified'
|
|
16
19
|
| 'network-unavailable';
|
|
17
20
|
|
|
18
21
|
export type AuthErrorAudience = 'user' | 'developer';
|
|
19
22
|
|
|
20
23
|
export interface AuthErrorArgs {
|
|
21
24
|
code: AuthErrorCode;
|
|
22
|
-
|
|
25
|
+
/** A key of this package's catalogue: the copy an app shows for this error. */
|
|
26
|
+
messageKey: AuthMessageKey;
|
|
23
27
|
audience?: AuthErrorAudience;
|
|
24
|
-
detail?: string;
|
|
28
|
+
detail?: string | undefined;
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
/** JWT-shaped values: three base64url segments separated by dots. */
|
|
32
|
+
const JWT_PATTERN = /[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g;
|
|
33
|
+
/** Any other long opaque token-like run; shorter than rn-core's payload run on purpose. */
|
|
34
|
+
const TOKEN_PATTERN = /[A-Za-z0-9_-]{24,}/g;
|
|
35
|
+
|
|
27
36
|
/**
|
|
28
|
-
* Replace token-like substrings
|
|
29
|
-
*
|
|
37
|
+
* Replace token-like substrings with a placeholder so diagnostics never leak
|
|
38
|
+
* secrets or token values (FR-014): JWTs become `[redacted-token]`; URLs, e-mail
|
|
39
|
+
* addresses, UUIDs and other opaque runs become `[redacted]` (rn-core's patterns).
|
|
30
40
|
*/
|
|
31
41
|
export function redact(value: string | undefined): string | undefined {
|
|
32
42
|
if (!value) {
|
|
33
43
|
return value;
|
|
34
44
|
}
|
|
35
|
-
return (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// Any other long opaque token-like run.
|
|
40
|
-
.replace(/[A-Za-z0-9_-]{24,}/g, '[redacted]')
|
|
41
|
-
);
|
|
45
|
+
return redactPatterns(value.replace(JWT_PATTERN, '[redacted-token]'), [
|
|
46
|
+
...COMMON_PATTERNS,
|
|
47
|
+
TOKEN_PATTERN,
|
|
48
|
+
]);
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
/** Retrying can only help when the network was the problem. */
|
|
52
|
+
const RETRYABLE: ReadonlySet<AuthErrorCode> = new Set<AuthErrorCode>(['network-unavailable']);
|
|
53
|
+
|
|
54
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-auth'`. */
|
|
55
|
+
export class AuthError extends SdkError<AuthErrorCode> {
|
|
46
56
|
readonly audience: AuthErrorAudience;
|
|
47
|
-
readonly messageKey:
|
|
48
|
-
readonly detail?: string;
|
|
57
|
+
readonly messageKey: AuthMessageKey;
|
|
58
|
+
readonly detail?: string | undefined;
|
|
49
59
|
|
|
50
60
|
constructor(args: AuthErrorArgs) {
|
|
51
|
-
|
|
61
|
+
const detail = redact(args.detail);
|
|
62
|
+
super('rn-auth', args.code, {
|
|
63
|
+
message: args.messageKey,
|
|
64
|
+
retryable: RETRYABLE.has(args.code),
|
|
65
|
+
redactedCause: detail,
|
|
66
|
+
});
|
|
52
67
|
this.name = 'AuthError';
|
|
53
|
-
this.code = args.code;
|
|
54
68
|
this.audience = args.audience ?? 'developer';
|
|
55
69
|
this.messageKey = args.messageKey;
|
|
56
|
-
this.detail =
|
|
70
|
+
this.detail = detail;
|
|
57
71
|
}
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
export class AuthConfigError extends AuthError {
|
|
61
|
-
constructor(messageKey:
|
|
75
|
+
constructor(messageKey: AuthMessageKey, detail?: string) {
|
|
62
76
|
super({ code: 'configuration-error', messageKey, audience: 'developer', detail });
|
|
63
77
|
this.name = 'AuthConfigError';
|
|
64
78
|
}
|
|
@@ -72,7 +86,7 @@ export function isAuthError(value: unknown): value is AuthError {
|
|
|
72
86
|
export function toAuthError(
|
|
73
87
|
value: unknown,
|
|
74
88
|
fallbackCode: AuthErrorCode = 'token-acquisition-failed',
|
|
75
|
-
fallbackMessageKey = 'auth.error.token',
|
|
89
|
+
fallbackMessageKey: AuthMessageKey = 'auth.error.token',
|
|
76
90
|
): AuthError {
|
|
77
91
|
if (isAuthError(value)) {
|
|
78
92
|
return value;
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* developer/tenant-configuration copy are kept separate (UX-005).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { defineMessages } from '@chipmobilesdk/rn-core';
|
|
9
|
+
|
|
8
10
|
export interface AuthMessageMap {
|
|
9
11
|
// User-facing actions and states
|
|
10
12
|
'auth.action.continueGoogle': string;
|
|
@@ -32,28 +34,39 @@ export interface AuthMessageMap {
|
|
|
32
34
|
|
|
33
35
|
export type AuthMessageKey = keyof AuthMessageMap;
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The catalog, as an rn-core message set: `user` copy may reach an end user,
|
|
39
|
+
* `developer` copy describes a tenant or configuration fault (UX-005). Register
|
|
40
|
+
* `authMessages.user` with rn-i18n to translate it like app copy.
|
|
41
|
+
*/
|
|
42
|
+
export const authMessages = defineMessages({
|
|
43
|
+
user: {
|
|
44
|
+
'auth.action.continueGoogle': 'Continue with Google',
|
|
45
|
+
'auth.action.continueApple': 'Continue with Apple',
|
|
46
|
+
'auth.action.continueFacebook': 'Continue with Facebook',
|
|
47
|
+
'auth.state.signedOut': 'Sign in to continue',
|
|
48
|
+
'auth.state.authenticating': 'Signing you in…',
|
|
49
|
+
'auth.state.signedIn': 'You are signed in',
|
|
50
|
+
'auth.state.signingOut': 'Signing out…',
|
|
51
|
+
'auth.tier.free': 'Free',
|
|
52
|
+
'auth.tier.premium': 'Premium',
|
|
53
|
+
'auth.tier.unverified': 'Membership could not be verified',
|
|
54
|
+
'auth.error.cancelled': 'Sign-in was cancelled. You can try again.',
|
|
55
|
+
'auth.error.sessionExpired': 'Your session expired. Please sign in again.',
|
|
56
|
+
'auth.error.entitlement': 'This feature requires a verified premium membership.',
|
|
57
|
+
'auth.error.network': 'No connection. Some features are limited until you reconnect.',
|
|
58
|
+
},
|
|
59
|
+
developer: {
|
|
60
|
+
'auth.error.configuration': 'Authentication is not configured correctly.',
|
|
61
|
+
'auth.error.redirectMismatch': 'The sign-in redirect does not match the app configuration.',
|
|
62
|
+
'auth.error.token': 'We could not complete the request. Please try again.',
|
|
63
|
+
'auth.error.consentDenied': 'Access to this resource was denied.',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const DEFAULT_MESSAGES: AuthMessageMap = authMessages.defaults;
|
|
55
68
|
|
|
56
69
|
/** Resolve a message, preferring app overrides over the English defaults. */
|
|
57
70
|
export function resolveMessages(overrides?: Partial<AuthMessageMap>): AuthMessageMap {
|
|
58
|
-
return
|
|
71
|
+
return authMessages.resolve(overrides);
|
|
59
72
|
}
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type {
|
|
|
25
25
|
ProviderIdentity,
|
|
26
26
|
ProviderConfig,
|
|
27
27
|
ProviderId,
|
|
28
|
+
KnownProviderId,
|
|
28
29
|
DevicePlatform,
|
|
29
30
|
ResourceConfig,
|
|
30
31
|
ClaimMatcher,
|
|
@@ -37,11 +38,16 @@ export type {
|
|
|
37
38
|
} from './config/types';
|
|
38
39
|
|
|
39
40
|
// Ports + default adapters
|
|
41
|
+
export { describeProviderIdentity, mapClaimsToProfile } from './core/profile';
|
|
42
|
+
|
|
43
|
+
export type { AccessTokenRequestOptions } from './tokens/accessToken';
|
|
40
44
|
export type { SecureTokenStore } from './tokens/secureStore';
|
|
41
45
|
export { createKeychainSecureStore, AUTH_STORAGE_KEY } from './tokens/secureStore';
|
|
42
46
|
export type {
|
|
43
47
|
OidcAuthClient,
|
|
44
48
|
OidcTokens,
|
|
49
|
+
OidcServiceConfiguration,
|
|
50
|
+
AppAuthClientOptions,
|
|
45
51
|
AuthorizeParams,
|
|
46
52
|
RefreshParams,
|
|
47
53
|
EndSessionParams,
|
|
@@ -54,14 +60,16 @@ export { evaluateAccessTier, failClosedTier } from './access/accessTier';
|
|
|
54
60
|
// Diagnostics
|
|
55
61
|
export { AuthError, AuthConfigError, isAuthError, redact } from './diagnostics/errors';
|
|
56
62
|
export type { AuthErrorCode, AuthErrorAudience } from './diagnostics/errors';
|
|
57
|
-
export { DEFAULT_MESSAGES, resolveMessages } from './diagnostics/messageKeys';
|
|
63
|
+
export { DEFAULT_MESSAGES, authMessages, resolveMessages } from './diagnostics/messageKeys';
|
|
58
64
|
export type { AuthMessageMap, AuthMessageKey } from './diagnostics/messageKeys';
|
|
59
65
|
|
|
60
66
|
// React bindings
|
|
61
67
|
export { AuthProvider } from './react/AuthProvider';
|
|
68
|
+
export { useAuthSelector } from './react/hooks';
|
|
62
69
|
export type { AuthProviderProps } from './react/AuthProvider';
|
|
63
70
|
export {
|
|
64
71
|
useAuth,
|
|
72
|
+
useAuthMessages,
|
|
65
73
|
useProfile,
|
|
66
74
|
useAccessTier,
|
|
67
75
|
useAccessToken,
|
package/src/keychain.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@chipmobilesdk/rn-auth/keychain`: the default secure token store over
|
|
3
|
+
* react-native-keychain (AUTH-09). Its own entrypoint, so an app with its own
|
|
4
|
+
* secure storage never reaches this module.
|
|
5
|
+
*/
|
|
6
|
+
export { createKeychainSecureStore, type KeychainSecureStoreOptions } from './tokens/secureStore';
|