@flow-industries/id 0.10.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,22 +1,23 @@
1
1
  import type { RefreshCookieConfig } from "../types";
2
2
  /**
3
- * First-party persistence for the rotating refresh token. It lives in the
4
- * consumer app's OWN localStorage (origin-scoped), so it survives reloads and
5
- * navigations and — unlike the old id.flow.industries cookie — is never a
6
- * third-party cookie, so iOS ITP can't block it. Keyed by `host` so pointing
7
- * the SDK at a different Flow ID origin (dev vs prod) doesn't collide.
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.
8
17
  */
9
18
  export interface RefreshStore {
10
19
  get(): string | null;
11
20
  set(token: string): void;
12
21
  clear(): void;
13
22
  }
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
23
  export declare function makeRefreshStore(host: string, cookie?: RefreshCookieConfig): RefreshStore;
@@ -1,66 +1,76 @@
1
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) {
11
- const key = `flow.id.refresh:${host}`;
12
- const readLocal = () => {
13
- try {
14
- return localStorage.getItem(key);
15
- }
16
- catch {
17
- return null;
18
- }
19
- };
20
- const readCookie = () => {
21
- if (!cookie || typeof document === "undefined")
22
- return null;
23
- return readBrowserCookie(document.cookie, cookie.name);
24
- };
25
- const store = {
2
+ function localStorageStore(key) {
3
+ return {
26
4
  get() {
27
- return readCookie() ?? readLocal();
5
+ try {
6
+ return localStorage.getItem(key);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
28
11
  },
29
12
  set(token) {
30
- // Never persist a missing token: localStorage.setItem coerces undefined
31
- // to the literal "undefined", which later reads back as a bogus bearer
32
- // and 401s as refresh_token_invalid. Fail safe by ignoring it instead.
33
- if (!token)
34
- return;
35
13
  try {
36
14
  localStorage.setItem(key, token);
37
15
  }
38
16
  catch { }
39
- if (cookie) {
40
- writeBrowserCookie(cookie.name, token, {
41
- maxAge: REFRESH_COOKIE_MAX_AGE_S,
42
- secure: cookie.secure,
43
- });
44
- }
45
17
  },
46
18
  clear() {
47
19
  try {
48
20
  localStorage.removeItem(key);
49
21
  }
50
22
  catch { }
51
- if (cookie)
52
- clearBrowserCookie(cookie.name, { secure: cookie.secure });
53
23
  },
54
24
  };
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 { }
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();
64
74
  }
65
- return store;
75
+ return withMissingTokenGuard(store);
66
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
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
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
3
+ * (which persists the session into cookies) and the server helper (which reads
4
4
  * them per-request to render auth-aware UI without a client round-trip).
5
5
  * Isomorphic: no browser globals at import time.
6
6
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
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
3
+ * (which persists the session into cookies) and the server helper (which reads
4
4
  * them per-request to render auth-aware UI without a client round-trip).
5
5
  * Isomorphic: no browser globals at import time.
6
6
  */
@@ -72,11 +72,12 @@ export type CreateFlowOptions = {
72
72
  */
73
73
  autoGuest?: boolean;
74
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.
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.
80
81
  */
81
82
  cookies?: boolean;
82
83
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.10.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",