@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.
- package/dist/sdk/client/create-flow.js +50 -26
- package/dist/sdk/client/index.d.ts +2 -1
- package/dist/sdk/client/index.js +1 -0
- package/dist/sdk/client/refresh-store.d.ts +16 -6
- package/dist/sdk/client/refresh-store.js +53 -7
- package/dist/sdk/client/refresh-store.test.d.ts +1 -0
- package/dist/sdk/client/refresh-store.test.js +90 -0
- package/dist/sdk/client/static-flow.d.ts +14 -0
- package/dist/sdk/client/static-flow.js +65 -0
- package/dist/sdk/cookies.d.ts +45 -0
- package/dist/sdk/cookies.js +81 -0
- package/dist/sdk/server.d.ts +23 -0
- package/dist/sdk/server.js +200 -0
- package/dist/sdk/token-expiry.d.ts +5 -0
- package/dist/sdk/token-expiry.js +25 -0
- package/dist/sdk/types/events.d.ts +1 -1
- package/dist/sdk/types/index.d.ts +2 -1
- package/dist/sdk/types/sdk.d.ts +41 -0
- package/dist/sdk/types/server.d.ts +28 -0
- package/dist/sdk/types/server.js +0 -0
- package/package.json +5 -1
|
@@ -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
|
|
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";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
|
+
import type { RefreshCookieConfig } from "../types";
|
|
1
2
|
/**
|
|
2
|
-
* First-party persistence for the rotating refresh token
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
2
|
-
|
|
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";
|
package/dist/sdk/types/sdk.d.ts
CHANGED
|
@@ -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.
|
|
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": [
|