@chipmobilesdk/rn-auth 0.2.1 → 0.3.1

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,105 +1,159 @@
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
- }
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 { AccessTokenResult, ResourceConfig } from '../config/types';
8
+ import type { OidcAuthClient } from '../oidc/authClient';
9
+ import { isAuthError } from '../diagnostics/errors';
10
+
11
+ const EXPIRY_SKEW_MS = 60_000;
12
+
13
+ interface CachedToken {
14
+ token: string;
15
+ expiresAt: number;
16
+ scopes: string[];
17
+ }
18
+
19
+ export interface AccessTokenManagerDeps {
20
+ authority: string;
21
+ clientId: string;
22
+ redirectUrl: string;
23
+ resources: ResourceConfig[];
24
+ authClient: OidcAuthClient;
25
+ getRefreshToken: () => string | null;
26
+ /**
27
+ * The session generation, bumped by every sign-in, sign-out and restore. A
28
+ * refresh that started under one generation and finished under another
29
+ * belongs to a session that no longer exists, so its result is discarded.
30
+ */
31
+ sessionEpoch: () => number;
32
+ /**
33
+ * The provider rotated the refresh token. Resolves once the new token is
34
+ * live, and persisted where it can be: the old one may already be dead, so a
35
+ * restart must not come back with it.
36
+ */
37
+ onRefreshTokenRotated: (token: string, epoch: number) => Promise<void>;
38
+ }
39
+
40
+ export interface AccessTokenManager {
41
+ request(resourceKey: string): Promise<AccessTokenResult>;
42
+ /** Whether `request` would answer from the cache, without touching the network. */
43
+ hasFreshToken(resourceKey: string): boolean;
44
+ clear(): void;
45
+ }
46
+
47
+ const SESSION_ENDED: Omit<AccessTokenResult, 'resourceKey'> = {
48
+ status: 'unavailable',
49
+ errorCode: 'session-expired',
50
+ };
51
+
52
+ export function createAccessTokenManager(deps: AccessTokenManagerDeps): AccessTokenManager {
53
+ const cache = new Map<string, CachedToken>();
54
+ /**
55
+ * Refreshes in progress, by resource. Concurrent callers for one resource
56
+ * share a single network call; with a rotating refresh token, a second call
57
+ * would race the first with a token the first may have just retired.
58
+ */
59
+ const inFlight = new Map<string, Promise<AccessTokenResult>>();
60
+
61
+ const fresh = (resourceKey: string): CachedToken | undefined => {
62
+ const cached = cache.get(resourceKey);
63
+ return cached && cached.expiresAt - EXPIRY_SKEW_MS > Date.now() ? cached : undefined;
64
+ };
65
+
66
+ const refresh = async (resource: ResourceConfig): Promise<AccessTokenResult> => {
67
+ const resourceKey = resource.key;
68
+ const epoch = deps.sessionEpoch();
69
+ const refreshToken = deps.getRefreshToken();
70
+ if (!refreshToken) {
71
+ return { resourceKey, ...SESSION_ENDED };
72
+ }
73
+
74
+ try {
75
+ const tokens = await deps.authClient.refresh({
76
+ authority: deps.authority,
77
+ clientId: deps.clientId,
78
+ redirectUrl: deps.redirectUrl,
79
+ refreshToken,
80
+ scopes: resource.scopes,
81
+ });
82
+ // Signed out, or signed in as someone else, while this was in flight:
83
+ // nothing from it may be written back, and its token is not handed out.
84
+ if (deps.sessionEpoch() !== epoch) {
85
+ return { resourceKey, ...SESSION_ENDED };
86
+ }
87
+ if (tokens.refreshToken && tokens.refreshToken !== refreshToken) {
88
+ await deps.onRefreshTokenRotated(tokens.refreshToken, epoch);
89
+ if (deps.sessionEpoch() !== epoch) {
90
+ return { resourceKey, ...SESSION_ENDED };
91
+ }
92
+ }
93
+ if (!tokens.accessToken) {
94
+ return { resourceKey, status: 'error', errorCode: 'token-acquisition-failed' };
95
+ }
96
+ const expiresAt = tokens.accessTokenExpiresAt ?? Date.now() + 3_600_000;
97
+ cache.set(resourceKey, {
98
+ token: tokens.accessToken,
99
+ expiresAt,
100
+ scopes: resource.scopes,
101
+ });
102
+ return { resourceKey, status: 'success', token: tokens.accessToken, expiresAt };
103
+ } catch (error) {
104
+ if (isAuthError(error)) {
105
+ if (error.code === 'consent-denied') {
106
+ return { resourceKey, status: 'denied', errorCode: error.code };
107
+ }
108
+ if (error.code === 'network-unavailable') {
109
+ return { resourceKey, status: 'unavailable', errorCode: error.code };
110
+ }
111
+ if (error.code === 'session-expired') {
112
+ return { resourceKey, status: 'unavailable', errorCode: error.code };
113
+ }
114
+ return { resourceKey, status: 'error', errorCode: error.code };
115
+ }
116
+ return { resourceKey, status: 'error', errorCode: 'token-acquisition-failed' };
117
+ }
118
+ };
119
+
120
+ return {
121
+ clear() {
122
+ cache.clear();
123
+ inFlight.clear();
124
+ },
125
+
126
+ hasFreshToken(resourceKey) {
127
+ return fresh(resourceKey) !== undefined;
128
+ },
129
+
130
+ request(resourceKey): Promise<AccessTokenResult> {
131
+ const resource = deps.resources.find(entry => entry.key === resourceKey);
132
+ if (!resource) {
133
+ return Promise.resolve({ resourceKey, status: 'error', errorCode: 'configuration-error' });
134
+ }
135
+
136
+ const cached = fresh(resourceKey);
137
+ if (cached) {
138
+ return Promise.resolve({
139
+ resourceKey,
140
+ status: 'success',
141
+ token: cached.token,
142
+ expiresAt: cached.expiresAt,
143
+ });
144
+ }
145
+
146
+ const running = inFlight.get(resourceKey);
147
+ if (running) {
148
+ return running;
149
+ }
150
+ const flight = refresh(resource).finally(() => {
151
+ if (inFlight.get(resourceKey) === flight) {
152
+ inFlight.delete(resourceKey);
153
+ }
154
+ });
155
+ inFlight.set(resourceKey, flight);
156
+ return flight;
157
+ },
158
+ };
159
+ }
@@ -1,52 +1,51 @@
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
- }
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 = options.accessible ?? Keychain.ACCESSIBLE?.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
35
+
36
+ return {
37
+ async getItem(key) {
38
+ const result = await Keychain.getGenericPassword({ service: key });
39
+ return result ? result.password : null;
40
+ },
41
+ async setItem(key, value) {
42
+ await Keychain.setGenericPassword('token', value, {
43
+ service: key,
44
+ accessible,
45
+ });
46
+ },
47
+ async removeItem(key) {
48
+ await Keychain.resetGenericPassword({ service: key });
49
+ },
50
+ };
51
+ }