@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.
- package/dist/sdk/client/create-flow.d.ts +3 -3
- package/dist/sdk/client/create-flow.js +103 -112
- package/dist/sdk/client/index.d.ts +1 -0
- package/dist/sdk/client/index.js +1 -0
- package/dist/sdk/client/profile-button.js +2 -2
- package/dist/sdk/client/refresh-store.d.ts +13 -14
- package/dist/sdk/client/refresh-store.js +48 -58
- package/dist/sdk/client/static-flow.js +2 -2
- package/dist/sdk/cookies.d.ts +8 -19
- package/dist/sdk/cookies.js +9 -25
- package/dist/sdk/id-host.d.ts +36 -0
- package/dist/sdk/id-host.js +57 -0
- package/dist/sdk/server.d.ts +8 -18
- package/dist/sdk/server.js +17 -173
- package/dist/sdk/session-core.d.ts +49 -0
- package/dist/sdk/session-core.js +176 -0
- package/dist/sdk/session-route.d.ts +33 -0
- package/dist/sdk/session-route.js +127 -0
- package/dist/sdk/start/index.d.ts +48 -0
- package/dist/sdk/start/index.js +52 -0
- package/dist/sdk/start/server.d.ts +33 -0
- package/dist/sdk/start/server.js +35 -0
- package/dist/sdk/types/index.d.ts +2 -2
- package/dist/sdk/types/sdk.d.ts +7 -11
- package/dist/sdk/types/server.d.ts +30 -2
- package/package.json +15 -2
- package/dist/sdk/client/refresh-store.test.d.ts +0 -1
- package/dist/sdk/client/refresh-store.test.js +0 -90
|
@@ -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
|
+
}
|
package/dist/sdk/server.d.ts
CHANGED
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
import type { ResolvedFlowSession, ResolveSessionOptions } from "./types";
|
|
2
|
-
export
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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>;
|
package/dist/sdk/server.js
CHANGED
|
@@ -1,68 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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>;
|