@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.
- 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/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 -10
- package/dist/sdk/types/server.d.ts +30 -2
- package/package.json +15 -2
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>;
|
|
@@ -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;
|