@flow-industries/id 0.11.0 → 0.12.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/dist/sdk/client/create-flow.d.ts +3 -3
- package/dist/sdk/client/create-flow.js +103 -112
- package/dist/sdk/client/index.d.ts +1 -0
- package/dist/sdk/client/index.js +1 -0
- package/dist/sdk/client/profile-button.js +2 -2
- package/dist/sdk/client/refresh-store.d.ts +13 -14
- package/dist/sdk/client/refresh-store.js +48 -58
- package/dist/sdk/client/static-flow.js +2 -2
- package/dist/sdk/cookies.d.ts +8 -19
- package/dist/sdk/cookies.js +9 -25
- package/dist/sdk/id-host.d.ts +36 -0
- package/dist/sdk/id-host.js +57 -0
- package/dist/sdk/server.d.ts +8 -18
- package/dist/sdk/server.js +17 -173
- package/dist/sdk/session-core.d.ts +49 -0
- package/dist/sdk/session-core.js +176 -0
- package/dist/sdk/session-route.d.ts +33 -0
- package/dist/sdk/session-route.js +127 -0
- package/dist/sdk/start/index.d.ts +48 -0
- package/dist/sdk/start/index.js +52 -0
- package/dist/sdk/start/server.d.ts +33 -0
- package/dist/sdk/start/server.js +35 -0
- package/dist/sdk/types/index.d.ts +2 -2
- package/dist/sdk/types/sdk.d.ts +7 -11
- package/dist/sdk/types/server.d.ts +30 -2
- package/package.json +15 -2
- package/dist/sdk/client/refresh-store.test.d.ts +0 -1
- package/dist/sdk/client/refresh-store.test.js +0 -90
|
@@ -10,9 +10,9 @@ import { credentialToAddress } from "./session";
|
|
|
10
10
|
*
|
|
11
11
|
* On construction it kicks off two async tasks: rehydrating the credential
|
|
12
12
|
* from IndexedDB (so signing works even before the JWT is refreshed) and,
|
|
13
|
-
* unless `autoRestore: false`,
|
|
14
|
-
*
|
|
15
|
-
* to state changes via `flow.subscribe`.
|
|
13
|
+
* unless `autoRestore: false`, a silent session resolve through the app's
|
|
14
|
+
* first-party session route. Both run in the background; consumers can
|
|
15
|
+
* subscribe to state changes via `flow.subscribe`.
|
|
16
16
|
*
|
|
17
17
|
* Most signing methods dynamically import their implementation modules so
|
|
18
18
|
* apps that only use identity (no chain ops) don't pay for the viem/tempo
|
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_SESSION_PATH, isLocalHostname, resolveIdHost, } from "../id-host";
|
|
2
2
|
import { isExpiring } from "../token-expiry";
|
|
3
3
|
import { createDialogHost } from "./dialog-host";
|
|
4
4
|
import { idb } from "./idb";
|
|
5
5
|
import { METHODS } from "./methods";
|
|
6
|
-
import { makeRefreshStore } from "./refresh-store";
|
|
7
6
|
import { createRoomsApi } from "./rooms";
|
|
8
7
|
import { credentialToAddress, restoreCredential, runLogin, runLogout, } from "./session";
|
|
9
8
|
import { createStore, initialFlowState } from "./store";
|
|
10
|
-
const DEFAULT_HOST = "https://id.flow.industries";
|
|
11
9
|
/**
|
|
12
|
-
* Runs `fn` while holding a cross-tab lock (Web Locks API
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* Runs `fn` while holding a cross-tab lock (Web Locks API — origin-scoped by
|
|
11
|
+
* design, matching the origin-scoped session cookies) so concurrent tabs
|
|
12
|
+
* serialize session work: the first tab refreshes or mints, later tabs then
|
|
13
|
+
* see the result instead of racing it. Falls back to running `fn` directly
|
|
14
|
+
* where Web Locks is unavailable.
|
|
16
15
|
*/
|
|
17
|
-
function
|
|
16
|
+
function withOriginLock(name, fn) {
|
|
18
17
|
const locks = globalThis.navigator?.locks;
|
|
19
18
|
if (locks?.request)
|
|
20
19
|
return locks.request(name, fn);
|
|
@@ -50,9 +49,9 @@ function resolveAccessKey(input) {
|
|
|
50
49
|
*
|
|
51
50
|
* On construction it kicks off two async tasks: rehydrating the credential
|
|
52
51
|
* from IndexedDB (so signing works even before the JWT is refreshed) and,
|
|
53
|
-
* unless `autoRestore: false`,
|
|
54
|
-
*
|
|
55
|
-
* to state changes via `flow.subscribe`.
|
|
52
|
+
* unless `autoRestore: false`, a silent session resolve through the app's
|
|
53
|
+
* first-party session route. Both run in the background; consumers can
|
|
54
|
+
* subscribe to state changes via `flow.subscribe`.
|
|
56
55
|
*
|
|
57
56
|
* Most signing methods dynamically import their implementation modules so
|
|
58
57
|
* apps that only use identity (no chain ops) don't pay for the viem/tempo
|
|
@@ -67,21 +66,17 @@ export function createFlow(options = {}) {
|
|
|
67
66
|
// stale state. For genuine multi-instance scenarios (tests), call
|
|
68
67
|
// resetFlow() first or pass explicit Flow instances.
|
|
69
68
|
if (typeof window === "undefined") {
|
|
70
|
-
throw new Error("createFlow() is browser-only (
|
|
71
|
-
"For server rendering, resolve state with resolveSession()
|
|
72
|
-
"@flow-industries/id/server and render with createStaticFlow().");
|
|
69
|
+
throw new Error("createFlow() is browser-only (IndexedDB, iframes, fetch with " +
|
|
70
|
+
"cookies). For server rendering, resolve state with resolveSession() " +
|
|
71
|
+
"from @flow-industries/id/server and render with createStaticFlow().");
|
|
73
72
|
}
|
|
74
73
|
if (currentFlow)
|
|
75
74
|
return currentFlow;
|
|
76
|
-
const host = (options.host
|
|
75
|
+
const host = resolveIdHost(options.host);
|
|
77
76
|
const dialogUrl = `${host}/dialog/`;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
:
|
|
81
|
-
const cookiesSecure = window.location.protocol === "https:";
|
|
82
|
-
const refreshStore = makeRefreshStore(host, cookieNames
|
|
83
|
-
? { name: cookieNames.refresh, secure: cookiesSecure }
|
|
84
|
-
: undefined);
|
|
77
|
+
const sessionPath = options.sessionPath ?? DEFAULT_SESSION_PATH;
|
|
78
|
+
const rpId = options.rpId ??
|
|
79
|
+
(isLocalHostname(window.location.hostname) ? "localhost" : undefined);
|
|
85
80
|
const chains = options.chains ?? [];
|
|
86
81
|
const getChain = (chainId) => {
|
|
87
82
|
if (chainId == null)
|
|
@@ -94,27 +89,6 @@ export function createFlow(options = {}) {
|
|
|
94
89
|
};
|
|
95
90
|
const accessKeyOptions = resolveAccessKey(options.accessKey);
|
|
96
91
|
const store = createStore({ ...initialFlowState });
|
|
97
|
-
// One subscription mirrors the JWT into its first-party cookie on every
|
|
98
|
-
// commit path — refresh, guest mint, login, hydration — and clears it when
|
|
99
|
-
// logout resets the store. Keeping the mirror here (not in each path) means
|
|
100
|
-
// no mint site can forget it.
|
|
101
|
-
if (cookieNames) {
|
|
102
|
-
let mirroredJwt = null;
|
|
103
|
-
store.subscribe((s) => {
|
|
104
|
-
if (s.jwt === mirroredJwt)
|
|
105
|
-
return;
|
|
106
|
-
mirroredJwt = s.jwt;
|
|
107
|
-
if (s.jwt) {
|
|
108
|
-
writeBrowserCookie(cookieNames.jwt, s.jwt, {
|
|
109
|
-
maxAge: JWT_COOKIE_MAX_AGE_S,
|
|
110
|
-
secure: cookiesSecure,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
clearBrowserCookie(cookieNames.jwt, { secure: cookiesSecure });
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
92
|
// Seed synchronously so the first client render matches the SSR HTML —
|
|
119
93
|
// hooks read the hydrated user instead of flashing signed-out while a
|
|
120
94
|
// bootstrap refresh runs.
|
|
@@ -132,73 +106,90 @@ export function createFlow(options = {}) {
|
|
|
132
106
|
return dialog;
|
|
133
107
|
};
|
|
134
108
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* cross-origin cookies).
|
|
140
|
-
*
|
|
141
|
-
* Returns true if a session was found and state was populated, false if the
|
|
142
|
-
* user has no active session (in which case the caller should fall back to
|
|
143
|
-
* `flow.login()`). Never throws — restore failures are treated as "no session".
|
|
109
|
+
* Commits a session reported by the app's session route. `credential` and
|
|
110
|
+
* `address` keys are present only when the route rotated the token — the
|
|
111
|
+
* rotation response is authoritative for signing state; the JWT hot path
|
|
112
|
+
* knows neither, and overwriting would erase state restored from IDB.
|
|
144
113
|
*/
|
|
114
|
+
function commitSession(session) {
|
|
115
|
+
store.setState({
|
|
116
|
+
user: session.user,
|
|
117
|
+
jwt: session.jwt,
|
|
118
|
+
...(session.credential !== undefined
|
|
119
|
+
? { credential: session.credential, address: session.address ?? null }
|
|
120
|
+
: {}),
|
|
121
|
+
});
|
|
122
|
+
// A guest carries a null credential; never persist that — the IDB store is
|
|
123
|
+
// reserved for a real passkey credential and writing null would erase a
|
|
124
|
+
// previously stored one.
|
|
125
|
+
if (session.credential) {
|
|
126
|
+
void idb.set("flow.activeCredential", session.credential);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
145
129
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
130
|
+
* Resolves a fresh session from the app's own session route. The refresh
|
|
131
|
+
* token lives in an HttpOnly cookie only that route's server can read, so
|
|
132
|
+
* this is the sole silent-session path: the server rotates the token when
|
|
133
|
+
* the access JWT is expiring and answers with the minted session. Returns
|
|
134
|
+
* false (never throws) when the visitor is signed out or the route is
|
|
135
|
+
* unreachable — the caller then falls back to ensureGuest()/login().
|
|
152
136
|
*/
|
|
153
|
-
async function
|
|
154
|
-
|
|
155
|
-
|
|
137
|
+
async function refreshViaSession() {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(sessionPath, {
|
|
140
|
+
headers: { Accept: "application/json" },
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok)
|
|
143
|
+
return false;
|
|
144
|
+
const { state } = (await res.json());
|
|
145
|
+
if (!state)
|
|
146
|
+
return false;
|
|
147
|
+
commitSession(state);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
156
151
|
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* One-time handoff of a freshly minted session (dialog login or guest
|
|
156
|
+
* mint) to the app's server, which verifies the JWT against the issuer's
|
|
157
|
+
* JWKS and sets the HttpOnly cookies — the refresh token is never stored
|
|
158
|
+
* where page JavaScript could read it back. Best-effort: without a
|
|
159
|
+
* reachable session route the in-memory session still works, it just
|
|
160
|
+
* can't survive a reload.
|
|
161
|
+
*/
|
|
162
|
+
async function installSession(refreshToken, jwt) {
|
|
163
|
+
if (!refreshToken || !jwt)
|
|
164
|
+
return;
|
|
157
165
|
try {
|
|
158
|
-
const res = await fetch(
|
|
166
|
+
const res = await fetch(sessionPath, {
|
|
159
167
|
method: "POST",
|
|
160
|
-
headers: {
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ refreshToken, jwt }),
|
|
161
170
|
});
|
|
162
171
|
if (!res.ok) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
const result = (await res.json());
|
|
168
|
-
refreshStore.set(result.refreshToken);
|
|
169
|
-
store.setState({
|
|
170
|
-
user: result.user,
|
|
171
|
-
jwt: result.jwt,
|
|
172
|
-
credential: result.credential,
|
|
173
|
-
address: result.address,
|
|
174
|
-
});
|
|
175
|
-
// A guest carries a null credential; never persist that — the IDB store is
|
|
176
|
-
// reserved for a real passkey credential and writing null would erase a
|
|
177
|
-
// previously stored one.
|
|
178
|
-
if (result.credential) {
|
|
179
|
-
await idb.set("flow.activeCredential", result.credential);
|
|
172
|
+
console.warn(`Flow ID: session route ${sessionPath} answered ${res.status}; ` +
|
|
173
|
+
"the session will not survive a reload");
|
|
180
174
|
}
|
|
181
|
-
return true;
|
|
182
175
|
}
|
|
183
176
|
catch {
|
|
184
|
-
|
|
177
|
+
console.warn(`Flow ID: no session route at ${sessionPath}; ` +
|
|
178
|
+
"the session will not survive a reload");
|
|
185
179
|
}
|
|
186
180
|
}
|
|
187
|
-
// Every
|
|
188
|
-
// this one lock so
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// after a concurrent holder already minted a fresh JWT, so no one re-presents
|
|
194
|
-
// an already-rotated token. refreshViaToken re-reads the token fresh from
|
|
195
|
-
// storage inside the lock, so a genuinely-second refresh uses the rotated one.
|
|
181
|
+
// Every session-route call — boot, ensureGuest, and getToken — funnels
|
|
182
|
+
// through this one lock so concurrent tabs don't fan out parallel
|
|
183
|
+
// rotations. The server dedupes and the auth server's grace window absorbs
|
|
184
|
+
// stragglers, but serializing here means the common case is one rotation,
|
|
185
|
+
// and the guard short-circuits a caller that arrives after a concurrent
|
|
186
|
+
// holder already minted a fresh JWT.
|
|
196
187
|
async function doRefresh() {
|
|
197
|
-
return
|
|
188
|
+
return withOriginLock("flow.id.refresh", async () => {
|
|
198
189
|
const current = store.getSnapshot().jwt;
|
|
199
190
|
if (current && !isExpiring(current))
|
|
200
191
|
return true;
|
|
201
|
-
return
|
|
192
|
+
return refreshViaSession();
|
|
202
193
|
});
|
|
203
194
|
}
|
|
204
195
|
const refreshJwt = doRefresh;
|
|
@@ -247,10 +238,10 @@ export function createFlow(options = {}) {
|
|
|
247
238
|
if (store.getSnapshot().user)
|
|
248
239
|
return true;
|
|
249
240
|
if (!guestInFlight) {
|
|
250
|
-
guestInFlight =
|
|
241
|
+
guestInFlight = withOriginLock("flow.id.guest", async () => {
|
|
251
242
|
// Re-check under the lock: another tab may have minted the guest while
|
|
252
|
-
// we waited. Refresh-first (from this origin's
|
|
253
|
-
// existing session instead of minting a duplicate guest.
|
|
243
|
+
// we waited. Refresh-first (from this origin's session cookie)
|
|
244
|
+
// recovers an existing session instead of minting a duplicate guest.
|
|
254
245
|
if (store.getSnapshot().user)
|
|
255
246
|
return true;
|
|
256
247
|
if (await doRefresh())
|
|
@@ -259,7 +250,6 @@ export function createFlow(options = {}) {
|
|
|
259
250
|
const result = await getDialog().requestSilent(METHODS.guest, []);
|
|
260
251
|
if (!result.user)
|
|
261
252
|
return false;
|
|
262
|
-
refreshStore.set(result.refreshToken);
|
|
263
253
|
store.setState({
|
|
264
254
|
user: result.user,
|
|
265
255
|
jwt: result.jwt,
|
|
@@ -268,6 +258,7 @@ export function createFlow(options = {}) {
|
|
|
268
258
|
// address already in state rather than stripping signing.
|
|
269
259
|
...(result.user.isGuest ? { credential: null, address: null } : {}),
|
|
270
260
|
});
|
|
261
|
+
await installSession(result.refreshToken, result.jwt);
|
|
271
262
|
return true;
|
|
272
263
|
}
|
|
273
264
|
catch {
|
|
@@ -303,7 +294,7 @@ export function createFlow(options = {}) {
|
|
|
303
294
|
getState: () => store.getSnapshot(),
|
|
304
295
|
getChain,
|
|
305
296
|
getTransport,
|
|
306
|
-
...(
|
|
297
|
+
...(rpId ? { rpId } : {}),
|
|
307
298
|
...(accessKeyOptions?.strict ? { strict: accessKeyOptions.strict } : {}),
|
|
308
299
|
};
|
|
309
300
|
}
|
|
@@ -351,7 +342,7 @@ export function createFlow(options = {}) {
|
|
|
351
342
|
options: loginOpts,
|
|
352
343
|
...(extraCapabilities ? { extraCapabilities } : {}),
|
|
353
344
|
});
|
|
354
|
-
|
|
345
|
+
await installSession(refreshToken, session.jwt);
|
|
355
346
|
if (accessKeyModule && accessKeyPrep && webauthn) {
|
|
356
347
|
await accessKeyModule.finalizeAccessKey({
|
|
357
348
|
address: session.address,
|
|
@@ -369,24 +360,24 @@ export function createFlow(options = {}) {
|
|
|
369
360
|
return session;
|
|
370
361
|
}
|
|
371
362
|
/**
|
|
372
|
-
* Performs a full sign-out: tells the
|
|
373
|
-
* (so other Flow apps can't silently restore
|
|
374
|
-
*
|
|
363
|
+
* Performs a full sign-out: tells the id.flow.industries dialog to
|
|
364
|
+
* invalidate its cookie session (so other Flow apps can't silently restore
|
|
365
|
+
* it), tells the app's session route to revoke the refresh lineage and
|
|
366
|
+
* clear the HttpOnly cookies, then clears local credential and access-key
|
|
367
|
+
* state and resets the in-memory store.
|
|
375
368
|
*
|
|
376
|
-
*
|
|
369
|
+
* Both server-side steps are best-effort — if a network call fails (e.g.,
|
|
377
370
|
* offline) we still clear local state so the UI reflects "signed out". The
|
|
378
|
-
*
|
|
371
|
+
* cookies and tokens eventually expire on their own.
|
|
379
372
|
*/
|
|
380
373
|
async function logout() {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
374
|
+
// Both server-side sign-outs are independent and best-effort — if either
|
|
375
|
+
// fails (offline?) local state still clears so the UI reads "signed out".
|
|
376
|
+
await Promise.allSettled([
|
|
377
|
+
getDialog().requestSilent(METHODS.signOut, []),
|
|
378
|
+
fetch(sessionPath, { method: "DELETE" }),
|
|
379
|
+
]);
|
|
388
380
|
await runLogout(store);
|
|
389
|
-
refreshStore.clear();
|
|
390
381
|
dialog?.close();
|
|
391
382
|
// An autoGuest app is never truly "signed out" — it always wants at least a
|
|
392
383
|
// guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
1
2
|
export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
|
|
2
3
|
export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
|
|
3
4
|
export { createDialogHost } from "./dialog-host";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { resolveIdHost } from "../id-host";
|
|
1
2
|
import { getFlow, requireFlow } from "./create-flow";
|
|
2
3
|
import { bridgeToWindow, makeIframe } from "./iframe-host";
|
|
3
|
-
const DEFAULT_HOST = "https://id.flow.industries";
|
|
4
4
|
const POSITION_STYLE = {
|
|
5
5
|
"top-right": { top: "0", right: "0" },
|
|
6
6
|
"top-left": { top: "0", left: "0" },
|
|
@@ -31,7 +31,7 @@ export function createProfileButton(options) {
|
|
|
31
31
|
if (typeof document === "undefined")
|
|
32
32
|
return { destroy() { } };
|
|
33
33
|
const flow = options.flow ?? getFlow() ?? requireFlow();
|
|
34
|
-
const host = (options.host ?? flow.host
|
|
34
|
+
const host = resolveIdHost(options.host ?? flow.host);
|
|
35
35
|
const hostOrigin = new URL(host).origin;
|
|
36
36
|
const theme = options.theme ?? "light dark";
|
|
37
37
|
let createdContainer = null;
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
import type { RefreshCookieConfig } from "../types";
|
|
2
2
|
/**
|
|
3
|
-
* First-party persistence for the rotating refresh token
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* cookie is the sole store. The app's own server reads and rotates it
|
|
9
|
-
* during SSR, so any second copy only drifts behind on each rotation —
|
|
10
|
-
* and presenting a superseded token looks like theft to the server's
|
|
11
|
-
* reuse detection, which revokes the whole lineage (self-logout).
|
|
12
|
-
*
|
|
13
|
-
* - Without one (a consumer with no server to share the token with), it
|
|
14
|
-
* lives in the app's own localStorage: origin-scoped, survives reloads,
|
|
15
|
-
* and — unlike the old id.flow.industries cookie — never third-party, so
|
|
16
|
-
* iOS ITP can't block it.
|
|
3
|
+
* First-party persistence for the rotating refresh token. It lives in the
|
|
4
|
+
* consumer app's OWN localStorage (origin-scoped), so it survives reloads and
|
|
5
|
+
* navigations and — unlike the old id.flow.industries cookie — is never a
|
|
6
|
+
* third-party cookie, so iOS ITP can't block it. Keyed by `host` so pointing
|
|
7
|
+
* the SDK at a different Flow ID origin (dev vs prod) doesn't collide.
|
|
17
8
|
*/
|
|
18
9
|
export interface RefreshStore {
|
|
19
10
|
get(): string | null;
|
|
20
11
|
set(token: string): void;
|
|
21
12
|
clear(): void;
|
|
22
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* With a cookie config (`createFlow({ cookies: true })`) the token is
|
|
16
|
+
* additionally mirrored into a first-party cookie so the app's own server can
|
|
17
|
+
* refresh the session during SSR. Reads prefer the cookie: the only writer
|
|
18
|
+
* that updates one side without the other is that server (Set-Cookie only),
|
|
19
|
+
* and its value is always a successor of whatever localStorage still holds;
|
|
20
|
+
* every client-side write hits both in the same call.
|
|
21
|
+
*/
|
|
23
22
|
export declare function makeRefreshStore(host: string, cookie?: RefreshCookieConfig): RefreshStore;
|
|
@@ -1,76 +1,66 @@
|
|
|
1
1
|
import { clearBrowserCookie, REFRESH_COOKIE_MAX_AGE_S, readBrowserCookie, writeBrowserCookie, } from "../cookies";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* With a cookie config (`createFlow({ cookies: true })`) the token is
|
|
4
|
+
* additionally mirrored into a first-party cookie so the app's own server can
|
|
5
|
+
* refresh the session during SSR. Reads prefer the cookie: the only writer
|
|
6
|
+
* that updates one side without the other is that server (Set-Cookie only),
|
|
7
|
+
* and its value is always a successor of whatever localStorage still holds;
|
|
8
|
+
* every client-side write hits both in the same call.
|
|
9
|
+
*/
|
|
10
|
+
export function makeRefreshStore(host, cookie) {
|
|
11
|
+
const key = `flow.id.refresh:${host}`;
|
|
12
|
+
const readLocal = () => {
|
|
13
|
+
try {
|
|
14
|
+
return localStorage.getItem(key);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const readCookie = () => {
|
|
21
|
+
if (!cookie || typeof document === "undefined")
|
|
22
|
+
return null;
|
|
23
|
+
return readBrowserCookie(document.cookie, cookie.name);
|
|
24
|
+
};
|
|
25
|
+
const store = {
|
|
4
26
|
get() {
|
|
5
|
-
|
|
6
|
-
return localStorage.getItem(key);
|
|
7
|
-
}
|
|
8
|
-
catch {
|
|
9
|
-
return null;
|
|
10
|
-
}
|
|
27
|
+
return readCookie() ?? readLocal();
|
|
11
28
|
},
|
|
12
29
|
set(token) {
|
|
30
|
+
// Never persist a missing token: localStorage.setItem coerces undefined
|
|
31
|
+
// to the literal "undefined", which later reads back as a bogus bearer
|
|
32
|
+
// and 401s as refresh_token_invalid. Fail safe by ignoring it instead.
|
|
33
|
+
if (!token)
|
|
34
|
+
return;
|
|
13
35
|
try {
|
|
14
36
|
localStorage.setItem(key, token);
|
|
15
37
|
}
|
|
16
38
|
catch { }
|
|
39
|
+
if (cookie) {
|
|
40
|
+
writeBrowserCookie(cookie.name, token, {
|
|
41
|
+
maxAge: REFRESH_COOKIE_MAX_AGE_S,
|
|
42
|
+
secure: cookie.secure,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
17
45
|
},
|
|
18
46
|
clear() {
|
|
19
47
|
try {
|
|
20
48
|
localStorage.removeItem(key);
|
|
21
49
|
}
|
|
22
50
|
catch { }
|
|
51
|
+
if (cookie)
|
|
52
|
+
clearBrowserCookie(cookie.name, { secure: cookie.secure });
|
|
23
53
|
},
|
|
24
54
|
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
secure: cookie.secure,
|
|
35
|
-
});
|
|
36
|
-
},
|
|
37
|
-
clear() {
|
|
38
|
-
clearBrowserCookie(cookie.name, { secure: cookie.secure });
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* Never persist a missing token: both backends coerce undefined to the
|
|
44
|
-
* literal "undefined", which later reads back as a bogus bearer and 401s as
|
|
45
|
-
* refresh_token_invalid. Fail safe by ignoring it instead.
|
|
46
|
-
*/
|
|
47
|
-
function withMissingTokenGuard(store) {
|
|
48
|
-
return {
|
|
49
|
-
...store,
|
|
50
|
-
set(token) {
|
|
51
|
-
if (!token)
|
|
52
|
-
return;
|
|
53
|
-
store.set(token);
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
export function makeRefreshStore(host, cookie) {
|
|
58
|
-
const legacy = localStorageStore(`flow.id.refresh:${host}`);
|
|
59
|
-
if (!cookie)
|
|
60
|
-
return withMissingTokenGuard(legacy);
|
|
61
|
-
const store = cookieStore(cookie);
|
|
62
|
-
// One-time takeover from the dual-write SDK (<= 0.10.0), which kept a
|
|
63
|
-
// localStorage copy: promote it when no cookie exists yet (a session
|
|
64
|
-
// predating the app's cookie mode), then drop the key — a leftover copy
|
|
65
|
-
// goes stale on the next SSR rotation and replaying it trips lineage
|
|
66
|
-
// revocation. Remove this block once every legacy token has rotated or
|
|
67
|
-
// expired (REFRESH_COOKIE_MAX_AGE_S after 0.11.0 reaches all cookie-mode
|
|
68
|
-
// apps).
|
|
69
|
-
const legacyToken = legacy.get();
|
|
70
|
-
if (legacyToken) {
|
|
71
|
-
if (!store.get())
|
|
72
|
-
store.set(legacyToken);
|
|
73
|
-
legacy.clear();
|
|
55
|
+
// One-time reconcile so exactly one canonical value exists before the first
|
|
56
|
+
// refresh: an SSR rotation updates only the cookie, leaving localStorage one
|
|
57
|
+
// generation behind — presenting that stale token would look like reuse.
|
|
58
|
+
const fromCookie = readCookie();
|
|
59
|
+
if (fromCookie && fromCookie !== readLocal()) {
|
|
60
|
+
try {
|
|
61
|
+
localStorage.setItem(key, fromCookie);
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
74
64
|
}
|
|
75
|
-
return
|
|
65
|
+
return store;
|
|
76
66
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { resolveIdHost } from "../id-host";
|
|
1
2
|
import { createRoomsApi } from "./rooms";
|
|
2
|
-
const DEFAULT_HOST = "https://id.flow.industries";
|
|
3
3
|
function unavailable(method) {
|
|
4
4
|
throw new Error(`flow.${method} is not available on a static Flow — it renders ` +
|
|
5
5
|
"server-resolved state only. Interactive methods need the browser " +
|
|
@@ -16,7 +16,7 @@ function unavailable(method) {
|
|
|
16
16
|
* API); anything interactive (login, dialog, signing) throws.
|
|
17
17
|
*/
|
|
18
18
|
export function createStaticFlow(state, options = {}) {
|
|
19
|
-
const host = (options.host
|
|
19
|
+
const host = resolveIdHost(options.host);
|
|
20
20
|
const snapshot = Object.freeze({
|
|
21
21
|
user: state?.user ?? null,
|
|
22
22
|
jwt: state?.jwt ?? null,
|
package/dist/sdk/cookies.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* First-party cookie names and (de)serialization
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Isomorphic: no browser globals at import time.
|
|
2
|
+
* First-party session cookie names and (de)serialization. Cookies are owned
|
|
3
|
+
* exclusively by the app's server (the SSR resolver and the session route) —
|
|
4
|
+
* page JavaScript never reads or writes them, which is what lets them be
|
|
5
|
+
* HttpOnly. Isomorphic module: no browser globals at import time.
|
|
6
6
|
*/
|
|
7
7
|
import type { FlowCookieNames } from "./types";
|
|
8
8
|
export declare const JWT_COOKIE_MAX_AGE_S: number;
|
|
@@ -17,10 +17,10 @@ export declare const REFRESH_COOKIE_MAX_AGE_S: number;
|
|
|
17
17
|
*/
|
|
18
18
|
export declare function cookieNamesFor(origin: string): FlowCookieNames;
|
|
19
19
|
/**
|
|
20
|
-
* Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`,
|
|
21
|
+
* always `HttpOnly` (only the app's server touches these cookies — an XSS
|
|
22
|
+
* payload can never read the refresh token); `Secure` everywhere except
|
|
23
|
+
* plain-http localhost (Safari drops Secure cookies set over http).
|
|
24
24
|
*/
|
|
25
25
|
export declare function serializeCookie(name: string, value: string, opts: {
|
|
26
26
|
maxAge: number;
|
|
@@ -32,14 +32,3 @@ export declare function clearCookieString(name: string, opts: {
|
|
|
32
32
|
}): string;
|
|
33
33
|
/** Parses a Cookie request header into name → value; first occurrence wins (RFC 6265 practice). */
|
|
34
34
|
export declare function parseCookieHeader(header: string | null | undefined): Record<string, string>;
|
|
35
|
-
/** Reads one cookie from a `document.cookie` string. */
|
|
36
|
-
export declare function readBrowserCookie(cookieString: string, name: string): string | null;
|
|
37
|
-
/** Writes a cookie in the browser; no-op outside it. */
|
|
38
|
-
export declare function writeBrowserCookie(name: string, value: string, opts: {
|
|
39
|
-
maxAge: number;
|
|
40
|
-
secure: boolean;
|
|
41
|
-
}): void;
|
|
42
|
-
/** Deletes a cookie in the browser; no-op outside it. */
|
|
43
|
-
export declare function clearBrowserCookie(name: string, opts: {
|
|
44
|
-
secure: boolean;
|
|
45
|
-
}): void;
|
package/dist/sdk/cookies.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* First-party cookie names and (de)serialization
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Isomorphic: no browser globals at import time.
|
|
2
|
+
* First-party session cookie names and (de)serialization. Cookies are owned
|
|
3
|
+
* exclusively by the app's server (the SSR resolver and the session route) —
|
|
4
|
+
* page JavaScript never reads or writes them, which is what lets them be
|
|
5
|
+
* HttpOnly. Isomorphic module: no browser globals at import time.
|
|
6
6
|
*/
|
|
7
7
|
export const JWT_COOKIE_MAX_AGE_S = 60 * 60;
|
|
8
8
|
export const REFRESH_COOKIE_MAX_AGE_S = 30 * 24 * 60 * 60;
|
|
@@ -34,14 +34,14 @@ export function cookieNamesFor(origin) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path
|
|
38
|
-
* `
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`,
|
|
38
|
+
* always `HttpOnly` (only the app's server touches these cookies — an XSS
|
|
39
|
+
* payload can never read the refresh token); `Secure` everywhere except
|
|
40
|
+
* plain-http localhost (Safari drops Secure cookies set over http).
|
|
41
41
|
*/
|
|
42
42
|
export function serializeCookie(name, value, opts) {
|
|
43
43
|
const secure = opts.secure ? "; Secure" : "";
|
|
44
|
-
return `${name}=${value}; Path=/; SameSite=Lax; Max-Age=${opts.maxAge}${secure}`;
|
|
44
|
+
return `${name}=${value}; Path=/; SameSite=Lax; Max-Age=${opts.maxAge}; HttpOnly${secure}`;
|
|
45
45
|
}
|
|
46
46
|
/** Builds a Set-Cookie value that deletes the cookie. */
|
|
47
47
|
export function clearCookieString(name, opts) {
|
|
@@ -63,19 +63,3 @@ export function parseCookieHeader(header) {
|
|
|
63
63
|
}
|
|
64
64
|
return out;
|
|
65
65
|
}
|
|
66
|
-
/** Reads one cookie from a `document.cookie` string. */
|
|
67
|
-
export function readBrowserCookie(cookieString, name) {
|
|
68
|
-
return parseCookieHeader(cookieString)[name] ?? null;
|
|
69
|
-
}
|
|
70
|
-
/** Writes a cookie in the browser; no-op outside it. */
|
|
71
|
-
export function writeBrowserCookie(name, value, opts) {
|
|
72
|
-
if (typeof document === "undefined")
|
|
73
|
-
return;
|
|
74
|
-
document.cookie = serializeCookie(name, value, opts);
|
|
75
|
-
}
|
|
76
|
-
/** Deletes a cookie in the browser; no-op outside it. */
|
|
77
|
-
export function clearBrowserCookie(name, opts) {
|
|
78
|
-
if (typeof document === "undefined")
|
|
79
|
-
return;
|
|
80
|
-
document.cookie = clearCookieString(name, opts);
|
|
81
|
-
}
|