rastack 0.0.53 → 0.0.54
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 +2 -0
- package/auth/index.ts +22 -9
- package/auth/provider.ts +486 -0
- package/dist/admin.js +13 -13
- package/dist/auth/cognito.d.ts +169 -0
- package/dist/auth/cognito.js +296 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +18 -0
- package/dist/entity-generation/get-method.js +4 -4
- package/dist/entity-generation/imports.js +1 -1
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/src/auth/cognito.ts +410 -0
- package/src/auth/index.ts +2 -0
- package/src/entity-generation/get-method.ts +4 -4
- package/src/entity-generation/imports.ts +1 -1
- package/test/cognito.spec.ts +281 -0
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [0.0.54](https://github.com/theserverkid/reactapistack/compare/v0.0.53...v0.0.54) (2026-07-16)
|
|
6
|
+
|
|
5
7
|
### [0.0.53](https://github.com/theserverkid/reactapistack/compare/v0.0.52...v0.0.53) (2026-07-16)
|
|
6
8
|
|
|
7
9
|
### [0.0.52](https://github.com/theserverkid/reactapistack/compare/v0.0.51...v0.0.52) (2026-07-16)
|
package/auth/index.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `rastack/auth` — the whole auth surface of the stack:
|
|
2
|
+
* `rastack/auth` — the whole auth surface of the stack, both halves:
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* **Authentication** (who you are) — the Cognito sign-in that produces a
|
|
5
|
+
* verified identity: `<CognitoAuthProvider>` + `useCognitoAuth()` driving the
|
|
6
|
+
* standard **Hosted UI, OAuth 2.0 Authorization Code + PKCE** browser flow
|
|
7
|
+
* (`tools/src/auth/cognito.ts` is the pure PKCE/JWT/session core), plus a
|
|
8
|
+
* `local` mode that mints an in-browser session (a server-less dev shim). The
|
|
9
|
+
* session feeds a {@link RastackAuth} straight into `<RAStackProvider auth={…}>`
|
|
10
|
+
* (see {@link cognitoToRastackAuth} / {@link useRastackAuthFromCognito}).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* **Authorization** (what you can reach) — the type-level access rules and their
|
|
13
|
+
* runtime mirror: the entity markers (`IsAuthenticated`, `AdminGroups<…>`,
|
|
14
|
+
* `OwnerScoped`, `GroupScoped`, …) and `Owner` / `Group` field brands the
|
|
15
|
+
* compiler reads, the TypeScript auth type ({@link RastackAuth}), the
|
|
16
|
+
* client-side mirror of the server's rules ({@link can}, {@link ownsRow}, policy
|
|
17
|
+
* lookup), and the React context the provider and components share.
|
|
18
|
+
*
|
|
19
|
+
* See `docs/cognito-auth.md` (authentication) and
|
|
20
|
+
* `docs/security-and-local-cache.md` (authorization / row-level security).
|
|
13
21
|
*/
|
|
14
22
|
|
|
23
|
+
// -- authorization (the RLS mirror + authoring markers) --
|
|
15
24
|
export * from "../src/define/auth";
|
|
16
25
|
export * from "./core";
|
|
17
26
|
export {
|
|
@@ -21,3 +30,7 @@ export {
|
|
|
21
30
|
type RastackAccessValue,
|
|
22
31
|
type RastackAuthProviderProps,
|
|
23
32
|
} from "./context";
|
|
33
|
+
|
|
34
|
+
// -- authentication (Cognito sign-in) --
|
|
35
|
+
export * from "../src/auth/cognito";
|
|
36
|
+
export * from "./provider";
|
package/auth/provider.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
ReactElement,
|
|
5
|
+
ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import {
|
|
14
|
+
authorizeUrl,
|
|
15
|
+
CognitoAuthConfig,
|
|
16
|
+
CognitoClaims,
|
|
17
|
+
CognitoSession,
|
|
18
|
+
CognitoTokenResponse,
|
|
19
|
+
CognitoUser,
|
|
20
|
+
createPkcePair,
|
|
21
|
+
isSessionValid,
|
|
22
|
+
localSession,
|
|
23
|
+
logoutUrl,
|
|
24
|
+
parseRedirect,
|
|
25
|
+
randomState,
|
|
26
|
+
sessionFromTokens,
|
|
27
|
+
tokenEndpoint,
|
|
28
|
+
tokenRequestBody,
|
|
29
|
+
} from "../src/auth/cognito";
|
|
30
|
+
import { anonymousAuth, authFromClaims, type RastackAuth } from "./core";
|
|
31
|
+
|
|
32
|
+
/** Where a sign-in should send the browser: the real Hosted UI, or a local
|
|
33
|
+
* synthetic session for server-less (`mode:"local"`) dev. */
|
|
34
|
+
export type CognitoAuthMode = "hosted-ui" | "local";
|
|
35
|
+
|
|
36
|
+
/** The minimal key-value storage the provider persists a session in. */
|
|
37
|
+
export interface AuthStorage {
|
|
38
|
+
getItem(key: string): string | null;
|
|
39
|
+
setItem(key: string, value: string): void;
|
|
40
|
+
removeItem(key: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CognitoAuthProviderConfig extends Partial<CognitoAuthConfig> {
|
|
44
|
+
/**
|
|
45
|
+
* `hosted-ui` (default) runs the real OAuth Authorization Code + PKCE
|
|
46
|
+
* redirect against Cognito. `local` mints an unsigned in-browser session on
|
|
47
|
+
* sign-in — a server-less dev shim — so the entire app
|
|
48
|
+
* (which in `<RAStackProvider mode="local">` runs with no identity provider)
|
|
49
|
+
* can still exercise the login button and write-gating. Swap to `hosted-ui`
|
|
50
|
+
* for a deployed, Cognito-protected backend.
|
|
51
|
+
*/
|
|
52
|
+
mode?: CognitoAuthMode;
|
|
53
|
+
/** The simulated user granted on sign-in in `local` mode. */
|
|
54
|
+
localUser?: {
|
|
55
|
+
sub?: string;
|
|
56
|
+
email?: string;
|
|
57
|
+
username?: string;
|
|
58
|
+
groups?: string[];
|
|
59
|
+
};
|
|
60
|
+
/** Which token to hand out as the Bearer credential. Default `"id"` (its
|
|
61
|
+
* `aud` is the app client id, matching `rastack-server --audience`). */
|
|
62
|
+
tokenType?: "id" | "access";
|
|
63
|
+
/** Persistence for the session (default `localStorage`, memory fallback). */
|
|
64
|
+
storage?: AuthStorage;
|
|
65
|
+
/** Storage key prefix (default `"rastack.auth"`). */
|
|
66
|
+
storageKey?: string;
|
|
67
|
+
/** Override `fetch` (SSR/tests). Defaults to the global `fetch`. */
|
|
68
|
+
fetchImpl?: typeof fetch;
|
|
69
|
+
/** Injectable clock for tests. Defaults to `Date.now`. */
|
|
70
|
+
now?: () => number;
|
|
71
|
+
onSignIn?: (session: CognitoSession) => void;
|
|
72
|
+
onSignOut?: () => void;
|
|
73
|
+
onError?: (error: unknown) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type CognitoAuthStatus =
|
|
77
|
+
| "loading"
|
|
78
|
+
| "authenticated"
|
|
79
|
+
| "unauthenticated"
|
|
80
|
+
| "error";
|
|
81
|
+
|
|
82
|
+
/** What `useCognitoAuth()` returns. */
|
|
83
|
+
export interface CognitoAuthContextValue {
|
|
84
|
+
status: CognitoAuthStatus;
|
|
85
|
+
isAuthenticated: boolean;
|
|
86
|
+
isLoading: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* True once auth has resolved (a session was restored, a redirect completed,
|
|
89
|
+
* or we settled on signed-out) — i.e. any time we're not still booting. The
|
|
90
|
+
* generated read hooks gate their queries on this so *viewing works whether
|
|
91
|
+
* or not the user is signed in*; write UI gates on `isAuthenticated` instead.
|
|
92
|
+
*/
|
|
93
|
+
isReady: boolean;
|
|
94
|
+
user: CognitoUser | null;
|
|
95
|
+
/** Verified `cognito:groups` (empty when signed out). */
|
|
96
|
+
groups: string[];
|
|
97
|
+
claims: CognitoClaims | null;
|
|
98
|
+
error: unknown;
|
|
99
|
+
/** Start sign-in (Hosted UI redirect, or a local session in `local` mode). */
|
|
100
|
+
login: () => Promise<void>;
|
|
101
|
+
/** Sign out — clears the local session (and, in `hosted-ui`, the Cognito cookie). */
|
|
102
|
+
logout: () => Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* A valid bearer token, refreshing first if it's near expiry. Rejects if the
|
|
105
|
+
* user isn't signed in — pass this straight to `<RAStackProvider getToken>`.
|
|
106
|
+
*/
|
|
107
|
+
getToken: () => Promise<string>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const CognitoAuthContext = createContext<CognitoAuthContextValue | null>(null);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* DOM globals accessed defensively through `globalThis`, so this module
|
|
114
|
+
* type-checks under a non-DOM lib (the pure `.ts` graph the components and
|
|
115
|
+
* tests import via the `rastack/auth` barrel) and stays SSR-safe — every use is
|
|
116
|
+
* still guarded before it runs.
|
|
117
|
+
*/
|
|
118
|
+
const dom = globalThis as unknown as {
|
|
119
|
+
window?: any;
|
|
120
|
+
document?: any;
|
|
121
|
+
localStorage?: any;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** An in-memory `AuthStorage` for SSR / environments without `localStorage`. */
|
|
125
|
+
function memoryStorage(): AuthStorage {
|
|
126
|
+
const map = new Map<string, string>();
|
|
127
|
+
return {
|
|
128
|
+
getItem: (k) => (map.has(k) ? map.get(k)! : null),
|
|
129
|
+
setItem: (k, v) => void map.set(k, v),
|
|
130
|
+
removeItem: (k) => void map.delete(k),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function defaultStorage(): AuthStorage {
|
|
135
|
+
try {
|
|
136
|
+
if (dom.localStorage) {
|
|
137
|
+
// Touch it — Safari private mode throws on write.
|
|
138
|
+
const probe = "__rastack_auth_probe__";
|
|
139
|
+
dom.localStorage.setItem(probe, "1");
|
|
140
|
+
dom.localStorage.removeItem(probe);
|
|
141
|
+
return dom.localStorage as AuthStorage;
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
/* fall through to memory */
|
|
145
|
+
}
|
|
146
|
+
return memoryStorage();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function requireHostedConfig(
|
|
150
|
+
config: CognitoAuthProviderConfig,
|
|
151
|
+
): CognitoAuthConfig {
|
|
152
|
+
if (!config.domain || !config.clientId || !config.redirectUri) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
"CognitoAuthProvider: `domain`, `clientId` and `redirectUri` are required for hosted-ui mode.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return config as CognitoAuthConfig;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Provides Cognito auth to the tree. Reads any persisted session on mount and,
|
|
162
|
+
* in `hosted-ui` mode, completes the code exchange if the current URL is a
|
|
163
|
+
* Cognito redirect (`?code=…&state=…`).
|
|
164
|
+
*/
|
|
165
|
+
export function CognitoAuthProvider({
|
|
166
|
+
config,
|
|
167
|
+
children,
|
|
168
|
+
}: {
|
|
169
|
+
config: CognitoAuthProviderConfig;
|
|
170
|
+
children: ReactNode;
|
|
171
|
+
}): ReactElement {
|
|
172
|
+
const mode = config.mode ?? "hosted-ui";
|
|
173
|
+
const now = config.now ?? (() => Date.now());
|
|
174
|
+
const storage = useMemo(
|
|
175
|
+
() => config.storage ?? defaultStorage(),
|
|
176
|
+
[config.storage],
|
|
177
|
+
);
|
|
178
|
+
const keyBase = config.storageKey ?? "rastack.auth";
|
|
179
|
+
const sessionKey = `${keyBase}.session`;
|
|
180
|
+
const pkceKey = `${keyBase}.pkce`;
|
|
181
|
+
const doFetch = config.fetchImpl ?? (globalThis.fetch?.bind(globalThis) as typeof fetch);
|
|
182
|
+
|
|
183
|
+
const [session, setSession] = useState<CognitoSession | null>(null);
|
|
184
|
+
const [status, setStatus] = useState<CognitoAuthStatus>("loading");
|
|
185
|
+
const [error, setError] = useState<unknown>(null);
|
|
186
|
+
const sessionRef = useRef<CognitoSession | null>(null);
|
|
187
|
+
sessionRef.current = session;
|
|
188
|
+
|
|
189
|
+
const persist = useCallback(
|
|
190
|
+
(next: CognitoSession | null) => {
|
|
191
|
+
setSession(next);
|
|
192
|
+
if (next) storage.setItem(sessionKey, JSON.stringify(next));
|
|
193
|
+
else storage.removeItem(sessionKey);
|
|
194
|
+
},
|
|
195
|
+
[storage, sessionKey],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const fail = useCallback(
|
|
199
|
+
(err: unknown) => {
|
|
200
|
+
setError(err);
|
|
201
|
+
setStatus("error");
|
|
202
|
+
config.onError?.(err);
|
|
203
|
+
},
|
|
204
|
+
[config],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// -- boot: restore a stored session, or finish a redirect ------------------
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
let cancelled = false;
|
|
210
|
+
|
|
211
|
+
async function boot() {
|
|
212
|
+
// A live stored session wins.
|
|
213
|
+
const stored = storage.getItem(sessionKey);
|
|
214
|
+
if (stored) {
|
|
215
|
+
try {
|
|
216
|
+
const parsed: CognitoSession = JSON.parse(stored);
|
|
217
|
+
const valid = isSessionValid(parsed, now());
|
|
218
|
+
if (valid) {
|
|
219
|
+
if (!cancelled) {
|
|
220
|
+
setSession(parsed);
|
|
221
|
+
setStatus("authenticated");
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// Expired but refreshable → try below; otherwise drop it.
|
|
226
|
+
if (mode === "hosted-ui" && parsed.refreshToken) {
|
|
227
|
+
const refreshed = await refreshSession(parsed);
|
|
228
|
+
if (refreshed && !cancelled) {
|
|
229
|
+
persist(refreshed);
|
|
230
|
+
setStatus("authenticated");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
storage.removeItem(sessionKey);
|
|
235
|
+
} catch {
|
|
236
|
+
storage.removeItem(sessionKey);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Complete a Hosted UI redirect if this load is one.
|
|
241
|
+
if (mode === "hosted-ui" && dom.window) {
|
|
242
|
+
const redirect = parseRedirect(dom.window.location.search);
|
|
243
|
+
if (redirect.error) {
|
|
244
|
+
clearUrl();
|
|
245
|
+
if (!cancelled) fail(new Error(redirect.errorDescription ?? redirect.error));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (redirect.code) {
|
|
249
|
+
try {
|
|
250
|
+
const exchanged = await exchangeCode(redirect.code, redirect.state);
|
|
251
|
+
clearUrl();
|
|
252
|
+
if (!cancelled) {
|
|
253
|
+
persist(exchanged);
|
|
254
|
+
setStatus("authenticated");
|
|
255
|
+
config.onSignIn?.(exchanged);
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
clearUrl();
|
|
259
|
+
if (!cancelled) fail(err);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!cancelled) setStatus("unauthenticated");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
boot();
|
|
269
|
+
return () => {
|
|
270
|
+
cancelled = true;
|
|
271
|
+
};
|
|
272
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
275
|
+
/** Drop the `?code=&state=` off the address bar after a successful exchange. */
|
|
276
|
+
function clearUrl() {
|
|
277
|
+
if (!dom.window || !dom.window.history?.replaceState) return;
|
|
278
|
+
dom.window.history.replaceState(
|
|
279
|
+
{},
|
|
280
|
+
dom.document?.title ?? "",
|
|
281
|
+
dom.window.location.pathname,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function exchangeCode(
|
|
286
|
+
code: string,
|
|
287
|
+
state?: string,
|
|
288
|
+
): Promise<CognitoSession> {
|
|
289
|
+
const hosted = requireHostedConfig(config);
|
|
290
|
+
const pending = storage.getItem(pkceKey);
|
|
291
|
+
if (!pending) throw new Error("No pending PKCE verifier — restart sign-in.");
|
|
292
|
+
const { verifier, state: expectedState } = JSON.parse(pending) as {
|
|
293
|
+
verifier: string;
|
|
294
|
+
state: string;
|
|
295
|
+
};
|
|
296
|
+
storage.removeItem(pkceKey);
|
|
297
|
+
if (expectedState && state && expectedState !== state) {
|
|
298
|
+
throw new Error("OAuth state mismatch — possible CSRF, sign-in rejected.");
|
|
299
|
+
}
|
|
300
|
+
const response = await doFetch(tokenEndpoint(hosted), {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
303
|
+
body: tokenRequestBody(hosted, {
|
|
304
|
+
grantType: "authorization_code",
|
|
305
|
+
code,
|
|
306
|
+
codeVerifier: verifier,
|
|
307
|
+
}),
|
|
308
|
+
});
|
|
309
|
+
if (!response.ok) {
|
|
310
|
+
throw new Error(`Token exchange failed (${response.status}).`);
|
|
311
|
+
}
|
|
312
|
+
const tokens = (await response.json()) as CognitoTokenResponse;
|
|
313
|
+
return sessionFromTokens(tokens, now());
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function refreshSession(
|
|
317
|
+
prev: CognitoSession,
|
|
318
|
+
): Promise<CognitoSession | null> {
|
|
319
|
+
if (mode === "local") {
|
|
320
|
+
return localSession(config.localUser, now());
|
|
321
|
+
}
|
|
322
|
+
if (!prev.refreshToken) return null;
|
|
323
|
+
try {
|
|
324
|
+
const hosted = requireHostedConfig(config);
|
|
325
|
+
const response = await doFetch(tokenEndpoint(hosted), {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
328
|
+
body: tokenRequestBody(hosted, {
|
|
329
|
+
grantType: "refresh_token",
|
|
330
|
+
refreshToken: prev.refreshToken,
|
|
331
|
+
}),
|
|
332
|
+
});
|
|
333
|
+
if (!response.ok) return null;
|
|
334
|
+
const tokens = (await response.json()) as CognitoTokenResponse;
|
|
335
|
+
// A refresh grant doesn't return a new refresh token — carry the old one.
|
|
336
|
+
const next = sessionFromTokens(tokens, now());
|
|
337
|
+
return { ...next, refreshToken: next.refreshToken ?? prev.refreshToken };
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const login = useCallback(async () => {
|
|
344
|
+
setError(null);
|
|
345
|
+
if (mode === "local") {
|
|
346
|
+
const next = localSession(config.localUser, now());
|
|
347
|
+
persist(next);
|
|
348
|
+
setStatus("authenticated");
|
|
349
|
+
config.onSignIn?.(next);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const hosted = requireHostedConfig(config);
|
|
353
|
+
const { verifier, challenge } = await createPkcePair();
|
|
354
|
+
const state = randomState();
|
|
355
|
+
storage.setItem(pkceKey, JSON.stringify({ verifier, state }));
|
|
356
|
+
const url = authorizeUrl(hosted, { state, codeChallenge: challenge });
|
|
357
|
+
if (dom.window) dom.window.location.assign(url);
|
|
358
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
359
|
+
}, [mode, config, persist, storage, pkceKey]);
|
|
360
|
+
|
|
361
|
+
const logout = useCallback(async () => {
|
|
362
|
+
persist(null);
|
|
363
|
+
setStatus("unauthenticated");
|
|
364
|
+
config.onSignOut?.();
|
|
365
|
+
if (mode === "hosted-ui" && config.domain && config.clientId) {
|
|
366
|
+
if (dom.window) {
|
|
367
|
+
dom.window.location.assign(logoutUrl(config as CognitoAuthConfig));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
371
|
+
}, [mode, config, persist]);
|
|
372
|
+
|
|
373
|
+
const tokenType = config.tokenType ?? "id";
|
|
374
|
+
const getToken = useCallback(async () => {
|
|
375
|
+
let current = sessionRef.current;
|
|
376
|
+
if (current && !isSessionValid(current, now())) {
|
|
377
|
+
const refreshed = await refreshSession(current);
|
|
378
|
+
if (refreshed) {
|
|
379
|
+
persist(refreshed);
|
|
380
|
+
current = refreshed;
|
|
381
|
+
} else {
|
|
382
|
+
persist(null);
|
|
383
|
+
setStatus("unauthenticated");
|
|
384
|
+
current = null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!current) throw new Error("Not authenticated.");
|
|
388
|
+
return tokenType === "access" ? current.accessToken : current.idToken;
|
|
389
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
390
|
+
}, [persist, tokenType]);
|
|
391
|
+
|
|
392
|
+
// Flip to "unauthenticated" the moment a session lapses (no refresh path).
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
if (!session || mode === "local") return;
|
|
395
|
+
const ms = session.expiresAt - now();
|
|
396
|
+
if (ms <= 0) return;
|
|
397
|
+
const timer = setTimeout(() => {
|
|
398
|
+
if (!session.refreshToken) {
|
|
399
|
+
persist(null);
|
|
400
|
+
setStatus("unauthenticated");
|
|
401
|
+
}
|
|
402
|
+
}, ms);
|
|
403
|
+
return () => clearTimeout(timer);
|
|
404
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
405
|
+
}, [session, mode]);
|
|
406
|
+
|
|
407
|
+
const value = useMemo<CognitoAuthContextValue>(
|
|
408
|
+
() => ({
|
|
409
|
+
status,
|
|
410
|
+
isAuthenticated: status === "authenticated" && !!session,
|
|
411
|
+
isLoading: status === "loading",
|
|
412
|
+
isReady: status !== "loading",
|
|
413
|
+
user: session?.user ?? null,
|
|
414
|
+
groups: session?.user.groups ?? [],
|
|
415
|
+
claims: session?.claims ?? null,
|
|
416
|
+
error,
|
|
417
|
+
login,
|
|
418
|
+
logout,
|
|
419
|
+
getToken,
|
|
420
|
+
}),
|
|
421
|
+
[status, session, error, login, logout, getToken],
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
return createElement(CognitoAuthContext.Provider, { value }, children);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* A safe default for `useCognitoAuth()` outside a `<CognitoAuthProvider>`:
|
|
429
|
+
* resolved-but-signed-out, so the generated read hooks (which only read
|
|
430
|
+
* `isReady`) keep working in an app that wires identity some other way, while
|
|
431
|
+
* the sign-in actions throw a clear error if actually invoked.
|
|
432
|
+
*/
|
|
433
|
+
const NO_PROVIDER_VALUE: CognitoAuthContextValue = {
|
|
434
|
+
status: "unauthenticated",
|
|
435
|
+
isAuthenticated: false,
|
|
436
|
+
isLoading: false,
|
|
437
|
+
isReady: true,
|
|
438
|
+
user: null,
|
|
439
|
+
groups: [],
|
|
440
|
+
claims: null,
|
|
441
|
+
error: null,
|
|
442
|
+
login: async () => {
|
|
443
|
+
throw new Error("login() requires a <CognitoAuthProvider>.");
|
|
444
|
+
},
|
|
445
|
+
logout: async () => {
|
|
446
|
+
throw new Error("logout() requires a <CognitoAuthProvider>.");
|
|
447
|
+
},
|
|
448
|
+
getToken: async () => {
|
|
449
|
+
throw new Error("getToken() requires a <CognitoAuthProvider>.");
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Consume the Cognito auth state + actions. Outside a `<CognitoAuthProvider>`
|
|
455
|
+
* this returns a resolved, signed-out value (never throws) so the generated
|
|
456
|
+
* read hooks work with or without Cognito wired up.
|
|
457
|
+
*/
|
|
458
|
+
export function useCognitoAuth(): CognitoAuthContextValue {
|
|
459
|
+
return useContext(CognitoAuthContext) ?? NO_PROVIDER_VALUE;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Map the Cognito auth state to the framework-wide {@link RastackAuth} the
|
|
464
|
+
* authorization half (`can`/`ownsRow`, `<RAStackProvider auth={…}>`) consumes:
|
|
465
|
+
* the ID token's claims when signed in, an anonymous identity otherwise.
|
|
466
|
+
*/
|
|
467
|
+
export function cognitoToRastackAuth(
|
|
468
|
+
auth: Pick<CognitoAuthContextValue, "isAuthenticated" | "claims">,
|
|
469
|
+
): RastackAuth {
|
|
470
|
+
return auth.isAuthenticated && auth.claims
|
|
471
|
+
? authFromClaims(auth.claims as unknown as Record<string, unknown>)
|
|
472
|
+
: anonymousAuth();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The signed-in {@link RastackAuth} derived from the Cognito context — hand it
|
|
477
|
+
* to `<RAStackProvider auth={…}>` (or `<RastackAuthProvider auth={…}>`) so one
|
|
478
|
+
* Cognito identity drives the engine, owner stamping, and component gating.
|
|
479
|
+
*/
|
|
480
|
+
export function useRastackAuthFromCognito(): RastackAuth {
|
|
481
|
+
const auth = useCognitoAuth();
|
|
482
|
+
return useMemo(
|
|
483
|
+
() => cognitoToRastackAuth(auth),
|
|
484
|
+
[auth.isAuthenticated, auth.claims],
|
|
485
|
+
);
|
|
486
|
+
}
|