@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.
- package/CHANGELOG.md +71 -0
- package/README.md +177 -0
- package/package.json +53 -0
- package/src/access/accessTier.ts +88 -0
- package/src/config/types.ts +143 -0
- package/src/config/validateConfig.ts +108 -0
- package/src/core/authMachine.ts +249 -0
- package/src/core/profile.ts +60 -0
- package/src/core/session.ts +96 -0
- package/src/diagnostics/errors.ts +85 -0
- package/src/diagnostics/messageKeys.ts +61 -0
- package/src/index.ts +82 -0
- package/src/oidc/authClient.ts +224 -0
- package/src/oidc/socialRouting.ts +73 -0
- package/src/react/AuthProvider.tsx +47 -0
- package/src/react/hooks.ts +90 -0
- package/src/tokens/accessToken.ts +105 -0
- package/src/tokens/secureStore.ts +52 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OidcAuthClient port + default react-native-app-auth adapter (R1).
|
|
3
|
+
*
|
|
4
|
+
* The port abstracts the Authorization Code + PKCE public-client flow so the
|
|
5
|
+
* auth engine is testable without native code. The default adapter lazily
|
|
6
|
+
* requires react-native-app-auth and is validated on device.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AuthError, type AuthErrorCode } from '../diagnostics/errors';
|
|
10
|
+
|
|
11
|
+
export interface OidcTokens {
|
|
12
|
+
/** Resource/audience access token (present for authorize and resource refresh). */
|
|
13
|
+
accessToken?: string;
|
|
14
|
+
/** Refresh token used for silent acquisition and restore. */
|
|
15
|
+
refreshToken: string;
|
|
16
|
+
/** Raw id token (JWT). */
|
|
17
|
+
idToken: string;
|
|
18
|
+
/** Decoded id-token (or access-token) claims used for profile + access tier. */
|
|
19
|
+
claims: Record<string, unknown>;
|
|
20
|
+
/** Access-token expiry in epoch milliseconds, when available. */
|
|
21
|
+
accessTokenExpiresAt?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AuthorizeParams {
|
|
25
|
+
authority: string;
|
|
26
|
+
clientId: string;
|
|
27
|
+
redirectUrl: string;
|
|
28
|
+
scopes: string[];
|
|
29
|
+
/** Additional OIDC parameters, e.g. `domain_hint` for social routing. */
|
|
30
|
+
additionalParameters?: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RefreshParams {
|
|
34
|
+
authority: string;
|
|
35
|
+
clientId: string;
|
|
36
|
+
redirectUrl: string;
|
|
37
|
+
refreshToken: string;
|
|
38
|
+
scopes: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EndSessionParams {
|
|
42
|
+
authority: string;
|
|
43
|
+
clientId: string;
|
|
44
|
+
redirectUrl: string;
|
|
45
|
+
idToken?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface OidcAuthClient {
|
|
49
|
+
authorize(params: AuthorizeParams): Promise<OidcTokens>;
|
|
50
|
+
refresh(params: RefreshParams): Promise<OidcTokens>;
|
|
51
|
+
endSession(params: EndSessionParams): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Decode the payload segment of a JWT without any dependency (device-safe). */
|
|
55
|
+
export function decodeJwtClaims(jwt: string): Record<string, unknown> {
|
|
56
|
+
const parts = jwt.split('.');
|
|
57
|
+
if (parts.length < 2) {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const json = base64UrlDecode(parts[1]);
|
|
62
|
+
return JSON.parse(json) as Record<string, unknown>;
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const BASE64_CHARS =
|
|
69
|
+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
70
|
+
|
|
71
|
+
function base64UrlDecode(input: string): string {
|
|
72
|
+
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
|
73
|
+
while (base64.length % 4 !== 0) {
|
|
74
|
+
base64 += '=';
|
|
75
|
+
}
|
|
76
|
+
let bytes = '';
|
|
77
|
+
let buffer = 0;
|
|
78
|
+
let bits = 0;
|
|
79
|
+
for (const char of base64) {
|
|
80
|
+
if (char === '=') {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const index = BASE64_CHARS.indexOf(char);
|
|
84
|
+
if (index === -1) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Standard base64 bit assembly; bitwise ops are intentional here.
|
|
88
|
+
// eslint-disable-next-line no-bitwise
|
|
89
|
+
buffer = (buffer << 6) | index;
|
|
90
|
+
bits += 6;
|
|
91
|
+
if (bits >= 8) {
|
|
92
|
+
bits -= 8;
|
|
93
|
+
// eslint-disable-next-line no-bitwise
|
|
94
|
+
bytes += String.fromCharCode((buffer >> bits) & 0xff);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Interpret the byte string (each char is a 0–255 byte) as UTF-8 without
|
|
98
|
+
// relying on the deprecated global `escape`.
|
|
99
|
+
try {
|
|
100
|
+
const percentEncoded = Array.from(bytes)
|
|
101
|
+
.map(char => '%' + char.charCodeAt(0).toString(16).padStart(2, '0'))
|
|
102
|
+
.join('');
|
|
103
|
+
return decodeURIComponent(percentEncoded);
|
|
104
|
+
} catch {
|
|
105
|
+
return bytes;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
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
|
+
|
|
144
|
+
export interface AppAuthClientOptions {
|
|
145
|
+
/** When true, the OIDC discovery document is fetched from `${authority}`. */
|
|
146
|
+
useDiscovery?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Default OidcAuthClient adapter wrapping react-native-app-auth (PKCE public
|
|
151
|
+
* client). Lazily requires the native module. Validated on device (R8).
|
|
152
|
+
*/
|
|
153
|
+
export function createAppAuthClient(
|
|
154
|
+
options: AppAuthClientOptions = {},
|
|
155
|
+
): OidcAuthClient {
|
|
156
|
+
// Lazy require so injecting a fake client never loads native code.
|
|
157
|
+
const AppAuth = require('react-native-app-auth');
|
|
158
|
+
const useDiscovery = options.useDiscovery ?? true;
|
|
159
|
+
|
|
160
|
+
const baseConfig = (
|
|
161
|
+
authority: string,
|
|
162
|
+
clientId: string,
|
|
163
|
+
redirectUrl: string,
|
|
164
|
+
scopes: string[],
|
|
165
|
+
) => ({
|
|
166
|
+
issuer: useDiscovery ? authority : undefined,
|
|
167
|
+
clientId,
|
|
168
|
+
redirectUrl,
|
|
169
|
+
scopes,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const toTokens = (result: {
|
|
173
|
+
accessToken?: string;
|
|
174
|
+
refreshToken?: string;
|
|
175
|
+
idToken: string;
|
|
176
|
+
accessTokenExpirationDate?: string;
|
|
177
|
+
}): OidcTokens => ({
|
|
178
|
+
accessToken: result.accessToken,
|
|
179
|
+
refreshToken: result.refreshToken ?? '',
|
|
180
|
+
idToken: result.idToken,
|
|
181
|
+
claims: decodeJwtClaims(result.idToken),
|
|
182
|
+
accessTokenExpiresAt: result.accessTokenExpirationDate
|
|
183
|
+
? new Date(result.accessTokenExpirationDate).getTime()
|
|
184
|
+
: undefined,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
async authorize(params) {
|
|
189
|
+
try {
|
|
190
|
+
const result = await AppAuth.authorize({
|
|
191
|
+
...baseConfig(params.authority, params.clientId, params.redirectUrl, params.scopes),
|
|
192
|
+
additionalParameters: params.additionalParameters,
|
|
193
|
+
});
|
|
194
|
+
return toTokens(result);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw classifyAppAuthError(error);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
async refresh(params) {
|
|
200
|
+
try {
|
|
201
|
+
const result = await AppAuth.refresh(
|
|
202
|
+
baseConfig(params.authority, params.clientId, params.redirectUrl, params.scopes),
|
|
203
|
+
{ refreshToken: params.refreshToken },
|
|
204
|
+
);
|
|
205
|
+
return toTokens(result);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw classifyAppAuthError(error);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
async endSession(params) {
|
|
211
|
+
try {
|
|
212
|
+
await AppAuth.logout(
|
|
213
|
+
baseConfig(params.authority, params.clientId, params.redirectUrl, []),
|
|
214
|
+
{
|
|
215
|
+
idToken: params.idToken ?? '',
|
|
216
|
+
postLogoutRedirectUrl: params.redirectUrl,
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
throw classifyAppAuthError(error);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social provider routing (FR-002/FR-003, R3). Google and Apple are federated
|
|
3
|
+
* inside the External ID tenant; the app presents the platform-correct action
|
|
4
|
+
* and starts the user flow with a per-provider `domain_hint`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AuthConfig,
|
|
9
|
+
DevicePlatform,
|
|
10
|
+
ProviderConfig,
|
|
11
|
+
ProviderId,
|
|
12
|
+
} from '../config/types';
|
|
13
|
+
import { AuthConfigError } from '../diagnostics/errors';
|
|
14
|
+
|
|
15
|
+
/** Find the configured provider, ensuring it is enabled for the platform. */
|
|
16
|
+
export function resolveProvider(
|
|
17
|
+
config: AuthConfig,
|
|
18
|
+
providerId: ProviderId,
|
|
19
|
+
platform: DevicePlatform,
|
|
20
|
+
): ProviderConfig {
|
|
21
|
+
const provider = config.providers.find(candidate => candidate.id === providerId);
|
|
22
|
+
if (!provider) {
|
|
23
|
+
throw new AuthConfigError(
|
|
24
|
+
'auth.error.configuration',
|
|
25
|
+
`Provider "${providerId}" is not configured`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (!provider.platforms.includes(platform)) {
|
|
29
|
+
throw new AuthConfigError(
|
|
30
|
+
'auth.error.configuration',
|
|
31
|
+
`Provider "${providerId}" is not enabled for platform "${platform}"`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (provider.available === false) {
|
|
35
|
+
throw new AuthConfigError(
|
|
36
|
+
'auth.error.configuration',
|
|
37
|
+
`Provider "${providerId}" is marked unavailable`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return provider;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build the OIDC additional parameters that route to the chosen IdP. */
|
|
44
|
+
export function buildAdditionalParameters(
|
|
45
|
+
provider: ProviderConfig,
|
|
46
|
+
): Record<string, string> {
|
|
47
|
+
return { domain_hint: provider.domainHint };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* All providers to present on a given platform, in configuration array order
|
|
52
|
+
* (FR-003/FR-004). Filtered to providers enabled for the platform and not marked
|
|
53
|
+
* unavailable.
|
|
54
|
+
*/
|
|
55
|
+
export function availableProvidersForPlatform(
|
|
56
|
+
config: AuthConfig,
|
|
57
|
+
platform: DevicePlatform,
|
|
58
|
+
): ProviderConfig[] {
|
|
59
|
+
return config.providers.filter(
|
|
60
|
+
provider => provider.platforms.includes(platform) && provider.available !== false,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The first available provider for a platform (UX-004, back-compat). Equals
|
|
66
|
+
* `availableProvidersForPlatform(config, platform)[0]`.
|
|
67
|
+
*/
|
|
68
|
+
export function defaultProviderForPlatform(
|
|
69
|
+
config: AuthConfig,
|
|
70
|
+
platform: DevicePlatform,
|
|
71
|
+
): ProviderConfig | undefined {
|
|
72
|
+
return availableProvidersForPlatform(config, platform)[0];
|
|
73
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React context boundary for the auth controller (LIB-002). Subscribes to the
|
|
3
|
+
* controller and re-renders consumers on every state change.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, {
|
|
7
|
+
createContext,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import type { Auth, AuthState } from '../config/types';
|
|
14
|
+
|
|
15
|
+
interface AuthContextValue {
|
|
16
|
+
auth: Auth;
|
|
17
|
+
state: AuthState;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
21
|
+
|
|
22
|
+
export interface AuthProviderProps {
|
|
23
|
+
auth: Auth;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function AuthProvider({ auth, children }: AuthProviderProps) {
|
|
28
|
+
const [state, setState] = useState<AuthState>(() => auth.getState());
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
// Sync immediately in case state changed between render and subscribe.
|
|
32
|
+
setState(auth.getState());
|
|
33
|
+
return auth.subscribe(setState);
|
|
34
|
+
}, [auth]);
|
|
35
|
+
|
|
36
|
+
const value = useMemo<AuthContextValue>(() => ({ auth, state }), [auth, state]);
|
|
37
|
+
|
|
38
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useAuthContext(): AuthContextValue {
|
|
42
|
+
const value = useContext(AuthContext);
|
|
43
|
+
if (!value) {
|
|
44
|
+
throw new Error('useAuth must be used within an <AuthProvider>.');
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public hooks (LIB-002). All read from the AuthProvider context so consumers
|
|
3
|
+
* re-render on state changes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useState } from 'react';
|
|
7
|
+
import { useAuthContext } from './AuthProvider';
|
|
8
|
+
import type {
|
|
9
|
+
AccessTier,
|
|
10
|
+
AccessTokenResult,
|
|
11
|
+
AuthState,
|
|
12
|
+
PremiumGateResult,
|
|
13
|
+
Profile,
|
|
14
|
+
ProviderConfig,
|
|
15
|
+
ProviderId,
|
|
16
|
+
} from '../config/types';
|
|
17
|
+
import { failClosedTier } from '../access/accessTier';
|
|
18
|
+
|
|
19
|
+
export interface UseAuthResult {
|
|
20
|
+
state: AuthState;
|
|
21
|
+
signIn(providerId: ProviderId): Promise<AuthState>;
|
|
22
|
+
signOut(): Promise<void>;
|
|
23
|
+
signOutGlobal(): Promise<void>;
|
|
24
|
+
restore(): Promise<AuthState>;
|
|
25
|
+
requirePremium(): PremiumGateResult;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useAuth(): UseAuthResult {
|
|
29
|
+
const { auth, state } = useAuthContext();
|
|
30
|
+
return {
|
|
31
|
+
state,
|
|
32
|
+
signIn: useCallback((providerId: ProviderId) => auth.signIn(providerId), [auth]),
|
|
33
|
+
signOut: useCallback(() => auth.signOut(), [auth]),
|
|
34
|
+
signOutGlobal: useCallback(() => auth.signOutGlobal(), [auth]),
|
|
35
|
+
restore: useCallback(() => auth.restore(), [auth]),
|
|
36
|
+
requirePremium: useCallback(() => auth.requirePremium(), [auth]),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useProfile(): Profile | undefined {
|
|
41
|
+
const { state } = useAuthContext();
|
|
42
|
+
return state.profile;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useAccessTier(): AccessTier {
|
|
46
|
+
const { state } = useAuthContext();
|
|
47
|
+
return state.accessTier ?? failClosedTier();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The first provider to present on the current platform (UX-004), or undefined. */
|
|
51
|
+
export function useSignInProvider(): ProviderConfig | undefined {
|
|
52
|
+
const { auth } = useAuthContext();
|
|
53
|
+
return auth.getPlatformProvider();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** All available providers for the current platform, in configuration order (FR-003/FR-004). */
|
|
57
|
+
export function useSignInProviders(): ProviderConfig[] {
|
|
58
|
+
const { auth } = useAuthContext();
|
|
59
|
+
return auth.getPlatformProviders();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type UseAccessTokenStatus =
|
|
63
|
+
| 'idle'
|
|
64
|
+
| 'loading'
|
|
65
|
+
| 'success'
|
|
66
|
+
| 'denied'
|
|
67
|
+
| 'unavailable'
|
|
68
|
+
| 'error';
|
|
69
|
+
|
|
70
|
+
export interface UseAccessTokenResult {
|
|
71
|
+
status: UseAccessTokenStatus;
|
|
72
|
+
token?: string;
|
|
73
|
+
request(): Promise<AccessTokenResult>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useAccessToken(resourceKey: string): UseAccessTokenResult {
|
|
77
|
+
const { auth } = useAuthContext();
|
|
78
|
+
const [status, setStatus] = useState<UseAccessTokenStatus>('idle');
|
|
79
|
+
const [token, setToken] = useState<string | undefined>(undefined);
|
|
80
|
+
|
|
81
|
+
const request = useCallback(async () => {
|
|
82
|
+
setStatus('loading');
|
|
83
|
+
const result = await auth.requestAccessToken(resourceKey);
|
|
84
|
+
setStatus(result.status);
|
|
85
|
+
setToken(result.token);
|
|
86
|
+
return result;
|
|
87
|
+
}, [auth, resourceKey]);
|
|
88
|
+
|
|
89
|
+
return { status, token, request };
|
|
90
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-resource delegated access-token acquisition with an in-memory cache and
|
|
3
|
+
* silent refresh (FR-007, PERF-003, R4). Access tokens are never persisted to
|
|
4
|
+
* disk (FR-021) — only kept in memory keyed by resource until expiry.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AccessTokenResult,
|
|
9
|
+
ResourceConfig,
|
|
10
|
+
} from '../config/types';
|
|
11
|
+
import type { OidcAuthClient } from '../oidc/authClient';
|
|
12
|
+
import { isAuthError } from '../diagnostics/errors';
|
|
13
|
+
|
|
14
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
interface CachedToken {
|
|
17
|
+
token: string;
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AccessTokenManagerDeps {
|
|
23
|
+
authority: string;
|
|
24
|
+
clientId: string;
|
|
25
|
+
redirectUrl: string;
|
|
26
|
+
resources: ResourceConfig[];
|
|
27
|
+
authClient: OidcAuthClient;
|
|
28
|
+
getRefreshToken: () => string | null;
|
|
29
|
+
setRefreshToken: (token: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AccessTokenManager {
|
|
33
|
+
request(resourceKey: string): Promise<AccessTokenResult>;
|
|
34
|
+
clear(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createAccessTokenManager(
|
|
38
|
+
deps: AccessTokenManagerDeps,
|
|
39
|
+
): AccessTokenManager {
|
|
40
|
+
const cache = new Map<string, CachedToken>();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
clear() {
|
|
44
|
+
cache.clear();
|
|
45
|
+
},
|
|
46
|
+
async request(resourceKey): Promise<AccessTokenResult> {
|
|
47
|
+
const resource = deps.resources.find(entry => entry.key === resourceKey);
|
|
48
|
+
if (!resource) {
|
|
49
|
+
return { resourceKey, status: 'error', errorCode: 'configuration-error' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cached = cache.get(resourceKey);
|
|
53
|
+
if (cached && cached.expiresAt - EXPIRY_SKEW_MS > Date.now()) {
|
|
54
|
+
return {
|
|
55
|
+
resourceKey,
|
|
56
|
+
status: 'success',
|
|
57
|
+
token: cached.token,
|
|
58
|
+
expiresAt: cached.expiresAt,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const refreshToken = deps.getRefreshToken();
|
|
63
|
+
if (!refreshToken) {
|
|
64
|
+
return { resourceKey, status: 'unavailable', errorCode: 'session-expired' };
|
|
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,
|
|
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 };
|
|
100
|
+
}
|
|
101
|
+
return { resourceKey, status: 'error', errorCode: 'token-acquisition-failed' };
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SecureTokenStore port + default react-native-keychain adapter (FR-021, R2).
|
|
3
|
+
*
|
|
4
|
+
* Refresh tokens and the session-restore artifact persist ONLY through this
|
|
5
|
+
* port, backed by platform secure storage (iOS Keychain / Android Keystore).
|
|
6
|
+
* Access tokens are never written here — they stay in memory (see accessToken).
|
|
7
|
+
* The default adapter lazily requires the native module so unit tests that
|
|
8
|
+
* inject a fake never load native code.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Single key under which the persisted session artifact is stored. */
|
|
12
|
+
export const AUTH_STORAGE_KEY = '@chipmobilesdk/rn-auth/session';
|
|
13
|
+
|
|
14
|
+
export interface SecureTokenStore {
|
|
15
|
+
getItem(key: string): Promise<string | null>;
|
|
16
|
+
setItem(key: string, value: string): Promise<void>;
|
|
17
|
+
removeItem(key: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface KeychainSecureStoreOptions {
|
|
21
|
+
/** Optional Keychain access-control accessibility constant. */
|
|
22
|
+
accessible?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default secure store backed by react-native-keychain. Each logical key maps to
|
|
27
|
+
* a Keychain "service" so multiple keys can coexist. Validated on device.
|
|
28
|
+
*/
|
|
29
|
+
export function createKeychainSecureStore(
|
|
30
|
+
options: KeychainSecureStoreOptions = {},
|
|
31
|
+
): SecureTokenStore {
|
|
32
|
+
// Lazy require: only loaded when the default adapter is actually used.
|
|
33
|
+
const Keychain = require('react-native-keychain');
|
|
34
|
+
const accessible =
|
|
35
|
+
options.accessible ?? Keychain.ACCESSIBLE?.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
async getItem(key) {
|
|
39
|
+
const result = await Keychain.getGenericPassword({ service: key });
|
|
40
|
+
return result ? result.password : null;
|
|
41
|
+
},
|
|
42
|
+
async setItem(key, value) {
|
|
43
|
+
await Keychain.setGenericPassword('token', value, {
|
|
44
|
+
service: key,
|
|
45
|
+
accessible,
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
async removeItem(key) {
|
|
49
|
+
await Keychain.resetGenericPassword({ service: key });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|