@flow-industries/id 0.9.0 → 0.10.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,3 +1,4 @@
1
+ import type { RefreshCookieConfig } from "../types";
1
2
  /**
2
3
  * First-party persistence for the rotating refresh token. It lives in the
3
4
  * consumer app's OWN localStorage (origin-scoped), so it survives reloads and
@@ -10,4 +11,12 @@ export interface RefreshStore {
10
11
  set(token: string): void;
11
12
  clear(): void;
12
13
  }
13
- export declare function makeRefreshStore(host: string): RefreshStore;
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
+ */
22
+ export declare function makeRefreshStore(host: string, cookie?: RefreshCookieConfig): RefreshStore;
@@ -1,13 +1,30 @@
1
- export function makeRefreshStore(host) {
1
+ import { clearBrowserCookie, REFRESH_COOKIE_MAX_AGE_S, readBrowserCookie, writeBrowserCookie, } from "../cookies";
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) {
2
11
  const key = `flow.id.refresh:${host}`;
3
- return {
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
- try {
6
- return localStorage.getItem(key);
7
- }
8
- catch {
9
- return null;
10
- }
27
+ return readCookie() ?? readLocal();
11
28
  },
12
29
  set(token) {
13
30
  // Never persist a missing token: localStorage.setItem coerces undefined
@@ -19,12 +36,31 @@ export function makeRefreshStore(host) {
19
36
  localStorage.setItem(key, token);
20
37
  }
21
38
  catch { }
39
+ if (cookie) {
40
+ writeBrowserCookie(cookie.name, token, {
41
+ maxAge: REFRESH_COOKIE_MAX_AGE_S,
42
+ secure: cookie.secure,
43
+ });
44
+ }
22
45
  },
23
46
  clear() {
24
47
  try {
25
48
  localStorage.removeItem(key);
26
49
  }
27
50
  catch { }
51
+ if (cookie)
52
+ clearBrowserCookie(cookie.name, { secure: cookie.secure });
28
53
  },
29
54
  };
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 { }
64
+ }
65
+ return store;
30
66
  }
@@ -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 mirrors 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 mirrors 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,22 @@ 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 JWT and the rotating refresh token are mirrored into
76
+ * first-party cookies on the app's own origin so the app's server can
77
+ * resolve the session per-request (see `@flow-industries/id/server`).
78
+ * Off by default — a backend that never reads them shouldn't start
79
+ * receiving refresh tokens on every request.
80
+ */
81
+ cookies?: boolean;
82
+ /**
83
+ * Session state resolved server-side (via `resolveSession`) to seed the
84
+ * store synchronously, so the first client render matches the SSR HTML and
85
+ * no bootstrap refresh runs while the hydrated JWT is still fresh. Pass
86
+ * `null` for a known signed-out visitor; omit for the default client-only
87
+ * bootstrap.
88
+ */
89
+ initialState?: FlowSessionState | null;
74
90
  };
75
91
  export type FlowState = {
76
92
  user: FlowUser | null;
@@ -78,6 +94,30 @@ export type FlowState = {
78
94
  credential: FlowCredential | null;
79
95
  address: Address | null;
80
96
  };
97
+ /**
98
+ * The auth state a server can resolve per-request from the first-party
99
+ * cookies: identity + access token, but never the credential (passkeys live
100
+ * in the browser's IndexedDB only).
101
+ */
102
+ export type FlowSessionState = {
103
+ user: FlowUser;
104
+ jwt: string;
105
+ /**
106
+ * Null whenever the session came from a locally-verified JWT — the common
107
+ * SSR hot path — because the claim set carries no address. The client
108
+ * restores it from the stored passkey credential after hydration; never
109
+ * server-render wallet UI from this field.
110
+ */
111
+ address: Address | null;
112
+ };
113
+ export type FlowCookieNames = {
114
+ jwt: string;
115
+ refresh: string;
116
+ };
117
+ export type RefreshCookieConfig = {
118
+ name: string;
119
+ secure: boolean;
120
+ };
81
121
  export type LoginOptions = {
82
122
  mode?: "iframe" | "popup";
83
123
  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.10.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": [