@flow-industries/id 0.11.0 → 0.13.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 +131 -114
- package/dist/sdk/client/index.d.ts +2 -1
- 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/session.js +2 -1
- 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 +52 -0
- package/dist/sdk/start/index.js +58 -0
- package/dist/sdk/start/server.d.ts +33 -0
- package/dist/sdk/start/server.js +35 -0
- package/dist/sdk/types/auth.d.ts +13 -0
- package/dist/sdk/types/events.d.ts +1 -1
- package/dist/sdk/types/index.d.ts +3 -3
- package/dist/sdk/types/protocol.d.ts +12 -2
- package/dist/sdk/types/sdk.d.ts +25 -12
- 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,106 @@ 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 delivery of any additional-audience sessions a mint returned
|
|
156
|
+
* (AUTH-39). Handed straight to the consumer's callback and dropped — the
|
|
157
|
+
* SDK retains nothing; the consumer forwards each to its audience's own
|
|
158
|
+
* origin (the embed bootstrap) to be sealed HttpOnly there.
|
|
159
|
+
*/
|
|
160
|
+
function deliverAdditionalSessions(sessions) {
|
|
161
|
+
if (!sessions?.length || !options.onAdditionalSessions)
|
|
162
|
+
return;
|
|
163
|
+
try {
|
|
164
|
+
options.onAdditionalSessions(sessions);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.warn("Flow ID: onAdditionalSessions handler threw", err);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* One-time handoff of a freshly minted session (dialog login or guest
|
|
172
|
+
* mint) to the app's server, which verifies the JWT against the issuer's
|
|
173
|
+
* JWKS and sets the HttpOnly cookies — the refresh token is never stored
|
|
174
|
+
* where page JavaScript could read it back. Best-effort: without a
|
|
175
|
+
* reachable session route the in-memory session still works, it just
|
|
176
|
+
* can't survive a reload.
|
|
177
|
+
*/
|
|
178
|
+
async function installSession(refreshToken, jwt) {
|
|
179
|
+
if (!refreshToken || !jwt)
|
|
180
|
+
return;
|
|
157
181
|
try {
|
|
158
|
-
const res = await fetch(
|
|
182
|
+
const res = await fetch(sessionPath, {
|
|
159
183
|
method: "POST",
|
|
160
|
-
headers: {
|
|
184
|
+
headers: { "Content-Type": "application/json" },
|
|
185
|
+
body: JSON.stringify({ refreshToken, jwt }),
|
|
161
186
|
});
|
|
162
187
|
if (!res.ok) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return false;
|
|
188
|
+
console.warn(`Flow ID: session route ${sessionPath} answered ${res.status}; ` +
|
|
189
|
+
"the session will not survive a reload");
|
|
166
190
|
}
|
|
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);
|
|
180
|
-
}
|
|
181
|
-
return true;
|
|
182
191
|
}
|
|
183
192
|
catch {
|
|
184
|
-
|
|
193
|
+
console.warn(`Flow ID: no session route at ${sessionPath}; ` +
|
|
194
|
+
"the session will not survive a reload");
|
|
185
195
|
}
|
|
186
196
|
}
|
|
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.
|
|
197
|
+
// Every session-route call — boot, ensureGuest, and getToken — funnels
|
|
198
|
+
// through this one lock so concurrent tabs don't fan out parallel
|
|
199
|
+
// rotations. The server dedupes and the auth server's grace window absorbs
|
|
200
|
+
// stragglers, but serializing here means the common case is one rotation,
|
|
201
|
+
// and the guard short-circuits a caller that arrives after a concurrent
|
|
202
|
+
// holder already minted a fresh JWT.
|
|
196
203
|
async function doRefresh() {
|
|
197
|
-
return
|
|
204
|
+
return withOriginLock("flow.id.refresh", async () => {
|
|
198
205
|
const current = store.getSnapshot().jwt;
|
|
199
206
|
if (current && !isExpiring(current))
|
|
200
207
|
return true;
|
|
201
|
-
return
|
|
208
|
+
return refreshViaSession();
|
|
202
209
|
});
|
|
203
210
|
}
|
|
204
211
|
const refreshJwt = doRefresh;
|
|
@@ -247,19 +254,20 @@ export function createFlow(options = {}) {
|
|
|
247
254
|
if (store.getSnapshot().user)
|
|
248
255
|
return true;
|
|
249
256
|
if (!guestInFlight) {
|
|
250
|
-
guestInFlight =
|
|
257
|
+
guestInFlight = withOriginLock("flow.id.guest", async () => {
|
|
251
258
|
// 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.
|
|
259
|
+
// we waited. Refresh-first (from this origin's session cookie)
|
|
260
|
+
// recovers an existing session instead of minting a duplicate guest.
|
|
254
261
|
if (store.getSnapshot().user)
|
|
255
262
|
return true;
|
|
256
263
|
if (await doRefresh())
|
|
257
264
|
return true;
|
|
258
265
|
try {
|
|
259
|
-
const result = await getDialog().requestSilent(METHODS.guest,
|
|
266
|
+
const result = await getDialog().requestSilent(METHODS.guest, options.additionalAudiences?.length
|
|
267
|
+
? [{ additionalAudiences: options.additionalAudiences }]
|
|
268
|
+
: []);
|
|
260
269
|
if (!result.user)
|
|
261
270
|
return false;
|
|
262
|
-
refreshStore.set(result.refreshToken);
|
|
263
271
|
store.setState({
|
|
264
272
|
user: result.user,
|
|
265
273
|
jwt: result.jwt,
|
|
@@ -268,6 +276,8 @@ export function createFlow(options = {}) {
|
|
|
268
276
|
// address already in state rather than stripping signing.
|
|
269
277
|
...(result.user.isGuest ? { credential: null, address: null } : {}),
|
|
270
278
|
});
|
|
279
|
+
await installSession(result.refreshToken, result.jwt);
|
|
280
|
+
deliverAdditionalSessions(result.additionalSessions);
|
|
271
281
|
return true;
|
|
272
282
|
}
|
|
273
283
|
catch {
|
|
@@ -303,7 +313,7 @@ export function createFlow(options = {}) {
|
|
|
303
313
|
getState: () => store.getSnapshot(),
|
|
304
314
|
getChain,
|
|
305
315
|
getTransport,
|
|
306
|
-
...(
|
|
316
|
+
...(rpId ? { rpId } : {}),
|
|
307
317
|
...(accessKeyOptions?.strict ? { strict: accessKeyOptions.strict } : {}),
|
|
308
318
|
};
|
|
309
319
|
}
|
|
@@ -345,13 +355,20 @@ export function createFlow(options = {}) {
|
|
|
345
355
|
extraCapabilities = { ...(extraCapabilities ?? {}), guestToken };
|
|
346
356
|
}
|
|
347
357
|
}
|
|
348
|
-
|
|
358
|
+
if (options.additionalAudiences?.length) {
|
|
359
|
+
extraCapabilities = {
|
|
360
|
+
...(extraCapabilities ?? {}),
|
|
361
|
+
additionalAudiences: options.additionalAudiences,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const { session, webauthn, refreshToken, additionalSessions } = await runLogin({
|
|
349
365
|
dialog: dialogHost,
|
|
350
366
|
store,
|
|
351
367
|
options: loginOpts,
|
|
352
368
|
...(extraCapabilities ? { extraCapabilities } : {}),
|
|
353
369
|
});
|
|
354
|
-
|
|
370
|
+
await installSession(refreshToken, session.jwt);
|
|
371
|
+
deliverAdditionalSessions(additionalSessions);
|
|
355
372
|
if (accessKeyModule && accessKeyPrep && webauthn) {
|
|
356
373
|
await accessKeyModule.finalizeAccessKey({
|
|
357
374
|
address: session.address,
|
|
@@ -369,24 +386,24 @@ export function createFlow(options = {}) {
|
|
|
369
386
|
return session;
|
|
370
387
|
}
|
|
371
388
|
/**
|
|
372
|
-
* Performs a full sign-out: tells the
|
|
373
|
-
* (so other Flow apps can't silently restore
|
|
374
|
-
*
|
|
389
|
+
* Performs a full sign-out: tells the id.flow.industries dialog to
|
|
390
|
+
* invalidate its cookie session (so other Flow apps can't silently restore
|
|
391
|
+
* it), tells the app's session route to revoke the refresh lineage and
|
|
392
|
+
* clear the HttpOnly cookies, then clears local credential and access-key
|
|
393
|
+
* state and resets the in-memory store.
|
|
375
394
|
*
|
|
376
|
-
*
|
|
395
|
+
* Both server-side steps are best-effort — if a network call fails (e.g.,
|
|
377
396
|
* offline) we still clear local state so the UI reflects "signed out". The
|
|
378
|
-
*
|
|
397
|
+
* cookies and tokens eventually expire on their own.
|
|
379
398
|
*/
|
|
380
399
|
async function logout() {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
400
|
+
// Both server-side sign-outs are independent and best-effort — if either
|
|
401
|
+
// fails (offline?) local state still clears so the UI reads "signed out".
|
|
402
|
+
await Promise.allSettled([
|
|
403
|
+
getDialog().requestSilent(METHODS.signOut, []),
|
|
404
|
+
fetch(sessionPath, { method: "DELETE" }),
|
|
405
|
+
]);
|
|
388
406
|
await runLogout(store);
|
|
389
|
-
refreshStore.clear();
|
|
390
407
|
dialog?.close();
|
|
391
408
|
// An autoGuest app is never truly "signed out" — it always wants at least a
|
|
392
409
|
// guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { defaultIdHost, isLocalHostname } from "../id-host";
|
|
2
|
+
export type { AccessKeyOptions, AdditionalSession, 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";
|
|
4
5
|
export { METHODS } from "./methods";
|
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
|
}
|
|
@@ -40,7 +40,7 @@ export async function runLogin(params) {
|
|
|
40
40
|
const result = (await dialog.request(METHODS.connect, [
|
|
41
41
|
{ capabilities },
|
|
42
42
|
]));
|
|
43
|
-
const { jwt, refreshToken, user, credential, webauthn } = result;
|
|
43
|
+
const { jwt, refreshToken, user, credential, webauthn, additionalSessions } = result;
|
|
44
44
|
const address = credentialToAddress(credential);
|
|
45
45
|
await idb.set(CREDENTIAL_KEY, credential);
|
|
46
46
|
store.setState({ user, jwt, credential, address });
|
|
@@ -48,6 +48,7 @@ export async function runLogin(params) {
|
|
|
48
48
|
session: { user, jwt, credential, address },
|
|
49
49
|
refreshToken,
|
|
50
50
|
webauthn,
|
|
51
|
+
...(additionalSessions ? { additionalSessions } : {}),
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
@@ -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;
|