@flow-industries/id 0.9.0 → 0.11.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.
@@ -1,3 +1,5 @@
1
+ import { clearBrowserCookie, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, writeBrowserCookie, } from "../cookies";
2
+ import { isExpiring } from "../token-expiry";
1
3
  import { createDialogHost } from "./dialog-host";
2
4
  import { idb } from "./idb";
3
5
  import { METHODS } from "./methods";
@@ -6,31 +8,6 @@ import { createRoomsApi } from "./rooms";
6
8
  import { credentialToAddress, restoreCredential, runLogin, runLogout, } from "./session";
7
9
  import { createStore, initialFlowState } from "./store";
8
10
  const DEFAULT_HOST = "https://id.flow.industries";
9
- // Refresh a little before the JWT's `exp` so a token handed out by getToken()
10
- // is still valid by the time it reaches the relying party, absorbing request
11
- // latency and minor client/server clock skew.
12
- const EXPIRY_SKEW_MS = 60_000;
13
- /** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
14
- function jwtExp(token) {
15
- const parts = token.split(".");
16
- if (parts.length !== 3)
17
- return null;
18
- try {
19
- const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
20
- const payload = JSON.parse(atob(padded));
21
- return typeof payload.exp === "number" ? payload.exp : null;
22
- }
23
- catch {
24
- return null;
25
- }
26
- }
27
- /** True if the token is missing an exp, already expired, or within the skew. */
28
- function isExpiring(token) {
29
- const exp = jwtExp(token);
30
- if (exp === null)
31
- return true;
32
- return Date.now() >= exp * 1000 - EXPIRY_SKEW_MS;
33
- }
34
11
  /**
35
12
  * Runs `fn` while holding a cross-tab lock (Web Locks API) so concurrent tabs
36
13
  * of the same origin serialize guest creation: the first tab mints and sets the
@@ -89,11 +66,22 @@ export function createFlow(options = {}) {
89
66
  // and IDB writes while components/hooks that captured it keep reading
90
67
  // stale state. For genuine multi-instance scenarios (tests), call
91
68
  // resetFlow() first or pass explicit Flow instances.
69
+ if (typeof window === "undefined") {
70
+ throw new Error("createFlow() is browser-only (localStorage, IndexedDB, iframes). " +
71
+ "For server rendering, resolve state with resolveSession() from " +
72
+ "@flow-industries/id/server and render with createStaticFlow().");
73
+ }
92
74
  if (currentFlow)
93
75
  return currentFlow;
94
76
  const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, "");
95
77
  const dialogUrl = `${host}/dialog/`;
96
- const refreshStore = makeRefreshStore(host);
78
+ const cookieNames = options.cookies
79
+ ? cookieNamesFor(window.location.origin)
80
+ : null;
81
+ const cookiesSecure = window.location.protocol === "https:";
82
+ const refreshStore = makeRefreshStore(host, cookieNames
83
+ ? { name: cookieNames.refresh, secure: cookiesSecure }
84
+ : undefined);
97
85
  const chains = options.chains ?? [];
98
86
  const getChain = (chainId) => {
99
87
  if (chainId == null)
@@ -106,6 +94,37 @@ export function createFlow(options = {}) {
106
94
  };
107
95
  const accessKeyOptions = resolveAccessKey(options.accessKey);
108
96
  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
+ // Seed synchronously so the first client render matches the SSR HTML —
119
+ // hooks read the hydrated user instead of flashing signed-out while a
120
+ // bootstrap refresh runs.
121
+ if (options.initialState) {
122
+ store.setState({
123
+ user: options.initialState.user,
124
+ jwt: options.initialState.jwt,
125
+ address: options.initialState.address ?? null,
126
+ });
127
+ }
109
128
  let dialog = null;
110
129
  const getDialog = () => {
111
130
  if (!dialog)
@@ -262,6 +281,11 @@ export function createFlow(options = {}) {
262
281
  }
263
282
  void (async () => {
264
283
  await restoreCredential(store);
284
+ // A fresh hydrated JWT means the server already refreshed this request —
285
+ // running doRefresh() here would rotate a second time for nothing.
286
+ const hydrated = store.getSnapshot().jwt;
287
+ if (hydrated && !isExpiring(hydrated))
288
+ return;
265
289
  if (options.autoRestore !== false) {
266
290
  const restored = await doRefresh();
267
291
  // ensureGuest re-checks (refresh-first) under a cross-tab lock before
@@ -1,6 +1,7 @@
1
- export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
1
+ export type { AccessKeyOptions, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, DialogHost, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, LoginOptions, MethodName, MountProfileOptions, ProfileButtonHandle, ProfilePosition, Session, } from "../types";
2
2
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
3
3
  export { createDialogHost } from "./dialog-host";
4
4
  export { METHODS } from "./methods";
5
5
  export { createProfileButton } from "./profile-button";
6
6
  export { createRoomsApi, RoomsRequestError } from "./rooms";
7
+ export { createStaticFlow } from "./static-flow";
@@ -3,3 +3,4 @@ export { createDialogHost } from "./dialog-host";
3
3
  export { METHODS } from "./methods";
4
4
  export { createProfileButton } from "./profile-button";
5
5
  export { createRoomsApi, RoomsRequestError } from "./rooms";
6
+ export { createStaticFlow } from "./static-flow";
@@ -1,13 +1,23 @@
1
+ import type { RefreshCookieConfig } from "../types";
1
2
  /**
2
- * First-party persistence for the rotating refresh token. It lives in the
3
- * consumer app's OWN localStorage (origin-scoped), so it survives reloads and
4
- * navigations and — unlike the old id.flow.industries cookie — is never a
5
- * third-party cookie, so iOS ITP can't block it. Keyed by `host` so pointing
6
- * the SDK at a different Flow ID origin (dev vs prod) doesn't collide.
3
+ * First-party persistence for the rotating refresh token, keyed by `host` so
4
+ * pointing the SDK at a different Flow ID origin (dev vs prod) doesn't
5
+ * collide. Two modes:
6
+ *
7
+ * - With a cookie config (`createFlow({ cookies: true })`) the first-party
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.
7
17
  */
8
18
  export interface RefreshStore {
9
19
  get(): string | null;
10
20
  set(token: string): void;
11
21
  clear(): void;
12
22
  }
13
- export declare function makeRefreshStore(host: string): RefreshStore;
23
+ export declare function makeRefreshStore(host: string, cookie?: RefreshCookieConfig): RefreshStore;
@@ -1,5 +1,5 @@
1
- export function makeRefreshStore(host) {
2
- const key = `flow.id.refresh:${host}`;
1
+ import { clearBrowserCookie, REFRESH_COOKIE_MAX_AGE_S, readBrowserCookie, writeBrowserCookie, } from "../cookies";
2
+ function localStorageStore(key) {
3
3
  return {
4
4
  get() {
5
5
  try {
@@ -10,11 +10,6 @@ export function makeRefreshStore(host) {
10
10
  }
11
11
  },
12
12
  set(token) {
13
- // Never persist a missing token: localStorage.setItem coerces undefined
14
- // to the literal "undefined", which later reads back as a bogus bearer
15
- // and 401s as refresh_token_invalid. Fail safe by ignoring it instead.
16
- if (!token)
17
- return;
18
13
  try {
19
14
  localStorage.setItem(key, token);
20
15
  }
@@ -28,3 +23,54 @@ export function makeRefreshStore(host) {
28
23
  },
29
24
  };
30
25
  }
26
+ function cookieStore(cookie) {
27
+ return {
28
+ get() {
29
+ return readBrowserCookie(document.cookie, cookie.name);
30
+ },
31
+ set(token) {
32
+ writeBrowserCookie(cookie.name, token, {
33
+ maxAge: REFRESH_COOKIE_MAX_AGE_S,
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();
74
+ }
75
+ return withMissingTokenGuard(store);
76
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { makeRefreshStore } from "./refresh-store";
3
+ const HOST = "https://id.flow.industries";
4
+ const KEY = `flow.id.refresh:${HOST}`;
5
+ const COOKIE = { name: "flow_id.refresh_p5173", secure: false };
6
+ let local;
7
+ let jar;
8
+ function installBrowserStubs() {
9
+ local = new Map();
10
+ jar = new Map();
11
+ globalThis.localStorage = {
12
+ getItem: (k) => local.get(k) ?? null,
13
+ setItem: (k, v) => void local.set(k, String(v)),
14
+ removeItem: (k) => void local.delete(k),
15
+ };
16
+ globalThis.document = {
17
+ get cookie() {
18
+ return [...jar.entries()].map(([n, v]) => `${n}=${v}`).join("; ");
19
+ },
20
+ set cookie(str) {
21
+ const [pair = "", ...attrs] = str.split(";");
22
+ const eq = pair.indexOf("=");
23
+ const name = pair.slice(0, eq).trim();
24
+ const value = pair.slice(eq + 1).trim();
25
+ if (attrs.some((a) => a.trim() === "Max-Age=0"))
26
+ jar.delete(name);
27
+ else
28
+ jar.set(name, value);
29
+ },
30
+ };
31
+ }
32
+ beforeEach(installBrowserStubs);
33
+ describe("localStorage mode (no cookie config)", () => {
34
+ test("round-trips through localStorage", () => {
35
+ const store = makeRefreshStore(HOST);
36
+ expect(store.get()).toBeNull();
37
+ store.set("tok_a");
38
+ expect(local.get(KEY)).toBe("tok_a");
39
+ expect(store.get()).toBe("tok_a");
40
+ store.clear();
41
+ expect(store.get()).toBeNull();
42
+ });
43
+ test("ignores a missing token instead of persisting a bogus value", () => {
44
+ const store = makeRefreshStore(HOST);
45
+ store.set(undefined);
46
+ expect(store.get()).toBeNull();
47
+ });
48
+ test("never touches cookies", () => {
49
+ const store = makeRefreshStore(HOST);
50
+ store.set("tok_a");
51
+ expect(jar.size).toBe(0);
52
+ });
53
+ });
54
+ describe("cookie mode", () => {
55
+ test("promotes a legacy localStorage token into the cookie and drops the key", () => {
56
+ local.set(KEY, "tok_legacy");
57
+ const store = makeRefreshStore(HOST, COOKIE);
58
+ expect(store.get()).toBe("tok_legacy");
59
+ expect(jar.get(COOKIE.name)).toBe("tok_legacy");
60
+ expect(local.has(KEY)).toBe(false);
61
+ });
62
+ test("an existing cookie wins over a stale localStorage copy", () => {
63
+ local.set(KEY, "tok_stale");
64
+ jar.set(COOKIE.name, "tok_rotated");
65
+ const store = makeRefreshStore(HOST, COOKIE);
66
+ expect(store.get()).toBe("tok_rotated");
67
+ expect(local.has(KEY)).toBe(false);
68
+ });
69
+ test("reads and writes only the cookie", () => {
70
+ const store = makeRefreshStore(HOST, COOKIE);
71
+ store.set("tok_b");
72
+ expect(jar.get(COOKIE.name)).toBe("tok_b");
73
+ expect(local.size).toBe(0);
74
+ local.set(KEY, "tok_planted_later");
75
+ jar.delete(COOKIE.name);
76
+ expect(store.get()).toBeNull();
77
+ });
78
+ test("clear deletes the cookie", () => {
79
+ const store = makeRefreshStore(HOST, COOKIE);
80
+ store.set("tok_c");
81
+ store.clear();
82
+ expect(store.get()).toBeNull();
83
+ expect(jar.has(COOKIE.name)).toBe(false);
84
+ });
85
+ test("ignores a missing token instead of persisting a bogus value", () => {
86
+ const store = makeRefreshStore(HOST, COOKIE);
87
+ store.set(undefined);
88
+ expect(store.get()).toBeNull();
89
+ });
90
+ });
@@ -0,0 +1,14 @@
1
+ import type { Flow, FlowSessionState } from "../types";
2
+ /**
3
+ * A frozen, per-request Flow for server rendering: hooks and components read
4
+ * the state `resolveSession()` resolved for THIS request, so the SSR HTML
5
+ * matches what the hydrated client (seeded via `createFlow({ initialState })`)
6
+ * renders first. Never touches the module singleton — concurrent requests
7
+ * must not share auth state.
8
+ *
9
+ * Reads work (`user`, `jwt`, `getToken`, `getState`, `rooms` — a plain fetch
10
+ * API); anything interactive (login, dialog, signing) throws.
11
+ */
12
+ export declare function createStaticFlow(state: FlowSessionState | null, options?: {
13
+ host?: string;
14
+ }): Flow;
@@ -0,0 +1,65 @@
1
+ import { createRoomsApi } from "./rooms";
2
+ const DEFAULT_HOST = "https://id.flow.industries";
3
+ function unavailable(method) {
4
+ throw new Error(`flow.${method} is not available on a static Flow — it renders ` +
5
+ "server-resolved state only. Interactive methods need the browser " +
6
+ "client from createFlow().");
7
+ }
8
+ /**
9
+ * A frozen, per-request Flow for server rendering: hooks and components read
10
+ * the state `resolveSession()` resolved for THIS request, so the SSR HTML
11
+ * matches what the hydrated client (seeded via `createFlow({ initialState })`)
12
+ * renders first. Never touches the module singleton — concurrent requests
13
+ * must not share auth state.
14
+ *
15
+ * Reads work (`user`, `jwt`, `getToken`, `getState`, `rooms` — a plain fetch
16
+ * API); anything interactive (login, dialog, signing) throws.
17
+ */
18
+ export function createStaticFlow(state, options = {}) {
19
+ const host = (options.host ?? DEFAULT_HOST).replace(/\/+$/, "");
20
+ const snapshot = Object.freeze({
21
+ user: state?.user ?? null,
22
+ jwt: state?.jwt ?? null,
23
+ credential: null,
24
+ address: state?.address ?? null,
25
+ });
26
+ const getToken = async () => snapshot.jwt;
27
+ return {
28
+ get user() {
29
+ return snapshot.user;
30
+ },
31
+ get jwt() {
32
+ return snapshot.jwt;
33
+ },
34
+ get credential() {
35
+ return snapshot.credential;
36
+ },
37
+ get address() {
38
+ return snapshot.address;
39
+ },
40
+ get isAuthenticated() {
41
+ return snapshot.user !== null;
42
+ },
43
+ get isGuest() {
44
+ return snapshot.user?.isGuest === true;
45
+ },
46
+ login: () => unavailable("login"),
47
+ logout: () => unavailable("logout"),
48
+ restore: async () => false,
49
+ ensureGuest: () => unavailable("ensureGuest"),
50
+ refreshJwt: async () => false,
51
+ getToken,
52
+ rooms: createRoomsApi(host, getToken),
53
+ signMessage: () => unavailable("signMessage"),
54
+ signTypedData: () => unavailable("signTypedData"),
55
+ sendTransaction: () => unavailable("sendTransaction"),
56
+ sendCalls: () => unavailable("sendCalls"),
57
+ walletClient: () => unavailable("walletClient"),
58
+ subscribe: () => () => { },
59
+ getState: () => snapshot,
60
+ get dialog() {
61
+ return unavailable("dialog");
62
+ },
63
+ host,
64
+ };
65
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * First-party cookie names and (de)serialization shared by the browser SDK
3
+ * (which persists the session into cookies) and the server helper (which reads
4
+ * them per-request to render auth-aware UI without a client round-trip).
5
+ * Isomorphic: no browser globals at import time.
6
+ */
7
+ import type { FlowCookieNames } from "./types";
8
+ export declare const JWT_COOKIE_MAX_AGE_S: number;
9
+ export declare const REFRESH_COOKIE_MAX_AGE_S: number;
10
+ /**
11
+ * Cookie names for an app origin. On https the `__Host-` prefix makes the
12
+ * browser enforce Secure + Path=/ + host-only, so no sibling subdomain can
13
+ * plant a shadowing cookie with `Domain=` (session fixation). On localhost
14
+ * (plain http, where `__Host-` is unavailable) the port is folded into the
15
+ * name instead — cookie jars ignore ports, so without the suffix parallel
16
+ * dev stacks on different ports would overwrite each other's sessions.
17
+ */
18
+ export declare function cookieNamesFor(origin: string): FlowCookieNames;
19
+ /**
20
+ * Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`;
21
+ * `Secure` everywhere except plain-http localhost (Safari drops Secure
22
+ * cookies set over http). Never HttpOnly — the browser SDK reads and writes
23
+ * these same cookies.
24
+ */
25
+ export declare function serializeCookie(name: string, value: string, opts: {
26
+ maxAge: number;
27
+ secure: boolean;
28
+ }): string;
29
+ /** Builds a Set-Cookie value that deletes the cookie. */
30
+ export declare function clearCookieString(name: string, opts: {
31
+ secure: boolean;
32
+ }): string;
33
+ /** Parses a Cookie request header into name → value; first occurrence wins (RFC 6265 practice). */
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;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * First-party cookie names and (de)serialization shared by the browser SDK
3
+ * (which persists the session into cookies) and the server helper (which reads
4
+ * them per-request to render auth-aware UI without a client round-trip).
5
+ * Isomorphic: no browser globals at import time.
6
+ */
7
+ export const JWT_COOKIE_MAX_AGE_S = 60 * 60;
8
+ export const REFRESH_COOKIE_MAX_AGE_S = 30 * 24 * 60 * 60;
9
+ /**
10
+ * Cookie names for an app origin. On https the `__Host-` prefix makes the
11
+ * browser enforce Secure + Path=/ + host-only, so no sibling subdomain can
12
+ * plant a shadowing cookie with `Domain=` (session fixation). On localhost
13
+ * (plain http, where `__Host-` is unavailable) the port is folded into the
14
+ * name instead — cookie jars ignore ports, so without the suffix parallel
15
+ * dev stacks on different ports would overwrite each other's sessions.
16
+ */
17
+ export function cookieNamesFor(origin) {
18
+ let prefix = "";
19
+ let suffix = "";
20
+ try {
21
+ const url = new URL(origin);
22
+ if (url.protocol === "https:")
23
+ prefix = "__Host-";
24
+ const local = url.hostname === "localhost" ||
25
+ url.hostname === "127.0.0.1" ||
26
+ url.hostname === "[::1]";
27
+ if (local && url.port)
28
+ suffix = `_p${url.port}`;
29
+ }
30
+ catch { }
31
+ return {
32
+ jwt: `${prefix}flow_id.jwt${suffix}`,
33
+ refresh: `${prefix}flow_id.refresh${suffix}`,
34
+ };
35
+ }
36
+ /**
37
+ * Builds a Set-Cookie value. Host-only (no Domain), `SameSite=Lax`, `Path=/`;
38
+ * `Secure` everywhere except plain-http localhost (Safari drops Secure
39
+ * cookies set over http). Never HttpOnly — the browser SDK reads and writes
40
+ * these same cookies.
41
+ */
42
+ export function serializeCookie(name, value, opts) {
43
+ const secure = opts.secure ? "; Secure" : "";
44
+ return `${name}=${value}; Path=/; SameSite=Lax; Max-Age=${opts.maxAge}${secure}`;
45
+ }
46
+ /** Builds a Set-Cookie value that deletes the cookie. */
47
+ export function clearCookieString(name, opts) {
48
+ return serializeCookie(name, "", { maxAge: 0, secure: opts.secure });
49
+ }
50
+ /** Parses a Cookie request header into name → value; first occurrence wins (RFC 6265 practice). */
51
+ export function parseCookieHeader(header) {
52
+ const out = {};
53
+ if (!header)
54
+ return out;
55
+ for (const part of header.split(";")) {
56
+ const eq = part.indexOf("=");
57
+ if (eq === -1)
58
+ continue;
59
+ const name = part.slice(0, eq).trim();
60
+ if (!name || name in out)
61
+ continue;
62
+ out[name] = part.slice(eq + 1).trim();
63
+ }
64
+ return out;
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
+ }
@@ -0,0 +1,23 @@
1
+ import type { ResolvedFlowSession, ResolveSessionOptions } from "./types";
2
+ export type { FlowSessionState, ResolvedFlowSession, ResolveSessionOptions, } from "./types";
3
+ export { verifyFlowJWT } from "./verify";
4
+ /**
5
+ * Resolves the visitor's Flow session from the request's Cookie header — the
6
+ * server-side counterpart of `createFlow({ cookies: true })`, for rendering
7
+ * auth-aware UI without a client round-trip.
8
+ *
9
+ * Resolution order:
10
+ * 1. A still-fresh JWT cookie verifies locally against the issuer's JWKS
11
+ * (cached per process) — the hot path, no auth-server call, no cookies
12
+ * to set.
13
+ * 2. Otherwise a refresh cookie is presented to `/api/session/refresh`,
14
+ * which ROTATES it — append every entry of `setCookies` to the response
15
+ * or the client is left holding a dead predecessor. A definitive
16
+ * rejection (401/403) yields clearing cookies instead; a network failure
17
+ * leaves the cookies alone so a later request can retry.
18
+ * 3. Neither cookie → signed-out `state: null`.
19
+ *
20
+ * Feed `state` into the SSR payload and `createFlow({ initialState })` so the
21
+ * hydrated client renders it without a second refresh.
22
+ */
23
+ export declare function resolveSession(cookieHeader: string | null | undefined, opts: ResolveSessionOptions): Promise<ResolvedFlowSession>;
@@ -0,0 +1,200 @@
1
+ import { clearCookieString, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, parseCookieHeader, REFRESH_COOKIE_MAX_AGE_S, serializeCookie, } from "./cookies";
2
+ import { isExpiring } from "./token-expiry";
3
+ import { verifyFlowJWT } from "./verify";
4
+ export { verifyFlowJWT } from "./verify";
5
+ const DEFAULT_ISSUER_URL = "https://id.flow.industries";
6
+ function claimsToUser(claims) {
7
+ return {
8
+ id: claims.sub,
9
+ username: typeof claims.username === "string" ? claims.username : "",
10
+ // Fail-safe: only an explicit `false` marks a full account; a missing or
11
+ // mangled claim must never grant full privileges.
12
+ isGuest: claims.guest !== false,
13
+ };
14
+ }
15
+ async function refreshSession(issuerUrl, token) {
16
+ try {
17
+ const res = await fetch(`${issuerUrl}/api/session/refresh`, {
18
+ method: "POST",
19
+ headers: { Authorization: `Bearer ${token}` },
20
+ });
21
+ if (res.status === 401 || res.status === 403)
22
+ return "dead";
23
+ if (!res.ok)
24
+ return null;
25
+ return (await res.json());
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ // Dedupe a same-instance SSR fan-out (parallel requests carrying the same
32
+ // cookie) into one rotation. Settled results are retained briefly so a
33
+ // request that lands after the rotation but before the browser applied the
34
+ // Set-Cookie is served the same successor instead of re-presenting a dead
35
+ // token; the auth server's grace window covers presenters this map can't see
36
+ // (other replicas, other machines).
37
+ const REFRESH_RESULT_TTL_MS = 30_000;
38
+ const refreshInFlight = new Map();
39
+ function refreshOnce(issuerUrl, token) {
40
+ const key = `${issuerUrl}\n${token}`;
41
+ let flight = refreshInFlight.get(key);
42
+ if (!flight) {
43
+ flight = refreshSession(issuerUrl, token);
44
+ refreshInFlight.set(key, flight);
45
+ void flight.finally(() => {
46
+ const timer = setTimeout(() => refreshInFlight.delete(key), REFRESH_RESULT_TTL_MS);
47
+ timer.unref?.();
48
+ });
49
+ }
50
+ return flight;
51
+ }
52
+ // jose verification-class failures (bad signature, expired, wrong audience,
53
+ // unknown key) mean the token itself is unacceptable; anything else — JWKS
54
+ // fetch failure, timeouts — is transient infrastructure trouble, and falling
55
+ // through to a rotation there would turn a JWKS outage into a rotation storm
56
+ // against the same struggling server.
57
+ function isJwtVerificationFailure(err) {
58
+ const code = err?.code;
59
+ if (typeof code !== "string")
60
+ return false;
61
+ return (code.startsWith("ERR_JWT") ||
62
+ code.startsWith("ERR_JWS") ||
63
+ code === "ERR_JWKS_NO_MATCHING_KEY" ||
64
+ code === "ERR_JWKS_MULTIPLE_MATCHING_KEYS");
65
+ }
66
+ async function fetchProfile(issuerUrl, jwt, audience) {
67
+ try {
68
+ const res = await fetch(`${issuerUrl}/api/session/verify`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({ token: jwt, audience }),
72
+ });
73
+ if (!res.ok)
74
+ return undefined;
75
+ const data = (await res.json());
76
+ return data.payload?.profile ?? undefined;
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ /**
83
+ * Resolves the visitor's Flow session from the request's Cookie header — the
84
+ * server-side counterpart of `createFlow({ cookies: true })`, for rendering
85
+ * auth-aware UI without a client round-trip.
86
+ *
87
+ * Resolution order:
88
+ * 1. A still-fresh JWT cookie verifies locally against the issuer's JWKS
89
+ * (cached per process) — the hot path, no auth-server call, no cookies
90
+ * to set.
91
+ * 2. Otherwise a refresh cookie is presented to `/api/session/refresh`,
92
+ * which ROTATES it — append every entry of `setCookies` to the response
93
+ * or the client is left holding a dead predecessor. A definitive
94
+ * rejection (401/403) yields clearing cookies instead; a network failure
95
+ * leaves the cookies alone so a later request can retry.
96
+ * 3. Neither cookie → signed-out `state: null`.
97
+ *
98
+ * Feed `state` into the SSR payload and `createFlow({ initialState })` so the
99
+ * hydrated client renders it without a second refresh.
100
+ */
101
+ export async function resolveSession(cookieHeader, opts) {
102
+ const issuerUrl = (opts.issuerUrl ?? DEFAULT_ISSUER_URL).replace(/\/+$/, "");
103
+ const names = cookieNamesFor(opts.audience);
104
+ const secure = opts.audience.startsWith("https:");
105
+ const cookies = parseCookieHeader(cookieHeader);
106
+ const jwt = cookies[names.jwt];
107
+ if (jwt && !isExpiring(jwt)) {
108
+ try {
109
+ const claims = await verifyFlowJWT(jwt, {
110
+ audience: opts.audience,
111
+ issuerUrl,
112
+ });
113
+ const state = {
114
+ user: claimsToUser(claims),
115
+ jwt,
116
+ address: null,
117
+ };
118
+ const profile = opts.profile
119
+ ? await fetchProfile(issuerUrl, jwt, opts.audience)
120
+ : undefined;
121
+ return {
122
+ state,
123
+ claims,
124
+ setCookies: [],
125
+ ...(profile ? { profile } : {}),
126
+ };
127
+ }
128
+ catch (err) {
129
+ if (!isJwtVerificationFailure(err)) {
130
+ return { state: null, claims: null, setCookies: [] };
131
+ }
132
+ }
133
+ }
134
+ const refreshToken = cookies[names.refresh];
135
+ if (!refreshToken)
136
+ return { state: null, claims: null, setCookies: [] };
137
+ const result = await refreshOnce(issuerUrl, refreshToken);
138
+ if (result === "dead") {
139
+ return {
140
+ state: null,
141
+ claims: null,
142
+ setCookies: [
143
+ clearCookieString(names.jwt, { secure }),
144
+ clearCookieString(names.refresh, { secure }),
145
+ ],
146
+ };
147
+ }
148
+ if (!result)
149
+ return { state: null, claims: null, setCookies: [] };
150
+ const successorCookies = [
151
+ serializeCookie(names.jwt, result.jwt, {
152
+ maxAge: JWT_COOKIE_MAX_AGE_S,
153
+ secure,
154
+ }),
155
+ serializeCookie(names.refresh, result.refreshToken, {
156
+ maxAge: REFRESH_COOKIE_MAX_AGE_S,
157
+ secure,
158
+ }),
159
+ ];
160
+ // Never trust a minted session unverified: the refresh endpoint binds the
161
+ // JWT to the token ROW's audience, so a cookie planted by a sibling
162
+ // subdomain (or a misconfigured audience) would otherwise resolve into a
163
+ // wrong-app session. Verification failure ⇒ clear the cookies; a transient
164
+ // JWKS failure still hands the successor over (the rotation already
165
+ // happened) but renders signed-out rather than trusting it.
166
+ let claims;
167
+ try {
168
+ claims = await verifyFlowJWT(result.jwt, {
169
+ audience: opts.audience,
170
+ issuerUrl,
171
+ });
172
+ }
173
+ catch (err) {
174
+ if (isJwtVerificationFailure(err)) {
175
+ return {
176
+ state: null,
177
+ claims: null,
178
+ setCookies: [
179
+ clearCookieString(names.jwt, { secure }),
180
+ clearCookieString(names.refresh, { secure }),
181
+ ],
182
+ };
183
+ }
184
+ return { state: null, claims: null, setCookies: successorCookies };
185
+ }
186
+ const state = {
187
+ user: result.user,
188
+ jwt: result.jwt,
189
+ address: result.address,
190
+ };
191
+ const profile = opts.profile
192
+ ? await fetchProfile(issuerUrl, result.jwt, opts.audience)
193
+ : undefined;
194
+ return {
195
+ state,
196
+ claims,
197
+ setCookies: successorCookies,
198
+ ...(profile ? { profile } : {}),
199
+ };
200
+ }
@@ -0,0 +1,5 @@
1
+ export declare const EXPIRY_SKEW_MS = 60000;
2
+ /** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
3
+ export declare function jwtExp(token: string): number | null;
4
+ /** True if the token is missing an exp, already expired, or within the skew. */
5
+ export declare function isExpiring(token: string): boolean;
@@ -0,0 +1,25 @@
1
+ // Refresh a little before the JWT's `exp` so a token handed out to a caller
2
+ // is still valid by the time it reaches the relying party, absorbing request
3
+ // latency and minor client/server clock skew.
4
+ export const EXPIRY_SKEW_MS = 60_000;
5
+ /** Reads the `exp` (seconds since epoch) from a JWT, or null if unreadable. */
6
+ export function jwtExp(token) {
7
+ const parts = token.split(".");
8
+ if (parts.length !== 3)
9
+ return null;
10
+ try {
11
+ const padded = (parts[1] ?? "").replace(/-/g, "+").replace(/_/g, "/");
12
+ const payload = JSON.parse(atob(padded));
13
+ return typeof payload.exp === "number" ? payload.exp : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** True if the token is missing an exp, already expired, or within the skew. */
20
+ export function isExpiring(token) {
21
+ const exp = jwtExp(token);
22
+ if (exp === null)
23
+ return true;
24
+ return Date.now() >= exp * 1000 - EXPIRY_SKEW_MS;
25
+ }
@@ -8,7 +8,7 @@ export type AuthOutcome = "success" | "failure" | "info";
8
8
  export type AuthMode = "sign-up" | "sign-in";
9
9
  /** Client-only funnel steps reported via the `/api/events` beacon. */
10
10
  export type FunnelStep = "mode_selected" | "email_entered" | "ceremony_started" | "done_shown";
11
- export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.avatar.upload.succeeded" | "auth.avatar.upload.failed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.refresh.succeeded" | "auth.refresh.failed" | "auth.refresh.reuse" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
11
+ export type AuthEventName = "auth.username.checked" | "auth.signup.succeeded" | "auth.signup.failed" | "auth.otp.sent" | "auth.otp.verified" | "auth.otp.failed" | "auth.email.verify.sent" | "auth.email.verify.succeeded" | "auth.email.verify.failed" | "auth.email.changed" | "auth.avatar.upload.succeeded" | "auth.avatar.upload.failed" | "auth.challenge.issued" | "auth.signin.succeeded" | "auth.signin.failed" | "auth.restore.succeeded" | "auth.restore.failed" | "auth.restore.no_session" | "auth.refresh.succeeded" | "auth.refresh.failed" | "auth.refresh.reuse" | "auth.refresh.grace" | "auth.jwt.verified" | "auth.jwt.rejected" | "auth.signout" | "auth.audience.rejected" | "auth.guest.created" | "auth.guest.restored" | "auth.guest.upgraded" | "auth.guest.failed" | "auth.funnel.mode_selected" | "auth.funnel.email_entered" | "auth.funnel.ceremony_started" | "auth.funnel.done_shown";
12
12
  export type AuthErrorCode = "username_taken" | "credential_taken" | "email_taken" | "email_not_verified" | "no_email" | "image_upload_forbidden" | "image_upload_rate_limited" | "invalid_image_type" | "image_too_large" | "image_upload_failed" | "otp_invalid" | "otp_expired" | "otp_attempts_exceeded" | "otp_resend_cooldown" | "otp_resend_limit" | "otp_global_limit" | "otp_send_failed" | "challenge_expired" | "unknown_credential" | "invalid_assertion_type" | "invalid_assertion_origin" | "user_verification_required" | "invalid_signature" | "user_not_found" | "missing_audience" | "audience_not_allowed" | "audience_mismatch" | "no_session" | "no_passkey" | "refresh_token_invalid" | "refresh_token_expired" | "refresh_reuse_detected" | "guest_rate_limited" | "guest_global_limit" | "guest_username_exhausted" | "already_upgraded" | "malformed_token" | "unknown_key" | "verification_failed" | "internal_error";
13
13
  /** One flat record per event = one row in the `auth_events` stream. */
14
14
  export interface AuthEventRecord {
@@ -5,6 +5,7 @@ export type { Bridge, BridgeParameters, FlowAccount, FlowRemote, FlowRemoteConfi
5
5
  export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestRequest, GuestResponse, MethodName, MethodParams, MethodResult, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
6
6
  export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
7
7
  export type { CreateRoomInput, MyRooms, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, UpdateRoomInput, VerifyRoomContext, } from "./rooms";
8
- export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowIdProviderProps, FlowState, Listener, LoginOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
8
+ export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, Listener, LoginOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, RefreshCookieConfig, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
9
+ export type { ResolvedFlowSession, ResolveSessionOptions } from "./server";
9
10
  export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
10
11
  export type { ActionDayContext, ActionEventKind, ActionSessionState, ActiveActionResponse, LevelProgress, PublicProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
@@ -71,6 +71,23 @@ export type CreateFlowOptions = {
71
71
  * data keyed on the Flow user id survives. See `ensureGuest`.
72
72
  */
73
73
  autoGuest?: boolean;
74
+ /**
75
+ * If true, the session lives in first-party cookies on the app's own
76
+ * origin: the JWT is mirrored there from memory and the rotating refresh
77
+ * token is stored there alone (not in localStorage), so the app's server
78
+ * can resolve and rotate the session per-request (see
79
+ * `@flow-industries/id/server`). Off by default — a backend that never
80
+ * reads them shouldn't start receiving refresh tokens on every request.
81
+ */
82
+ cookies?: boolean;
83
+ /**
84
+ * Session state resolved server-side (via `resolveSession`) to seed the
85
+ * store synchronously, so the first client render matches the SSR HTML and
86
+ * no bootstrap refresh runs while the hydrated JWT is still fresh. Pass
87
+ * `null` for a known signed-out visitor; omit for the default client-only
88
+ * bootstrap.
89
+ */
90
+ initialState?: FlowSessionState | null;
74
91
  };
75
92
  export type FlowState = {
76
93
  user: FlowUser | null;
@@ -78,6 +95,30 @@ export type FlowState = {
78
95
  credential: FlowCredential | null;
79
96
  address: Address | null;
80
97
  };
98
+ /**
99
+ * The auth state a server can resolve per-request from the first-party
100
+ * cookies: identity + access token, but never the credential (passkeys live
101
+ * in the browser's IndexedDB only).
102
+ */
103
+ export type FlowSessionState = {
104
+ user: FlowUser;
105
+ jwt: string;
106
+ /**
107
+ * Null whenever the session came from a locally-verified JWT — the common
108
+ * SSR hot path — because the claim set carries no address. The client
109
+ * restores it from the stored passkey credential after hydration; never
110
+ * server-render wallet UI from this field.
111
+ */
112
+ address: Address | null;
113
+ };
114
+ export type FlowCookieNames = {
115
+ jwt: string;
116
+ refresh: string;
117
+ };
118
+ export type RefreshCookieConfig = {
119
+ name: string;
120
+ secure: boolean;
121
+ };
81
122
  export type LoginOptions = {
82
123
  mode?: "iframe" | "popup";
83
124
  signUp?: boolean;
@@ -0,0 +1,28 @@
1
+ import type { VerifiedFlowJWT } from "./auth";
2
+ import type { FlowSessionState } from "./sdk";
3
+ import type { PublicProfile } from "./xp";
4
+ export type ResolveSessionOptions = {
5
+ /** The calling app's origin, e.g. `https://flow.game` — must match the JWT `aud`. */
6
+ audience: string;
7
+ /** Flow ID origin; defaults to `https://id.flow.industries`. */
8
+ issuerUrl?: string;
9
+ /**
10
+ * Also fetch the public profile (avatar, tier, level) for a resolved
11
+ * session via `/api/session/verify` — one extra round-trip; off by default.
12
+ */
13
+ profile?: boolean;
14
+ };
15
+ export type ResolvedFlowSession = {
16
+ /** Resolved session, or null for a signed-out visitor. */
17
+ state: FlowSessionState | null;
18
+ /** Verified JWT claims when the session came from a locally-verified JWT. */
19
+ claims: VerifiedFlowJWT | null;
20
+ /**
21
+ * Set-Cookie values to append to the response: rotation successors after a
22
+ * server-side refresh, or expired-cookie clears after a dead token. Empty on
23
+ * the JWT-valid hot path.
24
+ */
25
+ setCookies: string[];
26
+ /** Public profile, only when requested via `profile: true` and resolvable. */
27
+ profile?: PublicProfile;
28
+ };
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "main": "./dist/sdk/client/index.js",
5
5
  "module": "./dist/sdk/client/index.js",
6
6
  "types": "./dist/sdk/client/index.d.ts",
@@ -29,6 +29,10 @@
29
29
  "./verify": {
30
30
  "types": "./dist/sdk/verify.d.ts",
31
31
  "import": "./dist/sdk/verify.js"
32
+ },
33
+ "./server": {
34
+ "types": "./dist/sdk/server.d.ts",
35
+ "import": "./dist/sdk/server.js"
32
36
  }
33
37
  },
34
38
  "files": [