@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.
- package/dist/sdk/client/refresh-store.d.ts +14 -13
- package/dist/sdk/client/refresh-store.js +58 -48
- package/dist/sdk/client/refresh-store.test.d.ts +1 -0
- package/dist/sdk/client/refresh-store.test.js +90 -0
- package/dist/sdk/cookies.d.ts +1 -1
- package/dist/sdk/cookies.js +1 -1
- package/dist/sdk/types/sdk.d.ts +6 -5
- package/package.json +1 -1
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
import type { RefreshCookieConfig } from "../types";
|
|
2
2
|
/**
|
|
3
|
-
* First-party persistence for the rotating refresh token
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
});
|
package/dist/sdk/cookies.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* First-party cookie names and (de)serialization shared by the browser SDK
|
|
3
|
-
* (which
|
|
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
|
*/
|
package/dist/sdk/cookies.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* First-party cookie names and (de)serialization shared by the browser SDK
|
|
3
|
-
* (which
|
|
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
|
*/
|
package/dist/sdk/types/sdk.d.ts
CHANGED
|
@@ -72,11 +72,12 @@ export type CreateFlowOptions = {
|
|
|
72
72
|
*/
|
|
73
73
|
autoGuest?: boolean;
|
|
74
74
|
/**
|
|
75
|
-
* If true, the
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
/**
|