@flow-industries/id 0.10.0 → 0.12.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,68 +1,7 @@
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";
1
+ import { resolveIdHost } from "./id-host";
2
+ import { resolveSessionCore } from "./session-core";
3
+ export { createSessionHandler, handleSessionRequest } from "./session-route";
4
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
5
  async function fetchProfile(issuerUrl, jwt, audience) {
67
6
  try {
68
7
  const res = await fetch(`${issuerUrl}/api/session/verify`, {
@@ -80,121 +19,26 @@ async function fetchProfile(issuerUrl, jwt, audience) {
80
19
  }
81
20
  }
82
21
  /**
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.
22
+ * Resolves the visitor's Flow session from the request's Cookie header for
23
+ * SSR: render auth-aware UI without a client round-trip, then feed `state`
24
+ * into the SSR payload and `createFlow({ initialState })` so the hydrated
25
+ * client renders it without a second refresh. Append every entry of
26
+ * `setCookies` to the response or the client is left holding a dead
27
+ * predecessor (a server-side refresh ROTATES the token).
100
28
  */
101
29
  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)
30
+ const issuerUrl = resolveIdHost(opts.issuerUrl, opts.audience);
31
+ const { session, claims, setCookies } = await resolveSessionCore(cookieHeader, opts.audience, issuerUrl);
32
+ const state = session
33
+ ? { user: session.user, jwt: session.jwt, address: session.address ?? null }
34
+ : null;
35
+ const profile = state && opts.profile
36
+ ? await fetchProfile(issuerUrl, state.jwt, opts.audience)
193
37
  : undefined;
194
38
  return {
195
39
  state,
196
40
  claims,
197
- setCookies: successorCookies,
41
+ setCookies,
198
42
  ...(profile ? { profile } : {}),
199
43
  };
200
44
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Internal session-resolution core shared by `resolveSession` (SSR) and the
3
+ * session route. Not part of the published API surface — `server.ts` and
4
+ * `session-route.ts` compose it into the public entry points.
5
+ */
6
+ import type { FlowUser, SessionRouteSession, VerifiedFlowJWT } from "./types";
7
+ export declare function claimsToUser(claims: VerifiedFlowJWT): FlowUser;
8
+ export declare function isJwtVerificationFailure(err: unknown): boolean;
9
+ /** The Set-Cookie pair carrying a session (1h JWT + 30-day refresh token). */
10
+ export declare function sessionCookies(audience: string, jwt: string, refreshToken: string): string[];
11
+ /** The Set-Cookie pair deleting both session cookies. */
12
+ export declare function clearingCookies(audience: string): string[];
13
+ /**
14
+ * The resolution result shared by `resolveSession` (SSR) and the session
15
+ * route. On the refresh path `session` carries the authoritative signing
16
+ * state (`credential`/`address`) from the rotation response; on the JWT hot
17
+ * path both keys are absent — the claims carry neither, and the consumer
18
+ * must not overwrite signing state it already holds.
19
+ */
20
+ export type ResolvedSessionCore = {
21
+ session: SessionRouteSession | null;
22
+ claims: VerifiedFlowJWT | null;
23
+ setCookies: string[];
24
+ };
25
+ /**
26
+ * Rotates a refresh token through the issuer and re-verifies the minted JWT.
27
+ * Never trust a minted session unverified: the refresh endpoint binds the
28
+ * JWT to the token ROW's audience, so a cookie planted by a sibling
29
+ * subdomain (or a misconfigured audience) would otherwise resolve into a
30
+ * wrong-app session. Verification failure ⇒ clearing cookies; a transient
31
+ * JWKS failure still hands the successor over (the rotation already
32
+ * happened) but resolves signed-out rather than trusting it. Returns "dead"
33
+ * for a definitive rejection and null for a transient network failure.
34
+ */
35
+ export declare function refreshSessionCore(issuerUrl: string, audience: string, refreshToken: string): Promise<ResolvedSessionCore | "dead" | null>;
36
+ /**
37
+ * Resolves a session from a request's Cookie header.
38
+ *
39
+ * Resolution order:
40
+ * 1. A still-fresh JWT cookie verifies locally against the issuer's JWKS
41
+ * (cached per process) — the hot path, no auth-server call, no cookies
42
+ * to set.
43
+ * 2. Otherwise a refresh cookie is presented to `/api/session/refresh`,
44
+ * which ROTATES it — the returned `setCookies` must reach the response.
45
+ * A definitive rejection (401/403) yields clearing cookies instead; a
46
+ * network failure leaves the cookies alone so a later request can retry.
47
+ * 3. Neither cookie → signed-out `session: null`.
48
+ */
49
+ export declare function resolveSessionCore(cookieHeader: string | null | undefined, audience: string, issuerUrl: string): Promise<ResolvedSessionCore>;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Internal session-resolution core shared by `resolveSession` (SSR) and the
3
+ * session route. Not part of the published API surface — `server.ts` and
4
+ * `session-route.ts` compose it into the public entry points.
5
+ */
6
+ import { clearCookieString, cookieNamesFor, JWT_COOKIE_MAX_AGE_S, parseCookieHeader, REFRESH_COOKIE_MAX_AGE_S, serializeCookie, } from "./cookies";
7
+ import { isExpiring } from "./token-expiry";
8
+ import { verifyFlowJWT } from "./verify";
9
+ export function claimsToUser(claims) {
10
+ return {
11
+ id: claims.sub,
12
+ username: typeof claims.username === "string" ? claims.username : "",
13
+ // Fail-safe: only an explicit `false` marks a full account; a missing or
14
+ // mangled claim must never grant full privileges.
15
+ isGuest: claims.guest !== false,
16
+ };
17
+ }
18
+ async function refreshSession(issuerUrl, token) {
19
+ try {
20
+ const res = await fetch(`${issuerUrl}/api/session/refresh`, {
21
+ method: "POST",
22
+ headers: { Authorization: `Bearer ${token}` },
23
+ });
24
+ if (res.status === 401 || res.status === 403)
25
+ return "dead";
26
+ if (!res.ok)
27
+ return null;
28
+ return (await res.json());
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ // Dedupe a same-instance fan-out (parallel requests carrying the same cookie)
35
+ // into one rotation. Settled results are retained briefly so a request that
36
+ // lands after the rotation but before the browser applied the Set-Cookie is
37
+ // served the same successor instead of re-presenting a dead token; the auth
38
+ // server's grace window covers presenters this map can't see (other replicas,
39
+ // other machines).
40
+ const REFRESH_RESULT_TTL_MS = 30_000;
41
+ const refreshInFlight = new Map();
42
+ function refreshOnce(issuerUrl, token) {
43
+ const key = `${issuerUrl}\n${token}`;
44
+ let flight = refreshInFlight.get(key);
45
+ if (!flight) {
46
+ flight = refreshSession(issuerUrl, token);
47
+ refreshInFlight.set(key, flight);
48
+ void flight.finally(() => {
49
+ const timer = setTimeout(() => refreshInFlight.delete(key), REFRESH_RESULT_TTL_MS);
50
+ timer.unref?.();
51
+ });
52
+ }
53
+ return flight;
54
+ }
55
+ // jose verification-class failures (bad signature, expired, wrong audience,
56
+ // unknown key) mean the token itself is unacceptable; anything else — JWKS
57
+ // fetch failure, timeouts — is transient infrastructure trouble, and falling
58
+ // through to a rotation there would turn a JWKS outage into a rotation storm
59
+ // against the same struggling server.
60
+ export function isJwtVerificationFailure(err) {
61
+ const code = err?.code;
62
+ if (typeof code !== "string")
63
+ return false;
64
+ return (code.startsWith("ERR_JWT") ||
65
+ code.startsWith("ERR_JWS") ||
66
+ code === "ERR_JWKS_NO_MATCHING_KEY" ||
67
+ code === "ERR_JWKS_MULTIPLE_MATCHING_KEYS");
68
+ }
69
+ /** The Set-Cookie pair carrying a session (1h JWT + 30-day refresh token). */
70
+ export function sessionCookies(audience, jwt, refreshToken) {
71
+ const names = cookieNamesFor(audience);
72
+ const secure = audience.startsWith("https:");
73
+ return [
74
+ serializeCookie(names.jwt, jwt, { maxAge: JWT_COOKIE_MAX_AGE_S, secure }),
75
+ serializeCookie(names.refresh, refreshToken, {
76
+ maxAge: REFRESH_COOKIE_MAX_AGE_S,
77
+ secure,
78
+ }),
79
+ ];
80
+ }
81
+ /** The Set-Cookie pair deleting both session cookies. */
82
+ export function clearingCookies(audience) {
83
+ const names = cookieNamesFor(audience);
84
+ const secure = audience.startsWith("https:");
85
+ return [
86
+ clearCookieString(names.jwt, { secure }),
87
+ clearCookieString(names.refresh, { secure }),
88
+ ];
89
+ }
90
+ /**
91
+ * Rotates a refresh token through the issuer and re-verifies the minted JWT.
92
+ * Never trust a minted session unverified: the refresh endpoint binds the
93
+ * JWT to the token ROW's audience, so a cookie planted by a sibling
94
+ * subdomain (or a misconfigured audience) would otherwise resolve into a
95
+ * wrong-app session. Verification failure ⇒ clearing cookies; a transient
96
+ * JWKS failure still hands the successor over (the rotation already
97
+ * happened) but resolves signed-out rather than trusting it. Returns "dead"
98
+ * for a definitive rejection and null for a transient network failure.
99
+ */
100
+ export async function refreshSessionCore(issuerUrl, audience, refreshToken) {
101
+ const result = await refreshOnce(issuerUrl, refreshToken);
102
+ if (result === "dead" || !result)
103
+ return result;
104
+ const successorCookies = sessionCookies(audience, result.jwt, result.refreshToken);
105
+ let claims;
106
+ try {
107
+ claims = await verifyFlowJWT(result.jwt, { audience, issuerUrl });
108
+ }
109
+ catch (err) {
110
+ if (isJwtVerificationFailure(err)) {
111
+ return {
112
+ session: null,
113
+ claims: null,
114
+ setCookies: clearingCookies(audience),
115
+ };
116
+ }
117
+ return { session: null, claims: null, setCookies: successorCookies };
118
+ }
119
+ return {
120
+ session: {
121
+ user: result.user,
122
+ jwt: result.jwt,
123
+ credential: result.credential,
124
+ address: result.address,
125
+ },
126
+ claims,
127
+ setCookies: successorCookies,
128
+ };
129
+ }
130
+ /**
131
+ * Resolves a session from a request's Cookie header.
132
+ *
133
+ * Resolution order:
134
+ * 1. A still-fresh JWT cookie verifies locally against the issuer's JWKS
135
+ * (cached per process) — the hot path, no auth-server call, no cookies
136
+ * to set.
137
+ * 2. Otherwise a refresh cookie is presented to `/api/session/refresh`,
138
+ * which ROTATES it — the returned `setCookies` must reach the response.
139
+ * A definitive rejection (401/403) yields clearing cookies instead; a
140
+ * network failure leaves the cookies alone so a later request can retry.
141
+ * 3. Neither cookie → signed-out `session: null`.
142
+ */
143
+ export async function resolveSessionCore(cookieHeader, audience, issuerUrl) {
144
+ const names = cookieNamesFor(audience);
145
+ const cookies = parseCookieHeader(cookieHeader);
146
+ const jwt = cookies[names.jwt];
147
+ if (jwt && !isExpiring(jwt)) {
148
+ try {
149
+ const claims = await verifyFlowJWT(jwt, { audience, issuerUrl });
150
+ return {
151
+ session: { user: claimsToUser(claims), jwt },
152
+ claims,
153
+ setCookies: [],
154
+ };
155
+ }
156
+ catch (err) {
157
+ if (!isJwtVerificationFailure(err)) {
158
+ return { session: null, claims: null, setCookies: [] };
159
+ }
160
+ }
161
+ }
162
+ const refreshToken = cookies[names.refresh];
163
+ if (!refreshToken)
164
+ return { session: null, claims: null, setCookies: [] };
165
+ const refreshed = await refreshSessionCore(issuerUrl, audience, refreshToken);
166
+ if (refreshed === "dead") {
167
+ return {
168
+ session: null,
169
+ claims: null,
170
+ setCookies: clearingCookies(audience),
171
+ };
172
+ }
173
+ if (!refreshed)
174
+ return { session: null, claims: null, setCookies: [] };
175
+ return refreshed;
176
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The first-party session route every consumer app serves from its own
3
+ * origin — the only path between a browser and the refresh token. The
4
+ * rotating refresh token lives in an HttpOnly cookie owned by the app's
5
+ * server; the browser client calls this route instead of ever holding the
6
+ * token itself:
7
+ *
8
+ * - `GET` resolve: fresh JWT from the cookies, rotating server-side when
9
+ * the access token is expiring.
10
+ * - `POST` install: one-time handoff of a freshly minted session (refresh
11
+ * token + access JWT from the dialog) into the HttpOnly cookies.
12
+ * The JWT verifies locally against the issuer's cached JWKS — no
13
+ * rotation of a seconds-old token; a bogus refresh token simply
14
+ * dies at the first GET, like any planted cookie.
15
+ * - `DELETE` sign-out: revoke the token's lineage at the issuer and clear
16
+ * the cookies.
17
+ *
18
+ * Framework-agnostic (`Request` → `Response`); `flowSessionHandlers` from
19
+ * `@flow-industries/id/start` adapts it to a TanStack Start route file.
20
+ */
21
+ import type { SessionRouteOptions } from "./types";
22
+ /**
23
+ * Handles one session-route request (no path check — the caller routed it).
24
+ * `audience` defaults per `defaultAudience` (`APP_ORIGIN` env, then the
25
+ * request's own origin); `issuerUrl` per `defaultIdHost`.
26
+ */
27
+ export declare function handleSessionRequest(request: Request, opts?: SessionRouteOptions): Promise<Response>;
28
+ /**
29
+ * Wraps `handleSessionRequest` with a path check for servers that route by
30
+ * hand (a Bun/Hono server): returns null for requests that aren't for the
31
+ * session path so the caller can fall through.
32
+ */
33
+ export declare function createSessionHandler(opts?: SessionRouteOptions): (request: Request) => Promise<Response | null>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The first-party session route every consumer app serves from its own
3
+ * origin — the only path between a browser and the refresh token. The
4
+ * rotating refresh token lives in an HttpOnly cookie owned by the app's
5
+ * server; the browser client calls this route instead of ever holding the
6
+ * token itself:
7
+ *
8
+ * - `GET` resolve: fresh JWT from the cookies, rotating server-side when
9
+ * the access token is expiring.
10
+ * - `POST` install: one-time handoff of a freshly minted session (refresh
11
+ * token + access JWT from the dialog) into the HttpOnly cookies.
12
+ * The JWT verifies locally against the issuer's cached JWKS — no
13
+ * rotation of a seconds-old token; a bogus refresh token simply
14
+ * dies at the first GET, like any planted cookie.
15
+ * - `DELETE` sign-out: revoke the token's lineage at the issuer and clear
16
+ * the cookies.
17
+ *
18
+ * Framework-agnostic (`Request` → `Response`); `flowSessionHandlers` from
19
+ * `@flow-industries/id/start` adapts it to a TanStack Start route file.
20
+ */
21
+ import { cookieNamesFor, parseCookieHeader } from "./cookies";
22
+ import { DEFAULT_SESSION_PATH, defaultAudience, resolveIdHost, } from "./id-host";
23
+ import { claimsToUser, clearingCookies, isJwtVerificationFailure, resolveSessionCore, sessionCookies, } from "./session-core";
24
+ import { verifyFlowJWT } from "./verify";
25
+ function json(body, status, setCookies = []) {
26
+ const headers = new Headers({
27
+ "Content-Type": "application/json",
28
+ "Cache-Control": "no-store",
29
+ });
30
+ for (const cookie of setCookies)
31
+ headers.append("Set-Cookie", cookie);
32
+ return new Response(JSON.stringify(body), { status, headers });
33
+ }
34
+ /**
35
+ * Cross-site requests must never reach the session route: the cookies are
36
+ * SameSite=Lax already, so this is defense in depth. `Sec-Fetch-Site` is
37
+ * authoritative where present ("none" = direct navigation, harmless); the
38
+ * Origin header — sent by browsers on every non-GET — must match the app.
39
+ * Requests carrying neither header (server-to-server tooling) pass.
40
+ */
41
+ function isCrossSite(request, audience) {
42
+ const site = request.headers.get("Sec-Fetch-Site");
43
+ if (site && site !== "same-origin" && site !== "none")
44
+ return true;
45
+ const origin = request.headers.get("Origin");
46
+ if (origin && origin !== audience && origin !== new URL(request.url).origin) {
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ /**
52
+ * Handles one session-route request (no path check — the caller routed it).
53
+ * `audience` defaults per `defaultAudience` (`APP_ORIGIN` env, then the
54
+ * request's own origin); `issuerUrl` per `defaultIdHost`.
55
+ */
56
+ export async function handleSessionRequest(request, opts = {}) {
57
+ const audience = opts.audience ?? defaultAudience(new URL(request.url).origin);
58
+ const issuerUrl = resolveIdHost(opts.issuerUrl, audience);
59
+ if (isCrossSite(request, audience)) {
60
+ return json({ state: null }, 403);
61
+ }
62
+ switch (request.method) {
63
+ case "GET": {
64
+ const { session, setCookies } = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl);
65
+ return json({ state: session }, 200, setCookies);
66
+ }
67
+ case "POST": {
68
+ const body = (await request.json().catch(() => null));
69
+ const refreshToken = body?.refreshToken;
70
+ const jwt = body?.jwt;
71
+ if (typeof refreshToken !== "string" ||
72
+ refreshToken.length === 0 ||
73
+ typeof jwt !== "string" ||
74
+ jwt.length === 0) {
75
+ return json({ state: null }, 400);
76
+ }
77
+ // The audience check on the verified JWT is what stops a token minted
78
+ // for another app from being installed here; a failed install must not
79
+ // clear cookies that may hold a live session.
80
+ try {
81
+ const claims = await verifyFlowJWT(jwt, { audience, issuerUrl });
82
+ return json({ state: { user: claimsToUser(claims), jwt } }, 200, sessionCookies(audience, jwt, refreshToken));
83
+ }
84
+ catch (err) {
85
+ return json({ state: null }, isJwtVerificationFailure(err) ? 401 : 502);
86
+ }
87
+ }
88
+ case "DELETE": {
89
+ const names = cookieNamesFor(audience);
90
+ const token = parseCookieHeader(request.headers.get("Cookie"))[names.refresh];
91
+ // Best-effort: clearing the cookies signs this browser out either way;
92
+ // an unreachable issuer just leaves the lineage to expire on its own.
93
+ if (token) {
94
+ try {
95
+ await fetch(`${issuerUrl}/api/session/revoke`, {
96
+ method: "POST",
97
+ headers: { Authorization: `Bearer ${token}` },
98
+ });
99
+ }
100
+ catch { }
101
+ }
102
+ const headers = new Headers({ "Cache-Control": "no-store" });
103
+ for (const cookie of clearingCookies(audience)) {
104
+ headers.append("Set-Cookie", cookie);
105
+ }
106
+ return new Response(null, { status: 204, headers });
107
+ }
108
+ default:
109
+ return new Response(null, {
110
+ status: 405,
111
+ headers: { Allow: "GET, POST, DELETE" },
112
+ });
113
+ }
114
+ }
115
+ /**
116
+ * Wraps `handleSessionRequest` with a path check for servers that route by
117
+ * hand (a Bun/Hono server): returns null for requests that aren't for the
118
+ * session path so the caller can fall through.
119
+ */
120
+ export function createSessionHandler(opts = {}) {
121
+ const path = opts.path ?? DEFAULT_SESSION_PATH;
122
+ return async (request) => {
123
+ if (new URL(request.url).pathname !== path)
124
+ return null;
125
+ return handleSessionRequest(request, opts);
126
+ };
127
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * TanStack Start integration — the whole Flow ID wiring a Start app needs,
3
+ * so integrating a new domain is one route file plus one root wrapper:
4
+ *
5
+ * ```tsx
6
+ * // src/routes/flow.session.ts — the first-party session route + SSR fn
7
+ * import { createServerFn } from "@tanstack/react-start";
8
+ * import { createFileRoute } from "@tanstack/react-router";
9
+ * import { flowSessionHandlers, resolveFlowSessionRequest } from "@flow-industries/id/start/server";
10
+ *
11
+ * export const getFlowSession = createServerFn({ method: "GET" }).handler(resolveFlowSessionRequest);
12
+ * export const Route = createFileRoute("/flow/session")({ server: { handlers: flowSessionHandlers() } });
13
+ *
14
+ * // src/routes/__root.tsx
15
+ * beforeLoad: () => flowSessionContext(getFlowSession),
16
+ * // and in the root component:
17
+ * <FlowRoot session={session}>{children}</FlowRoot>
18
+ * ```
19
+ *
20
+ * The server fn stays in app source because TanStack's compiler splits it
21
+ * into the server bundle there; everything it does lives here.
22
+ */
23
+ import { type ReactNode } from "react";
24
+ import type { FlowSessionState } from "../types";
25
+ /**
26
+ * The `beforeLoad` body: during SSR resolve the session server-side (via the
27
+ * app's `getFlowSession` server fn, which appends rotated Set-Cookie values);
28
+ * in the browser snapshot the live singleton instead — no network.
29
+ */
30
+ export declare function flowSessionContext(getSession: () => Promise<FlowSessionState | null>): Promise<{
31
+ session: FlowSessionState | null;
32
+ }>;
33
+ export type FlowRootProps = {
34
+ /** The session resolved by `flowSessionContext` in `beforeLoad`. */
35
+ session: FlowSessionState | null;
36
+ /** Mint a silent guest for first-time visitors (see `CreateFlowOptions`). */
37
+ autoGuest?: boolean;
38
+ /** Override the Flow ID origin; defaults per `defaultIdHost`. */
39
+ host?: string;
40
+ children: ReactNode;
41
+ };
42
+ /**
43
+ * Renders the subtree under a request-scoped static Flow on the server and
44
+ * the seeded browser singleton on the client, so the first client render
45
+ * matches the SSR HTML and no bootstrap refresh runs while the hydrated JWT
46
+ * is fresh.
47
+ */
48
+ export declare function FlowRoot({ session, autoGuest, host, children, }: FlowRootProps): import("react/jsx-runtime").JSX.Element;