@flow-industries/id 0.11.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.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared defaults for the first-party session plumbing. The browser client,
3
+ * the SSR resolver, and the session route all resolve the Flow ID origin and
4
+ * the app-local session path from here, so consumer apps agree on both
5
+ * without carrying any per-app configuration.
6
+ */
7
+ export declare const DEFAULT_ISSUER_URL = "https://id.flow.industries";
8
+ /** Flow ID origin of a local auth stack (`bun run dev` in the auth repo). */
9
+ export declare const LOCAL_ISSUER_URL = "http://localhost:5175";
10
+ /**
11
+ * Path of the first-party session route every app serves from its own origin
12
+ * (GET resolve, POST install, DELETE sign-out). The browser client and the
13
+ * route registration must agree on it; override via `createFlow({ sessionPath })`
14
+ * plus the route's `path` option only when an app cannot claim this path.
15
+ */
16
+ export declare const DEFAULT_SESSION_PATH = "/flow/session";
17
+ export declare function isLocalHostname(hostname: string): boolean;
18
+ /**
19
+ * The app's public origin — the JWT audience and what cookie names derive
20
+ * from. The `APP_ORIGIN` env var wins (mandatory behind a TLS-terminating
21
+ * proxy, where the request origin is the internal http one); dev falls back
22
+ * to the request's own origin.
23
+ */
24
+ export declare function defaultAudience(requestOrigin: string): string;
25
+ /**
26
+ * Resolves the Flow ID origin for the current runtime: the `FLOW_ID_HOST`
27
+ * env var wins on servers, a localhost app talks to the local auth stack,
28
+ * everything else uses production. Pass the app origin when resolving on
29
+ * behalf of a request (servers have no `window`).
30
+ */
31
+ export declare function defaultIdHost(appOrigin?: string): string;
32
+ /**
33
+ * An explicit host override or the runtime default, normalized (no trailing
34
+ * slash) so callers can append paths without producing `//api/...` URLs.
35
+ */
36
+ export declare function resolveIdHost(override?: string | null, appOrigin?: string): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared defaults for the first-party session plumbing. The browser client,
3
+ * the SSR resolver, and the session route all resolve the Flow ID origin and
4
+ * the app-local session path from here, so consumer apps agree on both
5
+ * without carrying any per-app configuration.
6
+ */
7
+ export const DEFAULT_ISSUER_URL = "https://id.flow.industries";
8
+ /** Flow ID origin of a local auth stack (`bun run dev` in the auth repo). */
9
+ export const LOCAL_ISSUER_URL = "http://localhost:5175";
10
+ /**
11
+ * Path of the first-party session route every app serves from its own origin
12
+ * (GET resolve, POST install, DELETE sign-out). The browser client and the
13
+ * route registration must agree on it; override via `createFlow({ sessionPath })`
14
+ * plus the route's `path` option only when an app cannot claim this path.
15
+ */
16
+ export const DEFAULT_SESSION_PATH = "/flow/session";
17
+ export function isLocalHostname(hostname) {
18
+ return (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]");
19
+ }
20
+ /**
21
+ * The app's public origin — the JWT audience and what cookie names derive
22
+ * from. The `APP_ORIGIN` env var wins (mandatory behind a TLS-terminating
23
+ * proxy, where the request origin is the internal http one); dev falls back
24
+ * to the request's own origin.
25
+ */
26
+ export function defaultAudience(requestOrigin) {
27
+ return ((typeof process !== "undefined" ? process.env?.APP_ORIGIN : undefined) ??
28
+ requestOrigin);
29
+ }
30
+ /**
31
+ * Resolves the Flow ID origin for the current runtime: the `FLOW_ID_HOST`
32
+ * env var wins on servers, a localhost app talks to the local auth stack,
33
+ * everything else uses production. Pass the app origin when resolving on
34
+ * behalf of a request (servers have no `window`).
35
+ */
36
+ export function defaultIdHost(appOrigin) {
37
+ if (typeof process !== "undefined" && process.env?.FLOW_ID_HOST) {
38
+ return process.env.FLOW_ID_HOST;
39
+ }
40
+ const origin = appOrigin ??
41
+ (typeof window !== "undefined" ? window.location.origin : undefined);
42
+ if (origin) {
43
+ try {
44
+ if (isLocalHostname(new URL(origin).hostname))
45
+ return LOCAL_ISSUER_URL;
46
+ }
47
+ catch { }
48
+ }
49
+ return DEFAULT_ISSUER_URL;
50
+ }
51
+ /**
52
+ * An explicit host override or the runtime default, normalized (no trailing
53
+ * slash) so callers can append paths without producing `//api/...` URLs.
54
+ */
55
+ export function resolveIdHost(override, appOrigin) {
56
+ return (override ?? defaultIdHost(appOrigin)).replace(/\/+$/, "");
57
+ }
@@ -1,23 +1,13 @@
1
1
  import type { ResolvedFlowSession, ResolveSessionOptions } from "./types";
2
- export type { FlowSessionState, ResolvedFlowSession, ResolveSessionOptions, } from "./types";
2
+ export { createSessionHandler, handleSessionRequest } from "./session-route";
3
+ export type { FlowSessionState, ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./types";
3
4
  export { verifyFlowJWT } from "./verify";
4
5
  /**
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.
6
+ * Resolves the visitor's Flow session from the request's Cookie header for
7
+ * SSR: render auth-aware UI without a client round-trip, then feed `state`
8
+ * into the SSR payload and `createFlow({ initialState })` so the hydrated
9
+ * client renders it without a second refresh. Append every entry of
10
+ * `setCookies` to the response or the client is left holding a dead
11
+ * predecessor (a server-side refresh ROTATES the token).
22
12
  */
23
13
  export declare function resolveSession(cookieHeader: string | null | undefined, opts: ResolveSessionOptions): Promise<ResolvedFlowSession>;
@@ -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>;