@chipmobilesdk/rn-auth 0.2.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.
@@ -0,0 +1,249 @@
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
+
7
+ import { Platform } from 'react-native';
8
+ import type {
9
+ Auth,
10
+ AuthConfig,
11
+ AuthState,
12
+ CreateAuthOptions,
13
+ DevicePlatform,
14
+ PremiumGateResult,
15
+ Profile,
16
+ ProviderId,
17
+ } from '../config/types';
18
+ import { validateConfig } from '../config/validateConfig';
19
+ import { createAppAuthClient } from '../oidc/authClient';
20
+ import {
21
+ availableProvidersForPlatform,
22
+ buildAdditionalParameters,
23
+ defaultProviderForPlatform,
24
+ resolveProvider,
25
+ } from '../oidc/socialRouting';
26
+ import { mapClaimsToProfile } from './profile';
27
+ import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
28
+ import { restoreSession } from './session';
29
+ import { AUTH_STORAGE_KEY } from '../tokens/secureStore';
30
+ import { createAccessTokenManager } from '../tokens/accessToken';
31
+ import { toAuthError } from '../diagnostics/errors';
32
+
33
+ export function createAuthMachine(
34
+ config: AuthConfig,
35
+ options: CreateAuthOptions = {},
36
+ ): Auth {
37
+ // Throws AuthConfigError before anything else (FR-005, US4 scenario 2).
38
+ validateConfig(config);
39
+
40
+ const platform: DevicePlatform =
41
+ options.platform ?? ((Platform?.OS as DevicePlatform) ?? 'ios');
42
+ const secureStore = config.secureStore;
43
+ const authClient = config.authClient ?? createAppAuthClient();
44
+ const resources = config.resources ?? [];
45
+
46
+ let state: AuthState = { status: 'loading', restore: 'pending' };
47
+ let refreshToken: string | null = null;
48
+ const listeners = new Set<(state: AuthState) => void>();
49
+
50
+ const tokenManager = createAccessTokenManager({
51
+ authority: config.authority,
52
+ clientId: config.clientId,
53
+ redirectUrl: config.redirectUrl,
54
+ resources,
55
+ authClient,
56
+ getRefreshToken: () => refreshToken,
57
+ setRefreshToken: token => {
58
+ refreshToken = token;
59
+ },
60
+ });
61
+
62
+ function getState(): AuthState {
63
+ return { ...state };
64
+ }
65
+
66
+ function setState(patch: Partial<AuthState>): void {
67
+ state = { ...state, ...patch };
68
+ for (const listener of listeners) {
69
+ listener(getState());
70
+ }
71
+ }
72
+
73
+ async function persist(profile: Profile): Promise<void> {
74
+ if (!refreshToken) {
75
+ return;
76
+ }
77
+ await secureStore.setItem(
78
+ AUTH_STORAGE_KEY,
79
+ JSON.stringify({ refreshToken, profile }),
80
+ );
81
+ }
82
+
83
+ async function clearPersisted(): Promise<void> {
84
+ refreshToken = null;
85
+ tokenManager.clear();
86
+ await secureStore.removeItem(AUTH_STORAGE_KEY);
87
+ }
88
+
89
+ return {
90
+ getState,
91
+
92
+ subscribe(listener) {
93
+ listeners.add(listener);
94
+ return () => {
95
+ listeners.delete(listener);
96
+ };
97
+ },
98
+
99
+ async restore() {
100
+ setState({ status: 'loading', restore: 'pending', error: undefined });
101
+ const result = await restoreSession({
102
+ secureStore,
103
+ authority: config.authority,
104
+ clientId: config.clientId,
105
+ redirectUrl: config.redirectUrl,
106
+ scopes: config.scopes,
107
+ authClient,
108
+ mapping: config.accessTierMapping,
109
+ });
110
+
111
+ if (result.outcome === 'restored' && result.profile) {
112
+ refreshToken = result.refreshToken ?? null;
113
+ await persist(result.profile);
114
+ setState({
115
+ status: 'authenticated',
116
+ profile: result.profile,
117
+ accessTier: result.accessTier,
118
+ restore: 'restored',
119
+ error: undefined,
120
+ });
121
+ } else if (result.outcome === 'offline' && result.profile) {
122
+ refreshToken = result.refreshToken ?? null;
123
+ setState({
124
+ status: 'authenticated',
125
+ profile: result.profile,
126
+ accessTier: result.accessTier ?? failClosedTier(),
127
+ restore: 'offline',
128
+ error: undefined,
129
+ });
130
+ } else {
131
+ await clearPersisted();
132
+ setState({
133
+ status: 'signed-out',
134
+ profile: undefined,
135
+ accessTier: undefined,
136
+ restore: 'unavailable',
137
+ error: undefined,
138
+ });
139
+ }
140
+ return getState();
141
+ },
142
+
143
+ async signIn(providerId: ProviderId) {
144
+ setState({ status: 'authenticating', error: undefined });
145
+ let provider;
146
+ try {
147
+ provider = resolveProvider(config, providerId, platform);
148
+ } catch (error) {
149
+ setState({ status: 'error', error: toAuthError(error, 'configuration-error', 'auth.error.configuration') });
150
+ return getState();
151
+ }
152
+
153
+ try {
154
+ const tokens = await authClient.authorize({
155
+ authority: config.authority,
156
+ clientId: config.clientId,
157
+ redirectUrl: config.redirectUrl,
158
+ scopes: config.scopes,
159
+ additionalParameters: buildAdditionalParameters(provider),
160
+ });
161
+ // Single active account: a new sign-in replaces any prior session.
162
+ refreshToken = tokens.refreshToken;
163
+ tokenManager.clear();
164
+ const profile = mapClaimsToProfile(tokens.claims, providerId);
165
+ const accessTier = evaluateAccessTier(tokens.claims, config.accessTierMapping);
166
+ await persist(profile);
167
+ setState({
168
+ status: 'authenticated',
169
+ profile,
170
+ accessTier,
171
+ restore: 'restored',
172
+ error: undefined,
173
+ });
174
+ } catch (error) {
175
+ const authError = toAuthError(error);
176
+ if (authError.code === 'provider-cancelled') {
177
+ setState({ status: 'cancelled', error: authError });
178
+ } else {
179
+ setState({ status: 'error', error: authError });
180
+ }
181
+ }
182
+ return getState();
183
+ },
184
+
185
+ async signOut() {
186
+ await clearPersisted();
187
+ setState({
188
+ status: 'signed-out',
189
+ profile: undefined,
190
+ accessTier: undefined,
191
+ restore: 'unavailable',
192
+ error: undefined,
193
+ });
194
+ },
195
+
196
+ async signOutGlobal() {
197
+ try {
198
+ await authClient.endSession({
199
+ authority: config.authority,
200
+ clientId: config.clientId,
201
+ redirectUrl: config.redirectUrl,
202
+ });
203
+ } catch {
204
+ // End-session failure must not block clearing local state.
205
+ }
206
+ await clearPersisted();
207
+ setState({
208
+ status: 'signed-out',
209
+ profile: undefined,
210
+ accessTier: undefined,
211
+ restore: 'unavailable',
212
+ error: undefined,
213
+ });
214
+ },
215
+
216
+ async requestAccessToken(resourceKey) {
217
+ const wasAuthenticated = state.status === 'authenticated';
218
+ if (wasAuthenticated) {
219
+ setState({ status: 'token-refreshing' });
220
+ }
221
+ const result = await tokenManager.request(resourceKey);
222
+ if (wasAuthenticated) {
223
+ setState({ status: 'authenticated' });
224
+ }
225
+ return result;
226
+ },
227
+
228
+ getAccessTier() {
229
+ return state.accessTier ?? failClosedTier();
230
+ },
231
+
232
+ requirePremium(): PremiumGateResult {
233
+ const tier = state.accessTier ?? failClosedTier();
234
+ const allowed = tier.tier === 'premium' && tier.verification === 'verified';
235
+ if (!allowed) {
236
+ setState({ status: 'access-denied' });
237
+ }
238
+ return { allowed, tier };
239
+ },
240
+
241
+ getPlatformProvider() {
242
+ return defaultProviderForPlatform(config, platform);
243
+ },
244
+
245
+ getPlatformProviders() {
246
+ return availableProvidersForPlatform(config, platform);
247
+ },
248
+ };
249
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Claims → Profile mapping (FR-006). Tolerant of Apple private-relay emails and
3
+ * missing display name / given name (edge cases).
4
+ */
5
+
6
+ import type { Profile, ProviderId, ProviderIdentity } from '../config/types';
7
+
8
+ function asString(value: unknown): string | undefined {
9
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
10
+ }
11
+
12
+ function looksLikeEmail(value: string | undefined): boolean {
13
+ return !!value && /.+@.+\..+/.test(value);
14
+ }
15
+
16
+ /** Map id-token claims to the app-visible Profile. */
17
+ export function mapClaimsToProfile(
18
+ claims: Record<string, unknown>,
19
+ providerId?: ProviderId,
20
+ ): Profile {
21
+ const tenantUserId =
22
+ asString(claims.oid) ?? asString(claims.sub) ?? asString(claims.unique_name) ?? 'unknown';
23
+
24
+ const givenName = asString(claims.given_name);
25
+ const familyName = asString(claims.family_name);
26
+ const composedName = [givenName, familyName].filter(Boolean).join(' ') || undefined;
27
+ const displayName = asString(claims.name) ?? composedName;
28
+
29
+ const preferred = asString(claims.preferred_username);
30
+ const email =
31
+ asString(claims.email) ?? (looksLikeEmail(preferred) ? preferred : undefined);
32
+
33
+ const provider =
34
+ providerId ?? asString(claims.idp) ?? asString(claims.identityProvider) ?? 'unknown';
35
+
36
+ return {
37
+ tenantUserId,
38
+ displayName,
39
+ email,
40
+ provider,
41
+ claims,
42
+ };
43
+ }
44
+
45
+ /** Describe the external provider identity behind the profile (FR-004 edge cases). */
46
+ export function describeProviderIdentity(
47
+ claims: Record<string, unknown>,
48
+ providerId?: ProviderId,
49
+ ): ProviderIdentity {
50
+ const provider = providerId ?? asString(claims.idp) ?? 'unknown';
51
+ const subject = asString(claims.sub) ?? asString(claims.oid) ?? 'unknown';
52
+ const email = asString(claims.email);
53
+ let emailBehavior: ProviderIdentity['emailBehavior'] = 'none';
54
+ if (email) {
55
+ emailBehavior = email.endsWith('@privaterelay.appleid.com')
56
+ ? 'private-relay'
57
+ : 'direct';
58
+ }
59
+ return { provider, subject, emailBehavior };
60
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Session restoration (FR-008, R5). Reads the persisted artifact, attempts a
3
+ * silent refresh, and classifies the outcome:
4
+ * - restored: fresh tokens + claims → authenticated
5
+ * - offline: network unavailable → cached profile read-only, tier unverified
6
+ * (fail closed) — clarify Q3
7
+ * - unavailable: no artifact or invalid/expired refresh → signed-out reauth
8
+ */
9
+
10
+ import type {
11
+ AccessTier,
12
+ AccessTierMapping,
13
+ Profile,
14
+ RestoreStatus,
15
+ } from '../config/types';
16
+ import type { OidcAuthClient } from '../oidc/authClient';
17
+ import type { SecureTokenStore } from '../tokens/secureStore';
18
+ import { AUTH_STORAGE_KEY } from '../tokens/secureStore';
19
+ import { mapClaimsToProfile } from './profile';
20
+ import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
21
+ import { isAuthError } from '../diagnostics/errors';
22
+
23
+ export interface PersistedSession {
24
+ refreshToken: string;
25
+ profile: Profile;
26
+ }
27
+
28
+ export interface RestoreDeps {
29
+ secureStore: SecureTokenStore;
30
+ authority: string;
31
+ clientId: string;
32
+ redirectUrl: string;
33
+ scopes: string[];
34
+ authClient: OidcAuthClient;
35
+ mapping: AccessTierMapping;
36
+ }
37
+
38
+ export interface RestoreResult {
39
+ outcome: RestoreStatus;
40
+ profile?: Profile;
41
+ accessTier?: AccessTier;
42
+ refreshToken?: string;
43
+ }
44
+
45
+ export function parsePersistedSession(raw: string | null): PersistedSession | null {
46
+ if (!raw) {
47
+ return null;
48
+ }
49
+ try {
50
+ const parsed = JSON.parse(raw) as PersistedSession;
51
+ if (parsed && typeof parsed.refreshToken === 'string' && parsed.profile) {
52
+ return parsed;
53
+ }
54
+ return null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ export async function restoreSession(deps: RestoreDeps): Promise<RestoreResult> {
61
+ const raw = await deps.secureStore.getItem(AUTH_STORAGE_KEY);
62
+ const persisted = parsePersistedSession(raw);
63
+ if (!persisted) {
64
+ return { outcome: 'unavailable' };
65
+ }
66
+
67
+ try {
68
+ const tokens = await deps.authClient.refresh({
69
+ authority: deps.authority,
70
+ clientId: deps.clientId,
71
+ redirectUrl: deps.redirectUrl,
72
+ refreshToken: persisted.refreshToken,
73
+ scopes: deps.scopes,
74
+ });
75
+ const profile = mapClaimsToProfile(tokens.claims);
76
+ const accessTier = evaluateAccessTier(tokens.claims, deps.mapping);
77
+ return {
78
+ outcome: 'restored',
79
+ profile,
80
+ accessTier,
81
+ refreshToken: tokens.refreshToken || persisted.refreshToken,
82
+ };
83
+ } catch (error) {
84
+ // Offline: keep the cached identity read-only, fail closed on entitlement.
85
+ if (isAuthError(error) && error.code === 'network-unavailable') {
86
+ return {
87
+ outcome: 'offline',
88
+ profile: persisted.profile,
89
+ accessTier: failClosedTier(),
90
+ refreshToken: persisted.refreshToken,
91
+ };
92
+ }
93
+ // Invalid/expired refresh → require reauthentication.
94
+ return { outcome: 'unavailable' };
95
+ }
96
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Developer-facing diagnostics. AuthError carries a stable machine-readable
3
+ * code, an audience (user vs developer/tenant), and a localizable message key.
4
+ * `detail` is always passed through `redact` so tokens and secrets never appear
5
+ * in logs or error objects (FR-014).
6
+ */
7
+
8
+ export type AuthErrorCode =
9
+ | 'configuration-error'
10
+ | 'provider-cancelled'
11
+ | 'redirect-mismatch'
12
+ | 'token-acquisition-failed'
13
+ | 'session-expired'
14
+ | 'consent-denied'
15
+ | 'entitlement-unverified'
16
+ | 'network-unavailable';
17
+
18
+ export type AuthErrorAudience = 'user' | 'developer';
19
+
20
+ export interface AuthErrorArgs {
21
+ code: AuthErrorCode;
22
+ messageKey: string;
23
+ audience?: AuthErrorAudience;
24
+ detail?: string;
25
+ }
26
+
27
+ /**
28
+ * Replace token-like substrings (long URL-safe base64 / JWT segments) with a
29
+ * placeholder so diagnostics never leak secrets or token values (FR-014).
30
+ */
31
+ export function redact(value: string | undefined): string | undefined {
32
+ if (!value) {
33
+ return value;
34
+ }
35
+ return value
36
+ // JWT-shaped values: three base64url segments separated by dots.
37
+ .replace(/[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted-token]')
38
+ // Any other long opaque token-like run.
39
+ .replace(/[A-Za-z0-9_-]{24,}/g, '[redacted]');
40
+ }
41
+
42
+ export class AuthError extends Error {
43
+ readonly code: AuthErrorCode;
44
+ readonly audience: AuthErrorAudience;
45
+ readonly messageKey: string;
46
+ readonly detail?: string;
47
+
48
+ constructor(args: AuthErrorArgs) {
49
+ super(args.messageKey);
50
+ this.name = 'AuthError';
51
+ this.code = args.code;
52
+ this.audience = args.audience ?? 'developer';
53
+ this.messageKey = args.messageKey;
54
+ this.detail = redact(args.detail);
55
+ }
56
+ }
57
+
58
+ export class AuthConfigError extends AuthError {
59
+ constructor(messageKey: string, detail?: string) {
60
+ super({ code: 'configuration-error', messageKey, audience: 'developer', detail });
61
+ this.name = 'AuthConfigError';
62
+ }
63
+ }
64
+
65
+ export function isAuthError(value: unknown): value is AuthError {
66
+ return value instanceof AuthError;
67
+ }
68
+
69
+ /** Normalize any thrown value into an AuthError without leaking secrets. */
70
+ export function toAuthError(
71
+ value: unknown,
72
+ fallbackCode: AuthErrorCode = 'token-acquisition-failed',
73
+ fallbackMessageKey = 'auth.error.token',
74
+ ): AuthError {
75
+ if (isAuthError(value)) {
76
+ return value;
77
+ }
78
+ const message = value instanceof Error ? value.message : String(value);
79
+ return new AuthError({
80
+ code: fallbackCode,
81
+ messageKey: fallbackMessageKey,
82
+ audience: 'developer',
83
+ detail: message,
84
+ });
85
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Stable, localizable message keys with English default copy (UX-006 / LIB-011).
3
+ * The library takes no i18n runtime dependency: consuming apps map these keys to
4
+ * localized strings via their own i18n system. User-actionable copy and
5
+ * developer/tenant-configuration copy are kept separate (UX-005).
6
+ */
7
+
8
+ export interface AuthMessageMap {
9
+ // User-facing actions and states
10
+ 'auth.action.continueGoogle': string;
11
+ 'auth.action.continueApple': string;
12
+ 'auth.action.continueFacebook': string;
13
+ 'auth.state.signedOut': string;
14
+ 'auth.state.authenticating': string;
15
+ 'auth.state.signedIn': string;
16
+ 'auth.state.signingOut': string;
17
+ // Access tier (user-facing)
18
+ 'auth.tier.free': string;
19
+ 'auth.tier.premium': string;
20
+ 'auth.tier.unverified': string;
21
+ // User-actionable errors
22
+ 'auth.error.cancelled': string;
23
+ 'auth.error.sessionExpired': string;
24
+ 'auth.error.entitlement': string;
25
+ 'auth.error.network': string;
26
+ // Developer / tenant-configuration errors
27
+ 'auth.error.configuration': string;
28
+ 'auth.error.redirectMismatch': string;
29
+ 'auth.error.token': string;
30
+ 'auth.error.consentDenied': string;
31
+ }
32
+
33
+ export type AuthMessageKey = keyof AuthMessageMap;
34
+
35
+ export const DEFAULT_MESSAGES: AuthMessageMap = {
36
+ 'auth.action.continueGoogle': 'Continue with Google',
37
+ 'auth.action.continueApple': 'Continue with Apple',
38
+ 'auth.action.continueFacebook': 'Continue with Facebook',
39
+ 'auth.state.signedOut': 'Sign in to continue',
40
+ 'auth.state.authenticating': 'Signing you in…',
41
+ 'auth.state.signedIn': 'You are signed in',
42
+ 'auth.state.signingOut': 'Signing out…',
43
+ 'auth.tier.free': 'Free',
44
+ 'auth.tier.premium': 'Premium',
45
+ 'auth.tier.unverified': 'Membership could not be verified',
46
+ 'auth.error.cancelled': 'Sign-in was cancelled. You can try again.',
47
+ 'auth.error.sessionExpired': 'Your session expired. Please sign in again.',
48
+ 'auth.error.entitlement': 'This feature requires a verified premium membership.',
49
+ 'auth.error.network': 'No connection. Some features are limited until you reconnect.',
50
+ 'auth.error.configuration': 'Authentication is not configured correctly.',
51
+ 'auth.error.redirectMismatch': 'The sign-in redirect does not match the app configuration.',
52
+ 'auth.error.token': 'We could not complete the request. Please try again.',
53
+ 'auth.error.consentDenied': 'Access to this resource was denied.',
54
+ };
55
+
56
+ /** Resolve a message, preferring app overrides over the English defaults. */
57
+ export function resolveMessages(
58
+ overrides?: Partial<AuthMessageMap>,
59
+ ): AuthMessageMap {
60
+ return { ...DEFAULT_MESSAGES, ...(overrides ?? {}) };
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @chipmobilesdk/rn-auth — reusable Microsoft Entra External ID authentication
3
+ * for ChipMobileSdk-family React Native apps (public OIDC/PKCE client, no
4
+ * backend). This is the single public entrypoint (LIB-003); modules under
5
+ * `core/`, `tokens/`, `oidc/`, `access/`, and `diagnostics/` are private.
6
+ */
7
+
8
+ import { createAuthMachine } from './core/authMachine';
9
+ import type { Auth, AuthConfig, CreateAuthOptions } from './config/types';
10
+
11
+ /** Create the auth controller. Throws AuthConfigError on invalid config (FR-005). */
12
+ export function createAuth(config: AuthConfig, options?: CreateAuthOptions): Auth {
13
+ return createAuthMachine(config, options);
14
+ }
15
+
16
+ // Public types
17
+ export type {
18
+ Auth,
19
+ AuthConfig,
20
+ CreateAuthOptions,
21
+ AuthState,
22
+ AuthStatus,
23
+ RestoreStatus,
24
+ Profile,
25
+ ProviderIdentity,
26
+ ProviderConfig,
27
+ ProviderId,
28
+ DevicePlatform,
29
+ ResourceConfig,
30
+ ClaimMatcher,
31
+ AccessTierMapping,
32
+ AccessTier,
33
+ AccessTokenResult,
34
+ AccessTokenStatus,
35
+ PremiumGateResult,
36
+ } from './config/types';
37
+
38
+ // Ports + default adapters
39
+ export type { SecureTokenStore } from './tokens/secureStore';
40
+ export { createKeychainSecureStore, AUTH_STORAGE_KEY } from './tokens/secureStore';
41
+ export type {
42
+ OidcAuthClient,
43
+ OidcTokens,
44
+ AuthorizeParams,
45
+ RefreshParams,
46
+ EndSessionParams,
47
+ } from './oidc/authClient';
48
+ export { createAppAuthClient } from './oidc/authClient';
49
+
50
+ // Access-tier evaluation (also exported for app-side reuse/testing)
51
+ export { evaluateAccessTier, failClosedTier } from './access/accessTier';
52
+
53
+ // Diagnostics
54
+ export {
55
+ AuthError,
56
+ AuthConfigError,
57
+ isAuthError,
58
+ redact,
59
+ } from './diagnostics/errors';
60
+ export type { AuthErrorCode, AuthErrorAudience } from './diagnostics/errors';
61
+ export {
62
+ DEFAULT_MESSAGES,
63
+ resolveMessages,
64
+ } from './diagnostics/messageKeys';
65
+ export type { AuthMessageMap, AuthMessageKey } from './diagnostics/messageKeys';
66
+
67
+ // React bindings
68
+ export { AuthProvider } from './react/AuthProvider';
69
+ export type { AuthProviderProps } from './react/AuthProvider';
70
+ export {
71
+ useAuth,
72
+ useProfile,
73
+ useAccessTier,
74
+ useAccessToken,
75
+ useSignInProvider,
76
+ useSignInProviders,
77
+ } from './react/hooks';
78
+ export type {
79
+ UseAuthResult,
80
+ UseAccessTokenResult,
81
+ UseAccessTokenStatus,
82
+ } from './react/hooks';