@flow-industries/id 0.22.1 → 0.23.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.
@@ -2,9 +2,8 @@ import { DEFAULT_SESSION_PATH, isLocalHostname, resolveIdHost, } from "../id-hos
2
2
  import { isExpiring } from "../token-expiry";
3
3
  import { createDialogHost } from "./dialog-host";
4
4
  import { idb } from "./idb";
5
- import { METHODS } from "./methods";
6
5
  import { createRoomsApi } from "./rooms";
7
- import { credentialToAddress, restoreCredential, runLogin, runLogout, } from "./session";
6
+ import { credentialToAddress, runLogout } from "./session";
8
7
  import { createStore, initialFlowState } from "./store";
9
8
  /**
10
9
  * Runs `fn` while holding a cross-tab lock (Web Locks API — origin-scoped by
@@ -73,7 +72,7 @@ export function createFlow(options = {}) {
73
72
  if (currentFlow)
74
73
  return currentFlow;
75
74
  const host = resolveIdHost(options.host);
76
- const dialogUrl = `${host}/dialog/`;
75
+ const dialogUrl = `${host}/dialog/?v=2`;
77
76
  const sessionPath = options.sessionPath ?? DEFAULT_SESSION_PATH;
78
77
  const rpId = options.rpId ??
79
78
  (isLocalHostname(window.location.hostname) ? "localhost" : undefined);
@@ -111,7 +110,13 @@ export function createFlow(options = {}) {
111
110
  * rotation response is authoritative for signing state; the JWT hot path
112
111
  * knows neither, and overwriting would erase state restored from IDB.
113
112
  */
113
+ let additionalSubject = null;
114
114
  function commitSession(session) {
115
+ const previous = store.getSnapshot().user?.id;
116
+ if (previous && previous !== session.user.id) {
117
+ store.setState({ credential: null, address: null });
118
+ void idb.delete("flow.activeCredential");
119
+ }
115
120
  const next = {
116
121
  user: session.user,
117
122
  jwt: session.jwt,
@@ -121,6 +126,18 @@ export function createFlow(options = {}) {
121
126
  next.address = session.address ?? null;
122
127
  }
123
128
  store.setState(next);
129
+ if (additionalSubject !== session.user.id &&
130
+ options.additionalAudiences?.length) {
131
+ additionalSubject = session.user.id;
132
+ void Promise.all(options.additionalAudiences.map(mintAudience))
133
+ .then((sessions) => {
134
+ if (store.getSnapshot().user?.id !== session.user.id)
135
+ return;
136
+ const minted = sessions.filter((session) => session !== null);
137
+ options.onAdditionalSessions?.(minted);
138
+ })
139
+ .catch(() => { });
140
+ }
124
141
  // A guest carries a null credential; never persist that — the IDB store is
125
142
  // reserved for a real passkey credential and writing null would erase a
126
143
  // previously stored one.
@@ -128,6 +145,41 @@ export function createFlow(options = {}) {
128
145
  void idb.set("flow.activeCredential", session.credential);
129
146
  }
130
147
  }
148
+ async function recoverAccessKey(state, loginCompletion) {
149
+ const completionKey = "flow.pendingAccessKey.completion";
150
+ if (loginCompletion)
151
+ await idb.set(completionKey, {
152
+ ...loginCompletion,
153
+ expires: Date.now() + 600_000,
154
+ });
155
+ const completion = loginCompletion ??
156
+ (await idb.get(completionKey));
157
+ if (completion && completion.userId === state.user.id) {
158
+ const key = `flow.pendingAccessKey.${completion.loginId}`;
159
+ const pending = await idb.get(key);
160
+ if (pending && pending.expires > Date.now() && completion.webauthn) {
161
+ const fresh = await fetch(`${sessionPath}?refresh=1`);
162
+ if (!fresh.ok)
163
+ throw new Error("Could not restore signing state");
164
+ // SAFETY: the app session handler returns the SDK protocol response.
165
+ const resolved = (await fresh.json());
166
+ if (resolved.state?.user.id === completion.userId &&
167
+ resolved.state.credential &&
168
+ resolved.state.address) {
169
+ commitSession(resolved.state);
170
+ const { finalizeAccessKey } = await import("./access-key");
171
+ await finalizeAccessKey({
172
+ address: resolved.state.address,
173
+ credential: resolved.state.credential,
174
+ webauthn: completion.webauthn,
175
+ preparation: pending.preparation,
176
+ });
177
+ await idb.delete(key);
178
+ await idb.delete(completionKey);
179
+ }
180
+ }
181
+ }
182
+ }
131
183
  /**
132
184
  * Resolves a fresh session from the app's own session route. The refresh
133
185
  * token lives in an HttpOnly cookie only that route's server can read, so
@@ -144,10 +196,11 @@ export function createFlow(options = {}) {
144
196
  if (!res.ok)
145
197
  return false;
146
198
  // SAFETY: the app's own /flow/session route answers this shape.
147
- const { state } = (await res.json());
199
+ const { state, loginCompletion } = (await res.json());
148
200
  if (!state)
149
201
  return false;
150
202
  commitSession(state);
203
+ await recoverAccessKey(state, loginCompletion).catch(() => { });
151
204
  return true;
152
205
  }
153
206
  catch {
@@ -179,49 +232,6 @@ export function createFlow(options = {}) {
179
232
  return null;
180
233
  }
181
234
  }
182
- /**
183
- * One-time delivery of any additional-audience sessions a mint returned
184
- * (AUTH-39). Handed straight to the consumer's callback and dropped — the
185
- * SDK retains nothing; the consumer forwards each to its audience's own
186
- * origin (the embed bootstrap) to be sealed HttpOnly there.
187
- */
188
- function deliverAdditionalSessions(sessions) {
189
- if (!sessions?.length || !options.onAdditionalSessions)
190
- return;
191
- try {
192
- options.onAdditionalSessions(sessions);
193
- }
194
- catch (err) {
195
- console.warn("Flow ID: onAdditionalSessions handler threw", err);
196
- }
197
- }
198
- /**
199
- * One-time handoff of a freshly minted session (dialog login or guest
200
- * mint) to the app's server, which verifies the JWT against the issuer's
201
- * JWKS and sets the HttpOnly cookies — the refresh token is never stored
202
- * where page JavaScript could read it back. Best-effort: without a
203
- * reachable session route the in-memory session still works, it just
204
- * can't survive a reload.
205
- */
206
- async function installSession(refreshToken, jwt) {
207
- if (!refreshToken || !jwt)
208
- return;
209
- try {
210
- const res = await fetch(sessionPath, {
211
- method: "POST",
212
- headers: { "Content-Type": "application/json" },
213
- body: JSON.stringify({ refreshToken, jwt }),
214
- });
215
- if (!res.ok) {
216
- console.warn(`Flow ID: session route ${sessionPath} answered ${res.status}; ` +
217
- "the session will not survive a reload");
218
- }
219
- }
220
- catch {
221
- console.warn(`Flow ID: no session route at ${sessionPath}; ` +
222
- "the session will not survive a reload");
223
- }
224
- }
225
235
  // Every session-route call — boot, ensureGuest, and getToken — funnels
226
236
  // through this one lock so concurrent tabs don't fan out parallel
227
237
  // rotations. The server dedupes and the auth server's grace window absorbs
@@ -291,22 +301,18 @@ export function createFlow(options = {}) {
291
301
  if (await doRefresh())
292
302
  return true;
293
303
  try {
294
- const result = await getDialog().requestSilent(METHODS.guest, options.additionalAudiences?.length
295
- ? [{ additionalAudiences: options.additionalAudiences }]
296
- : []);
297
- if (!result.user)
298
- return false;
299
- store.setState({
300
- user: result.user,
301
- jwt: result.jwt,
302
- // Only a guest has no credential. The server's idempotency path can
303
- // return a full session here; in that case keep any credential/
304
- // address already in state rather than stripping signing.
305
- credential: result.user.isGuest ? null : undefined,
306
- address: result.user.isGuest ? null : undefined,
304
+ const response = await fetch(sessionPath, {
305
+ method: "POST",
306
+ headers: { "Content-Type": "application/json" },
307
+ body: JSON.stringify({ action: "guest" }),
307
308
  });
308
- await installSession(result.refreshToken, result.jwt);
309
- deliverAdditionalSessions(result.additionalSessions);
309
+ if (!response.ok)
310
+ return false;
311
+ // SAFETY: the app session handler returns the SDK protocol response.
312
+ const result = (await response.json());
313
+ if (!result.state)
314
+ return false;
315
+ commitSession(result.state);
310
316
  return true;
311
317
  }
312
318
  catch {
@@ -319,24 +325,26 @@ export function createFlow(options = {}) {
319
325
  return guestInFlight;
320
326
  }
321
327
  void (async () => {
322
- await restoreCredential(store);
323
- // A fresh hydrated JWT means the server already refreshed this request —
324
- // running doRefresh() here would rotate a second time for nothing.
325
- const hydrated = store.getSnapshot().jwt;
326
- if (hydrated && !isExpiring(hydrated))
327
- return;
328
+ void idb.pruneExpired("flow.pendingAccessKey.").catch(() => { });
328
329
  if (options.autoRestore !== false) {
329
- const restored = await doRefresh();
330
- // ensureGuest re-checks (refresh-first) under a cross-tab lock before
331
- // minting, so calling it after a failed refresh can't fork a visitor into
332
- // duplicate guests across tabs — the extra refresh is the dedup.
333
- if (!restored && options.autoGuest)
330
+ const restored = await refreshViaSession();
331
+ if (restored) {
332
+ const response = await fetch(`${sessionPath}?refresh=1`);
333
+ if (response.ok) {
334
+ // SAFETY: the app session handler returns the SDK protocol response.
335
+ const result = (await response.json());
336
+ if (result.state)
337
+ commitSession(result.state);
338
+ else
339
+ await runLogout(store);
340
+ }
341
+ }
342
+ else if (options.autoGuest)
334
343
  await ensureGuest();
335
344
  }
336
- else if (options.autoGuest) {
345
+ else if (options.autoGuest)
337
346
  await ensureGuest();
338
- }
339
- })();
347
+ })().catch(() => { });
340
348
  function buildSigningContext() {
341
349
  return {
342
350
  getState: () => store.getSnapshot(),
@@ -346,107 +354,108 @@ export function createFlow(options = {}) {
346
354
  strict: accessKeyOptions?.strict ? accessKeyOptions.strict : undefined,
347
355
  };
348
356
  }
349
- /**
350
- * Opens the dialog iframe and runs the interactive sign-in or sign-up flow.
351
- * Resolves with the resulting Session once the user completes authentication;
352
- * rejects if the user cancels or the dialog fails.
353
- *
354
- * If access keys are enabled, this also derives an ephemeral P256 key pair
355
- * locally and signs a KeyAuthorization with the passkey during the same
356
- * WebAuthn ceremony — so future signing calls can use the access key without
357
- * prompting for a passkey tap each time. The KeyAuthorization is finalized
358
- * (persisted to IDB) only after the dialog confirms login succeeded.
359
- */
357
+ /** Starts top-level authentication; successful navigation never resolves in this document. */
360
358
  async function login(loginOpts = {}) {
361
- const dialogHost = getDialog();
362
- dialogHost.captureFocus();
363
- let accessKeyModule;
364
- let accessKeyPrep;
365
- let extraCapabilities;
359
+ if (window.top !== window) {
360
+ throw new Error("Open sign-in from the containing app.");
361
+ }
362
+ let preparation;
366
363
  if (accessKeyOptions) {
367
364
  const chain = getChain();
368
- if (!chain) {
369
- throw new Error("accessKey requires chains to be configured");
370
- }
371
- accessKeyModule = await import("./access-key");
372
- accessKeyPrep = await accessKeyModule.prepareAccessKey(accessKeyOptions, chain.id);
373
- extraCapabilities = { accessKeyHash: accessKeyPrep.accessKeyHash };
374
- }
375
- // When the current session is a guest, forward its access token so a
376
- // sign-up upgrades that guest in place even where the guest cookie is
377
- // unreadable (the iOS top-level sign-up popup). Mint via getToken() rather
378
- // than reading store.jwt directly: the cached JWT can be up to an hour stale
379
- // on a long-idle tab, and an expired token fails server verification — the
380
- // guest would then be registered as a fresh account instead of upgraded.
381
- // An app that never becomes a guest forwards nothing.
382
- if (store.getSnapshot().user?.isGuest) {
383
- const guestToken = await getToken();
384
- if (guestToken) {
385
- extraCapabilities = { ...(extraCapabilities ?? {}), guestToken };
386
- }
365
+ if (!chain)
366
+ throw new Error("accessKey requires a configured chain");
367
+ const { prepareAccessKey } = await import("./access-key");
368
+ preparation = await prepareAccessKey(accessKeyOptions, chain.id);
387
369
  }
388
- if (options.additionalAudiences?.length) {
389
- extraCapabilities = {
390
- ...(extraCapabilities ?? {}),
391
- additionalAudiences: options.additionalAudiences,
392
- };
393
- }
394
- const { session, webauthn, refreshToken, additionalSessions } = await runLogin({
395
- dialog: dialogHost,
396
- store,
397
- options: loginOpts,
398
- extraCapabilities: extraCapabilities ? extraCapabilities : undefined,
370
+ const response = await fetch(sessionPath, {
371
+ method: "POST",
372
+ headers: { "Content-Type": "application/json" },
373
+ body: JSON.stringify({
374
+ action: "login",
375
+ accessKeyHash: preparation?.accessKeyHash,
376
+ signUp: loginOpts.signUp,
377
+ returnTo: loginOpts.returnTo ??
378
+ `${window.location.pathname}${window.location.search}${window.location.hash}`,
379
+ }),
399
380
  });
400
- await installSession(refreshToken, session.jwt);
401
- deliverAdditionalSessions(additionalSessions);
402
- // webauthn implies a passkey ceremony ran, but the types allow an
403
- // email-only session — finalize only with a real credential/address.
404
- if (accessKeyModule &&
405
- accessKeyPrep &&
406
- webauthn &&
407
- session.credential &&
408
- session.address) {
409
- await accessKeyModule.finalizeAccessKey({
410
- address: session.address,
411
- credential: session.credential,
412
- webauthn,
413
- preparation: accessKeyPrep,
381
+ if (!response.ok)
382
+ throw new Error("Could not start sign-in. Reload and try again.");
383
+ // SAFETY: the app session handler returns the SDK protocol response.
384
+ const result = (await response.json());
385
+ if (preparation)
386
+ await idb.set(`flow.pendingAccessKey.${result.loginId}`, {
387
+ preparation,
388
+ expires: Date.now() + 600_000,
414
389
  });
415
- }
416
- // The dialog owns closing in every interactive path: sign-in sends "close"
417
- // immediately after responding, sign-up after its brief "Welcome" screen.
418
- // Closing here would preempt the welcome screen, and — since the gate can
419
- // only see the caller's intent, not the flow the user actually completed —
420
- // it fired even when a flow.login() user navigated to sign-up. So don't
421
- // close from the connector; let the dialog decide.
422
- return session;
390
+ window.location.assign(result.authorizeUrl);
391
+ return new Promise((_resolve, reject) => {
392
+ window.setTimeout(() => reject(new Error("Sign-in navigation did not finish. Try again.")), 30_000);
393
+ });
394
+ }
395
+ function openAccount() {
396
+ const tab = window.open("about:blank", "_blank");
397
+ if (!tab)
398
+ return false;
399
+ tab.opener = null;
400
+ tab.document.title = "Opening Flow ID";
401
+ tab.document.body.textContent = "Opening your Flow ID account…";
402
+ void fetch(sessionPath, {
403
+ method: "POST",
404
+ headers: { "Content-Type": "application/json" },
405
+ body: JSON.stringify({ action: "account" }),
406
+ })
407
+ .then(async (response) => {
408
+ if (!response.ok)
409
+ throw new Error("Account unavailable");
410
+ // SAFETY: the app session handler returns the SDK protocol response.
411
+ const result = (await response.json());
412
+ tab.location.replace(result.accountUrl);
413
+ })
414
+ .catch(() => {
415
+ tab.document.title = "Open Flow ID";
416
+ tab.document.body.textContent =
417
+ "Your app account could not be opened securely. Return to the app and try again, or open Flow ID and check the signed-in account before making changes. ";
418
+ const link = tab.document.createElement("a");
419
+ link.href = `${host}/account`;
420
+ link.textContent = "Open Flow ID";
421
+ link.rel = "noreferrer";
422
+ tab.document.body.appendChild(link);
423
+ });
424
+ return true;
423
425
  }
424
- /**
425
- * Performs a full sign-out: tells the id.flow.industries dialog to
426
- * invalidate its cookie session (so other Flow apps can't silently restore
427
- * it), tells the app's session route to revoke the refresh lineage and
428
- * clear the HttpOnly cookies, then clears local credential and access-key
429
- * state and resets the in-memory store.
430
- *
431
- * Both server-side steps are best-effort — if a network call fails (e.g.,
432
- * offline) we still clear local state so the UI reflects "signed out". The
433
- * cookies and tokens eventually expire on their own.
434
- */
435
426
  async function logout() {
436
- // Both server-side sign-outs are independent and best-effort — if either
437
- // fails (offline?) local state still clears so the UI reads "signed out".
438
- await Promise.allSettled([
439
- getDialog().requestSilent(METHODS.signOut, []),
440
- fetch(sessionPath, { method: "DELETE" }),
441
- ]);
427
+ const response = await fetch(sessionPath, { method: "DELETE" });
428
+ if (!response.ok)
429
+ throw new Error("Could not sign out. Try again.");
442
430
  await runLogout(store);
443
- dialog?.close();
444
- // An autoGuest app is never truly "signed out" — it always wants at least a
445
- // guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
446
- // working after logout instead of vanishing until the next page load.
431
+ additionalSubject = null;
447
432
  if (options.autoGuest)
448
433
  await ensureGuest();
449
434
  }
435
+ let focusRefresh = null;
436
+ const refreshOnFocus = () => {
437
+ if (document.visibilityState === "hidden" || focusRefresh)
438
+ return;
439
+ focusRefresh = withOriginLock("flow.id.refresh", async () => {
440
+ const response = await fetch(`${sessionPath}?refresh=1`, {
441
+ headers: { Accept: "application/json" },
442
+ });
443
+ if (!response.ok)
444
+ return;
445
+ // SAFETY: the app session handler returns the SDK protocol response.
446
+ const result = (await response.json());
447
+ if (result.state)
448
+ commitSession(result.state);
449
+ else
450
+ await runLogout(store);
451
+ })
452
+ .catch(() => { })
453
+ .finally(() => {
454
+ focusRefresh = null;
455
+ });
456
+ };
457
+ window.addEventListener("focus", refreshOnFocus);
458
+ document.addEventListener("visibilitychange", refreshOnFocus);
450
459
  const flow = {
451
460
  get user() {
452
461
  return store.getSnapshot().user;
@@ -467,8 +476,9 @@ export function createFlow(options = {}) {
467
476
  return store.getSnapshot().user?.isGuest === true;
468
477
  },
469
478
  login,
479
+ openAccount,
470
480
  logout,
471
- restore: refreshJwt,
481
+ restore: refreshViaSession,
472
482
  ensureGuest,
473
483
  refreshJwt,
474
484
  getToken,
@@ -47,7 +47,7 @@ export function createFlowWidget(options) {
47
47
  const hostOrigin = new URL(host).origin;
48
48
  let theme = options.theme ?? "light dark";
49
49
  const route = WIDGET_ROUTE[options.widget];
50
- const frame = makeIframe(`${host}${route}`, WIDGET_TITLE[options.widget]);
50
+ const frame = makeIframe(`${host}${route}?v=2`, WIDGET_TITLE[options.widget]);
51
51
  frame.style.background = "transparent";
52
52
  const container = options.container ?? document.body;
53
53
  // Appended before bridging: `contentWindow` is null until the frame is in the
@@ -1,4 +1,5 @@
1
1
  export declare const idb: {
2
+ pruneExpired(prefix: string): Promise<void>;
2
3
  get<T = unknown>(key: string): Promise<T | undefined>;
3
4
  set<T>(key: string, value: T): Promise<void>;
4
5
  delete(key: string): Promise<void>;
@@ -1,3 +1,5 @@
1
+ import { z } from "zod";
2
+ const expiringEntrySchema = z.object({ expires: z.number().optional() });
1
3
  let defaultGetStoreFunc;
2
4
  function createStore(dbName, storeName) {
3
5
  let dbp;
@@ -38,6 +40,28 @@ function promisify(request) {
38
40
  });
39
41
  }
40
42
  export const idb = {
43
+ pruneExpired(prefix) {
44
+ if (!defaultGetStoreFunc)
45
+ defaultGetStoreFunc = createStore("flow-id", "keyval");
46
+ return defaultGetStoreFunc("readwrite", (store) => {
47
+ const request = store.openCursor();
48
+ request.onsuccess = () => {
49
+ const cursor = request.result;
50
+ if (!cursor)
51
+ return;
52
+ const key = z.string().safeParse(cursor.key);
53
+ if (key.success && key.data.startsWith(prefix)) {
54
+ const entry = expiringEntrySchema.safeParse(cursor.value);
55
+ if (!entry.success ||
56
+ !entry.data.expires ||
57
+ entry.data.expires <= Date.now())
58
+ cursor.delete();
59
+ }
60
+ cursor.continue();
61
+ };
62
+ return promisify(store.transaction);
63
+ });
64
+ },
41
65
  get(key) {
42
66
  if (!defaultGetStoreFunc)
43
67
  defaultGetStoreFunc = createStore("flow-id", "keyval");
@@ -30,7 +30,7 @@ function createViewer(options) {
30
30
  setOverlayFocus(frame, ready && visible);
31
31
  }
32
32
  function mount(username) {
33
- const el = makeIframe(`${host}/dialog/`, "Flow ID profile");
33
+ const el = makeIframe(`${host}/dialog/?v=2`, "Flow ID profile");
34
34
  el.dataset.flowProfileView = "";
35
35
  el.inert = true;
36
36
  Object.assign(el.style, OVERLAY_STYLE, { pointerEvents: "none" });
@@ -179,6 +179,14 @@ export function openProfile(username, options = {}) {
179
179
  return false;
180
180
  if (!("document" in globalThis))
181
181
  return false;
182
+ const current = options.flow ?? getFlow();
183
+ if (current?.user?.username === username) {
184
+ closeProfile();
185
+ const opened = current.openAccount();
186
+ if (opened)
187
+ queueMicrotask(() => options.onClose?.());
188
+ return opened;
189
+ }
182
190
  const live = viewer ?? createViewer(options);
183
191
  viewer = live;
184
192
  if (options.theme)
@@ -1,15 +1,3 @@
1
1
  import type { MountProfileOptions, ProfileButtonHandle } from "../types";
2
- /**
3
- * Mounts the universal Flow ID "profile button" — a persistent inline iframe
4
- * showing the signed-in user's avatar + username (the collapsed pill). Clicking
5
- * it opens the profile dialog in a SEPARATE fullscreen overlay iframe, so the
6
- * pill stays in place behind the dialog's dimmed backdrop rather than morphing
7
- * into it. Both iframes ride the dialog SPA at `${host}/dialog/` (routes
8
- * `/dialog/profile-pill` and `/dialog/profile`) so the chrome matches the auth
9
- * dialog and the widget is first-party to Flow ID (reads `/api/me`, uploads
10
- * avatars with the cookie session).
11
- *
12
- * Resolves the Flow singleton automatically (createFlow() must have run), so
13
- * callers don't thread the instance through. No-op under SSR.
14
- */
2
+ /** Mount a native account button that opens Flow ID in a normal browser tab. */
15
3
  export declare function createProfileButton(options: MountProfileOptions): ProfileButtonHandle;