@synap-core/sdk 1.3.0 → 1.5.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/CHANGELOG.md +133 -0
- package/README.md +212 -12
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-CVUT1Mcf.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-CVUT1Mcf.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +790 -161
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +610 -37
- package/dist/connect.d.ts +610 -37
- package/dist/connect.js +777 -162
- package/dist/connect.js.map +1 -1
- package/dist/public.cjs +9 -2
- package/dist/public.cjs.map +1 -1
- package/dist/public.d.cts +19 -4
- package/dist/public.d.ts +19 -4
- package/dist/public.js +8 -2
- package/dist/public.js.map +1 -1
- package/dist/testing.cjs +366 -17
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +190 -7
- package/dist/testing.d.ts +190 -7
- package/dist/testing.js +358 -17
- package/dist/testing.js.map +1 -1
- package/dist/{types-DpqPNaaa.d.cts → types-DZYNBCq7.d.cts} +47 -5
- package/dist/{types-DpqPNaaa.d.ts → types-DZYNBCq7.d.ts} +47 -5
- package/package.json +36 -12
- package/src/connect/auth.ts +314 -55
- package/src/connect/client.ts +472 -26
- package/src/connect/cloud.ts +210 -0
- package/src/connect/discovery.ts +79 -44
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +8 -1
- package/src/connect/flow.ts +80 -12
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +254 -0
- package/src/connect/index.ts +69 -23
- package/src/connect/rules.ts +18 -0
- package/src/connect/seal.ts +1 -1
- package/src/connect/store.ts +50 -8
- package/src/connect/types.ts +48 -4
- package/src/public/client.ts +14 -2
- package/src/public/safe-markdown.ts +13 -0
- package/src/public/share-model.ts +6 -2
- package/src/public/site-model.ts +5 -3
- package/src/testing/auth-server.ts +377 -20
- package/src/testing/cloud.ts +83 -0
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +10 -6
- package/src/testing/seed.ts +120 -0
- package/dist/chunk-J7XHAAFJ.js.map +0 -1
package/src/connect/auth.ts
CHANGED
|
@@ -8,23 +8,35 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Handlers:
|
|
10
10
|
* GET <metadata> the app's client metadata document (its client_id URL)
|
|
11
|
-
* POST <connect> form { pod, next?, space? } → 303 to the pod's /authorize
|
|
11
|
+
* POST <connect> form { pod, next?, space?, prompt? } → 303 to the pod's /authorize
|
|
12
12
|
* GET <callback> the pod's answer → sealed session cookie → 303 to `next`
|
|
13
13
|
* POST <disconnect> revoke at the pod (best effort), clear the cookie → 303
|
|
14
|
+
* POST <cloud> form { next?, space?, prompt? } → 303 to the Synap Cloud pod chooser
|
|
15
|
+
* GET <cloud> the chooser's return (?iss&state, state checked) → 303 to the pod's /authorize
|
|
16
|
+
*
|
|
17
|
+
* `prompt=consent` asks for the pod's consent screen again (widen or narrow
|
|
18
|
+
* what the app may touch); without it a person already connected to that pod
|
|
19
|
+
* goes straight to `next`. Either way a sign-in is a fresh PKCE + state flow.
|
|
14
20
|
*
|
|
15
21
|
* `connect` and `disconnect` change state, so they are POST and same-origin
|
|
16
22
|
* only (Origin, else Sec-Fetch-Site): a third-party page cannot start a
|
|
17
23
|
* sign-in into an attacker's pod for a visitor (login CSRF) or sign them out.
|
|
18
24
|
* Failures redirect with `?error=<ConnectErrorCode>` and nothing else.
|
|
25
|
+
*
|
|
26
|
+
* Each successful sign-in or refresh also sets a marker cookie holding the
|
|
27
|
+
* pod's URL and no secret (`markerCookie`), so a connection that lapsed reads
|
|
28
|
+
* as "expired" (`readLapsed`, `RefreshOutcome.lapsed`), not "never connected".
|
|
19
29
|
*/
|
|
30
|
+
import { acceptCloudReturn, cloudBaseUrl, CLOUD_TRANSACTION_TTL_MS, startCloudConnect } from "./cloud";
|
|
20
31
|
import { safeNextPath, canonicalPodUrl } from "./discovery";
|
|
21
32
|
import { ConnectError, isConnectError } from "./errors";
|
|
22
33
|
import { serializeCookie, parseCookies } from "./encoding";
|
|
23
34
|
import { createPodClient, type PodClient, type PodClientOptions } from "./client";
|
|
24
35
|
import { assertPermissions, handleCallback, refreshConnection, revokeConnection, startConnect, TRANSACTION_TTL_MS } from "./flow";
|
|
25
36
|
import { assertServerRuntime } from "./guard";
|
|
37
|
+
import { isIpLiteral, LOOPBACK_HOSTS, SPACE_HINT_RE } from "./rules";
|
|
26
38
|
import { assertAppSecret, type AppSecret } from "./seal";
|
|
27
|
-
import { applySetCookies, cookieNames, createSealedCookieStore, type ConnectionStore } from "./store";
|
|
39
|
+
import { applySetCookies, cookieNames, createSealedCookieStore, markerCookie, readMarker, type ConnectionStore } from "./store";
|
|
28
40
|
import type { AppRequest, ClientMetadataDocument, PodConnection } from "./types";
|
|
29
41
|
|
|
30
42
|
export interface PodAppPaths {
|
|
@@ -33,6 +45,8 @@ export interface PodAppPaths {
|
|
|
33
45
|
connect: string;
|
|
34
46
|
callback: string;
|
|
35
47
|
disconnect: string;
|
|
48
|
+
/** Synap Cloud: POST starts it, GET is the pod chooser's return (forwarded from the connect page). */
|
|
49
|
+
cloud: string;
|
|
36
50
|
/** The app's connect page (also the CIMD `initiate_login_uri`). */
|
|
37
51
|
connectPage: string;
|
|
38
52
|
/** Where a failed callback lands, with `?error=<code>`. */
|
|
@@ -46,6 +60,7 @@ export const DEFAULT_PATHS: PodAppPaths = {
|
|
|
46
60
|
connect: "/api/synap/connect",
|
|
47
61
|
callback: "/api/synap/callback",
|
|
48
62
|
disconnect: "/api/synap/disconnect",
|
|
63
|
+
cloud: "/api/synap/cloud",
|
|
49
64
|
connectPage: "/connect",
|
|
50
65
|
resultPage: "/connect/result",
|
|
51
66
|
afterConnect: "/",
|
|
@@ -55,7 +70,12 @@ export const DEFAULT_PATHS: PodAppPaths = {
|
|
|
55
70
|
export const REFRESH_WINDOW_MS = 5 * 60 * 1000;
|
|
56
71
|
|
|
57
72
|
export interface PodAppAuthConfig {
|
|
58
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* The app's origin, e.g. https://studio.example.com. `http://localhost` is
|
|
75
|
+
* accepted for the SDK's MOCK pod only: every real pod, a local one
|
|
76
|
+
* included, refuses an http client_id and redirect_uri, so an app that signs
|
|
77
|
+
* in to a real pod runs on https (a tunnel in development).
|
|
78
|
+
*/
|
|
59
79
|
appUrl: string;
|
|
60
80
|
/** Shown on the pod's consent screen (at most 100 characters). */
|
|
61
81
|
clientName: string;
|
|
@@ -70,7 +90,16 @@ export interface PodAppAuthConfig {
|
|
|
70
90
|
store?: ConnectionStore;
|
|
71
91
|
/** RFC 8707 resource asked for (default the Hub API). */
|
|
72
92
|
resource?: "api/hub" | "mcp" | null;
|
|
73
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* The Synap Control Plane's URL (e.g. https://api.synap.live): offers
|
|
95
|
+
* "Continue with Synap Cloud" (`cloudConnectAvailable`). Unset = address only.
|
|
96
|
+
*/
|
|
97
|
+
cloudUrl?: string | null;
|
|
98
|
+
/**
|
|
99
|
+
* Accept a pod (and a Control Plane) on http://localhost: development
|
|
100
|
+
* against the SDK's mock pod (`@synap-core/sdk/testing`). A real local pod
|
|
101
|
+
* still refuses an app that is not on https (see `appUrl`).
|
|
102
|
+
*/
|
|
74
103
|
allowInsecureLoopback?: boolean;
|
|
75
104
|
/** Developer log of every refused sign-in; messages never carry a token. Default: console.warn. */
|
|
76
105
|
onError?: (error: ConnectError, where: "connect" | "callback" | "disconnect" | "refresh") => void;
|
|
@@ -89,9 +118,9 @@ export type RefreshStatus =
|
|
|
89
118
|
| "refreshed"
|
|
90
119
|
/** The refresh failed (a lost rotation race, the pod busy) but the access token still works: keep it, set nothing. */
|
|
91
120
|
| "kept"
|
|
92
|
-
/** The access token is spent and cannot be renewed: connect again. */
|
|
121
|
+
/** The access token is spent and cannot be renewed (lapsed, or the pod moved: `error.code`): connect again. The session cookie is cleared (the marker stays). */
|
|
93
122
|
| "expired"
|
|
94
|
-
/** The pod refused the refresh token (access removed): connect again. */
|
|
123
|
+
/** The pod refused the refresh token (access removed): connect again. The session cookie is cleared (the marker stays). */
|
|
95
124
|
| "revoked"
|
|
96
125
|
/** The pod could not be reached and the access token is spent: try later. */
|
|
97
126
|
| "unavailable";
|
|
@@ -105,6 +134,36 @@ export interface RefreshOutcome {
|
|
|
105
134
|
/** The request's Cookie header with the refreshed value, to forward to the render (null = unchanged). */
|
|
106
135
|
cookieHeader: string | null;
|
|
107
136
|
error?: ConnectError;
|
|
137
|
+
/**
|
|
138
|
+
* With `none`: this browser WAS connected to that pod and the connection
|
|
139
|
+
* lapsed (its sealed cookie is gone, idle past its life). Say "expired,
|
|
140
|
+
* connect again" with the pod pre-filled, not "connect your pod".
|
|
141
|
+
*/
|
|
142
|
+
lapsed?: { podUrl: string };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface RefreshOptions {
|
|
146
|
+
/**
|
|
147
|
+
* Rotate now, whatever the access token's expiry says: the pod refused it
|
|
148
|
+
* (a Hub call answered `unauthorized` with `refreshable`). Needs a refresh
|
|
149
|
+
* token. If the pod refuses the refresh too, the outcome is `revoked` (or
|
|
150
|
+
* `expired`), never `kept`: the access token is known dead.
|
|
151
|
+
*/
|
|
152
|
+
force?: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface PodAppClientOptions extends PodClientOptions {
|
|
156
|
+
/**
|
|
157
|
+
* Where the client may set cookies (a route handler, a server action): after
|
|
158
|
+
* a 401 it then renews the connection ONCE (rotate, seal, `persist` the
|
|
159
|
+
* Set-Cookie values, retry the request once). The rotation spends the old
|
|
160
|
+
* refresh token, so it is never done where its result cannot be stored: a
|
|
161
|
+
* server component passes no `persist`, and a 401 there answers
|
|
162
|
+
* `unauthorized` with `refreshable` (see `RefreshOptions.force`).
|
|
163
|
+
*/
|
|
164
|
+
persist?: (setCookies: string[]) => void | Promise<void>;
|
|
165
|
+
/** The request (or its Cookie header) the connection came from: chunks a larger old cookie left are cleared. */
|
|
166
|
+
request?: Request | string | null;
|
|
108
167
|
}
|
|
109
168
|
|
|
110
169
|
export interface PodAppAuth {
|
|
@@ -112,11 +171,17 @@ export interface PodAppAuth {
|
|
|
112
171
|
redirectUri: string;
|
|
113
172
|
metadata: ClientMetadataDocument;
|
|
114
173
|
paths: PodAppPaths;
|
|
174
|
+
/** Whether "Continue with Synap Cloud" can be offered (`cloudUrl` is configured). */
|
|
175
|
+
cloudConnectAvailable: boolean;
|
|
115
176
|
handlers: {
|
|
116
177
|
clientMetadata(request: Request): Promise<Response>;
|
|
117
178
|
connect(request: Request): Promise<Response>;
|
|
118
179
|
callback(request: Request): Promise<Response>;
|
|
119
180
|
disconnect(request: Request): Promise<Response>;
|
|
181
|
+
/** POST: start a Synap Cloud sign-in (same-origin, form { next?, space?, prompt? }). */
|
|
182
|
+
cloudConnect(request: Request): Promise<Response>;
|
|
183
|
+
/** GET: the Synap Cloud pod chooser's return (`?iss&state`), checked against this browser's cookie. */
|
|
184
|
+
cloudReturn(request: Request): Promise<Response>;
|
|
120
185
|
};
|
|
121
186
|
/**
|
|
122
187
|
* The stored connection (no refresh, no expiry check), or null. Takes the
|
|
@@ -124,15 +189,54 @@ export interface PodAppAuth {
|
|
|
124
189
|
* pass `(await headers()).get("cookie")`).
|
|
125
190
|
*/
|
|
126
191
|
readConnection(source: Request | string | null): Promise<PodConnection | null>;
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
192
|
+
/**
|
|
193
|
+
* The pod this browser WAS connected to, when no connection is stored any
|
|
194
|
+
* more (it lapsed); null when one is stored or this browser never connected
|
|
195
|
+
* (or disconnected). Same sources as `readConnection`.
|
|
196
|
+
*/
|
|
197
|
+
readLapsed(source: Request | string | null): Promise<{ podUrl: string } | null>;
|
|
198
|
+
/** For `proxy.ts` / a route handler: rotate near expiry (or now, `force`), lost-race safe. */
|
|
199
|
+
refreshIfNeeded(request: Request, opts?: RefreshOptions): Promise<RefreshOutcome>;
|
|
200
|
+
/** A Hub REST client for the connection (renewing it after a 401 when it can `persist`). */
|
|
201
|
+
client(connection: PodConnection, opts?: PodAppClientOptions): PodClient;
|
|
131
202
|
}
|
|
132
203
|
|
|
133
|
-
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
134
204
|
const TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
|
|
135
205
|
const MAX_METADATA_BYTES = 5 * 1024;
|
|
206
|
+
const MAX_REQUESTS = 32;
|
|
207
|
+
/** How long a refused refresh is not asked again while the access token still works ("kept"). */
|
|
208
|
+
const KEPT_RETRY_MS = 60_000;
|
|
209
|
+
|
|
210
|
+
/** The pod's grant actions: the only verbs a pod app may ask for (client-metadata.ts `assertAppPermission`). */
|
|
211
|
+
const GRANT_ACTIONS = ["read", "create", "update", "delete"];
|
|
212
|
+
/** The review verbs only an official Synap app (`https://<id>.synap.live`) may ask for, spelled exactly. */
|
|
213
|
+
const SYNAP_APP_REVIEW_PERMISSIONS = ["proposal.approve", "proposal.reject"];
|
|
214
|
+
const PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
215
|
+
|
|
216
|
+
function isSynapAppOrigin(origin: string): boolean {
|
|
217
|
+
return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* One `synap_requests[].permission`, refused here exactly when the pod would
|
|
222
|
+
* refuse it: `<subject>[.<kind>].<action>`, lowercase, a kind only on
|
|
223
|
+
* `entity`, an action in the pod's grant actions (the review verbs only for a
|
|
224
|
+
* Synap app). Stricter on one point: no `*` at all (`can()` matches exactly).
|
|
225
|
+
*/
|
|
226
|
+
function assertAppPermission(permission: string, origin: string): void {
|
|
227
|
+
const segs = permission.split(".");
|
|
228
|
+
const fail = (why: string) => {
|
|
229
|
+
throw new ConnectError("misconfigured", `requests: "${permission}" ${why}`);
|
|
230
|
+
};
|
|
231
|
+
if (segs.length < 2 || segs.length > 3 || segs.some((seg) => !PERMISSION_SEGMENT_RE.test(seg))) {
|
|
232
|
+
fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
|
|
233
|
+
}
|
|
234
|
+
if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
|
|
235
|
+
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && isSynapAppOrigin(origin)) return;
|
|
236
|
+
if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
|
|
237
|
+
fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
136
240
|
|
|
137
241
|
function appOriginOf(appUrl: string): { origin: string; secure: boolean } {
|
|
138
242
|
let url: URL;
|
|
@@ -142,8 +246,11 @@ function appOriginOf(appUrl: string): { origin: string; secure: boolean } {
|
|
|
142
246
|
throw new ConnectError("misconfigured", "appUrl is not a URL");
|
|
143
247
|
}
|
|
144
248
|
const secure = url.protocol === "https:";
|
|
145
|
-
if (!secure && !(url.protocol === "http:" &&
|
|
146
|
-
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
|
|
249
|
+
if (!secure && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
|
|
250
|
+
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost, for the SDK's mock pod)");
|
|
251
|
+
}
|
|
252
|
+
if (secure && isIpLiteral(url.hostname)) {
|
|
253
|
+
throw new ConnectError("misconfigured", "appUrl must use a domain name: the pod refuses an IP address client_id");
|
|
147
254
|
}
|
|
148
255
|
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
149
256
|
throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
|
|
@@ -157,7 +264,12 @@ function assertPath(name: string, path: string): void {
|
|
|
157
264
|
}
|
|
158
265
|
}
|
|
159
266
|
|
|
160
|
-
/**
|
|
267
|
+
/**
|
|
268
|
+
* The app's client metadata document (its client_id URL serves it). Refuses,
|
|
269
|
+
* at setup, every document the pod would refuse at sign-in: the permissions
|
|
270
|
+
* (`assertAppPermission`), at most 32 requests counted as sent, duplicates
|
|
271
|
+
* included, an IP address origin, the 5 KB cap.
|
|
272
|
+
*/
|
|
161
273
|
export function buildClientMetadata(opts: {
|
|
162
274
|
appUrl: string;
|
|
163
275
|
clientName: string;
|
|
@@ -170,9 +282,13 @@ export function buildClientMetadata(opts: {
|
|
|
170
282
|
for (const [name, path] of Object.entries(paths)) assertPath(name, path);
|
|
171
283
|
const clientName = opts.clientName.trim();
|
|
172
284
|
if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
|
|
285
|
+
// The pod counts the entries as sent, before it drops duplicates.
|
|
286
|
+
if (opts.requests.length === 0 || opts.requests.length > MAX_REQUESTS) {
|
|
287
|
+
throw new ConnectError("misconfigured", `requests must hold 1-${MAX_REQUESTS} entries (duplicates count)`);
|
|
288
|
+
}
|
|
173
289
|
assertPermissions(opts.requests.map((r) => r.permission));
|
|
174
290
|
for (const r of opts.requests) {
|
|
175
|
-
|
|
291
|
+
assertAppPermission(r.permission, origin);
|
|
176
292
|
if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
|
|
177
293
|
throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
|
|
178
294
|
}
|
|
@@ -231,9 +347,23 @@ async function readForm(request: Request): Promise<FormData | null> {
|
|
|
231
347
|
}
|
|
232
348
|
}
|
|
233
349
|
|
|
234
|
-
|
|
350
|
+
/**
|
|
351
|
+
* A prefetch or a static asset: `refreshIfNeeded` answers `skipped` for these.
|
|
352
|
+
*
|
|
353
|
+
* LIMIT (Next.js 16 `proxy.ts` on the Node runtime): Next strips its router
|
|
354
|
+
* headers (`next-router-prefetch`, `rsc`, …, `FLIGHT_HEADERS` in
|
|
355
|
+
* next/dist/server/web/adapter.js) and the `_rsc` query BEFORE the proxy runs,
|
|
356
|
+
* so a router prefetch is invisible here. Skip prefetches in the proxy
|
|
357
|
+
* MATCHER, which still sees the raw headers:
|
|
358
|
+
*
|
|
359
|
+
* export const config = { matcher: [{ source: "/((?!_next/static|_next/image|.*\\.\\w+$).*)",
|
|
360
|
+
* missing: [{ type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }] }] };
|
|
361
|
+
*
|
|
362
|
+
* Route handlers and other frameworks see the headers, and this still checks them.
|
|
363
|
+
*/
|
|
364
|
+
export function isPrefetchOrAsset(request: Request): boolean {
|
|
235
365
|
const h = request.headers;
|
|
236
|
-
if (h.get("next-router-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
366
|
+
if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
237
367
|
if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
|
|
238
368
|
const path = new URL(request.url).pathname;
|
|
239
369
|
return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
|
|
@@ -245,6 +375,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
245
375
|
const { origin, secure } = appOriginOf(config.appUrl);
|
|
246
376
|
const paths: PodAppPaths = { ...DEFAULT_PATHS, ...config.paths };
|
|
247
377
|
const metadata = buildClientMetadata({ ...config, paths });
|
|
378
|
+
const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
|
|
248
379
|
const clientId = metadata.client_id;
|
|
249
380
|
const redirectUri = metadata.redirect_uris[0];
|
|
250
381
|
const permissions = config.requests.map((r) => r.permission);
|
|
@@ -263,6 +394,42 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
263
394
|
return e;
|
|
264
395
|
};
|
|
265
396
|
const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
|
|
397
|
+
const clearCloud = serializeCookie(names.cloud, "", { maxAgeSec: 0, secure });
|
|
398
|
+
const clearMarker = serializeCookie(names.marker, "", { maxAgeSec: 0, secure });
|
|
399
|
+
const marker = (podUrl: string) => markerCookie(podUrl, { secure, name: names.marker });
|
|
400
|
+
const asRequest = (source: Request | string | null) =>
|
|
401
|
+
source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} });
|
|
402
|
+
const lapsedIn = (request: Request) => {
|
|
403
|
+
const podUrl = readMarker(request.headers.get("cookie"), { secure, name: names.marker, allowInsecureLoopback: config.allowInsecureLoopback });
|
|
404
|
+
return podUrl ? { podUrl } : null;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/** Off to the pod's consent: fresh PKCE + state, the transaction cookie set (extra cookies first). */
|
|
408
|
+
async function toPod(opts: { pod: string; next: string; space: string | null; prompt: "consent" | null }, extra: string[] = []) {
|
|
409
|
+
const started = await startConnect({
|
|
410
|
+
...flow,
|
|
411
|
+
pod: opts.pod,
|
|
412
|
+
clientId,
|
|
413
|
+
redirectUri,
|
|
414
|
+
permissions,
|
|
415
|
+
secret: config.secret,
|
|
416
|
+
resource: config.resource,
|
|
417
|
+
spaceHint: opts.space && SPACE_HINT_RE.test(opts.space) ? opts.space : null,
|
|
418
|
+
next: opts.next,
|
|
419
|
+
prompt: opts.prompt,
|
|
420
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
421
|
+
});
|
|
422
|
+
return redirect(started.authorizeUrl, [
|
|
423
|
+
...extra,
|
|
424
|
+
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1000, secure }),
|
|
425
|
+
]);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** A returning person already connected to this pod goes straight on (unless they asked to see the consent again). */
|
|
429
|
+
async function liveConnectionTo(request: Request, pod: string): Promise<boolean> {
|
|
430
|
+
const existing = await store.read(request);
|
|
431
|
+
return Boolean(existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config));
|
|
432
|
+
}
|
|
266
433
|
|
|
267
434
|
async function clientMetadata(): Promise<Response> {
|
|
268
435
|
return new Response(JSON.stringify(metadata), {
|
|
@@ -278,27 +445,10 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
278
445
|
const pod = formField(form, "pod")?.slice(0, 512) ?? "";
|
|
279
446
|
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
280
447
|
const space = formField(form, "space");
|
|
448
|
+
const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
|
|
281
449
|
try {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config)) {
|
|
285
|
-
return redirect(new URL(next, origin).href);
|
|
286
|
-
}
|
|
287
|
-
const started = await startConnect({
|
|
288
|
-
...flow,
|
|
289
|
-
pod,
|
|
290
|
-
clientId,
|
|
291
|
-
redirectUri,
|
|
292
|
-
permissions,
|
|
293
|
-
secret: config.secret,
|
|
294
|
-
resource: config.resource,
|
|
295
|
-
spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
|
|
296
|
-
next,
|
|
297
|
-
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
298
|
-
});
|
|
299
|
-
return redirect(started.authorizeUrl, [
|
|
300
|
-
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1000, secure }),
|
|
301
|
-
]);
|
|
450
|
+
if (!prompt && (await liveConnectionTo(request, pod))) return redirect(new URL(next, origin).href);
|
|
451
|
+
return await toPod({ pod, next, space, prompt });
|
|
302
452
|
} catch (err) {
|
|
303
453
|
const e = fail(err, "connect");
|
|
304
454
|
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
|
|
@@ -318,7 +468,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
318
468
|
secret: config.secret,
|
|
319
469
|
});
|
|
320
470
|
const cookies = await store.write(connection, request);
|
|
321
|
-
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies]);
|
|
471
|
+
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
|
|
322
472
|
} catch (err) {
|
|
323
473
|
const e = fail(err, "callback");
|
|
324
474
|
return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
|
|
@@ -334,10 +484,64 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
334
484
|
const { revoked } = await revokeConnection(connection, flow);
|
|
335
485
|
if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
|
|
336
486
|
}
|
|
337
|
-
|
|
487
|
+
// A sign-out is not a lapse: the marker goes too.
|
|
488
|
+
return redirect(new URL(next, origin).href, [...(await store.clear(request)), clearMarker]);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function cloudConnect(request: Request): Promise<Response> {
|
|
492
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
493
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
494
|
+
const form = await readForm(request);
|
|
495
|
+
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
496
|
+
const space = formField(form, "space");
|
|
497
|
+
try {
|
|
498
|
+
const started = await startCloudConnect({
|
|
499
|
+
cloudUrl,
|
|
500
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
501
|
+
clientId,
|
|
502
|
+
secret: config.secret,
|
|
503
|
+
next,
|
|
504
|
+
spaceHint: space && SPACE_HINT_RE.test(space) ? space : null,
|
|
505
|
+
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
506
|
+
now,
|
|
507
|
+
});
|
|
508
|
+
return redirect(started.url, [
|
|
509
|
+
serializeCookie(names.cloud, started.transaction, { maxAgeSec: CLOUD_TRANSACTION_TTL_MS / 1000, secure }),
|
|
510
|
+
]);
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const e = fail(err, "connect");
|
|
513
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code }));
|
|
514
|
+
}
|
|
338
515
|
}
|
|
339
516
|
|
|
340
|
-
async function
|
|
517
|
+
async function cloudReturn(request: Request): Promise<Response> {
|
|
518
|
+
if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
|
|
519
|
+
let pod: string | null = null;
|
|
520
|
+
try {
|
|
521
|
+
// The state is checked against THIS browser's cookie before the pod is used.
|
|
522
|
+
const back = await acceptCloudReturn({
|
|
523
|
+
url: request.url,
|
|
524
|
+
transaction: parseCookies(request.headers.get("cookie")).get(names.cloud) ?? null,
|
|
525
|
+
clientId,
|
|
526
|
+
secret: config.secret,
|
|
527
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
528
|
+
now,
|
|
529
|
+
});
|
|
530
|
+
pod = back.pod;
|
|
531
|
+
const next = back.next ?? paths.afterConnect;
|
|
532
|
+
if (!back.prompt && (await liveConnectionTo(request, back.pod))) return redirect(new URL(next, origin).href, [clearCloud]);
|
|
533
|
+
return await toPod({ pod: back.pod, next, space: back.spaceHint, prompt: back.prompt }, [clearCloud]);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
const e = fail(err, "connect");
|
|
536
|
+
// The pod is echoed only once the state matched: a forged return pre-fills nothing.
|
|
537
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Refresh tokens whose refresh failed while their access token still worked ("kept"): not asked again for a minute. */
|
|
542
|
+
const keptFailures = new Map<string, { error: ConnectError; at: number }>();
|
|
543
|
+
|
|
544
|
+
async function refreshIfNeeded(request: Request, opts: RefreshOptions = {}): Promise<RefreshOutcome> {
|
|
341
545
|
const connection = await store.read(request);
|
|
342
546
|
const unchanged = (status: RefreshStatus, conn: PodConnection | null, error?: ConnectError): RefreshOutcome => ({
|
|
343
547
|
status,
|
|
@@ -346,15 +550,22 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
346
550
|
cookieHeader: null,
|
|
347
551
|
...(error ? { error } : {}),
|
|
348
552
|
});
|
|
349
|
-
if (!connection)
|
|
553
|
+
if (!connection) {
|
|
554
|
+
const lapsed = lapsedIn(request);
|
|
555
|
+
return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
|
|
556
|
+
}
|
|
350
557
|
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
351
558
|
const t = now();
|
|
352
|
-
|
|
353
|
-
|
|
559
|
+
// Forced: the pod already refused the access token, whatever its expiry says.
|
|
560
|
+
const accessLives = !opts.force && connection.accessExpiresAt > t;
|
|
561
|
+
if (!opts.force && connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
354
562
|
if (!connection.refreshToken) {
|
|
355
|
-
// The pod issued none
|
|
563
|
+
// The pod issued none: the connection ends with its access token.
|
|
356
564
|
return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
|
|
357
565
|
}
|
|
566
|
+
for (const [token, f] of keptFailures) if (t - f.at >= KEPT_RETRY_MS) keptFailures.delete(token);
|
|
567
|
+
const keptFailure = accessLives ? keptFailures.get(connection.refreshToken) : undefined;
|
|
568
|
+
if (keptFailure) return unchanged("kept", connection, keptFailure.error);
|
|
358
569
|
let next: PodConnection;
|
|
359
570
|
try {
|
|
360
571
|
next = await refreshConnection(connection, flow);
|
|
@@ -362,13 +573,24 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
362
573
|
const e = fail(err, "refresh");
|
|
363
574
|
// LOST-RACE SAFE: another instance may have redeemed the token at the
|
|
364
575
|
// same moment; the pod refuses the concurrent loser without a family
|
|
365
|
-
// revoke
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
if (
|
|
576
|
+
// revoke, and the access token we hold is still good. Keep it and set
|
|
577
|
+
// NOTHING, so the winner's cookie stands. (Within one process the race
|
|
578
|
+
// never reaches the pod: `refreshConnection` is single-flight.) Never
|
|
579
|
+
// clear the cookie here either: a clear racing the winner's Set-Cookie
|
|
580
|
+
// would sign the person out. The failure is remembered for a minute, so
|
|
581
|
+
// each page view does not ask the pod again.
|
|
582
|
+
if (accessLives) {
|
|
583
|
+
keptFailures.set(connection.refreshToken, { error: e, at: t });
|
|
584
|
+
return unchanged("kept", connection, e);
|
|
585
|
+
}
|
|
586
|
+
// Spent and refused for good: clear the session cookie, so every later
|
|
587
|
+
// page view does not post the dead token to /token again (that budget is
|
|
588
|
+
// per caller IP: the app server, shared by all its visitors). The marker
|
|
589
|
+
// stays: the person still reads "expired, connect again to <pod>".
|
|
590
|
+
if (e.code === "revoked" || e.code === "expired" || e.code === "pod_moved") {
|
|
591
|
+
const cleared = await store.clear(request).catch(() => [] as string[]);
|
|
592
|
+
return { status: e.code === "revoked" ? "revoked" : "expired", connection: null, setCookies: cleared, cookieHeader: applySetCookies(request.headers.get("cookie"), cleared), error: e };
|
|
593
|
+
}
|
|
372
594
|
return unchanged("unavailable", null, e);
|
|
373
595
|
}
|
|
374
596
|
// PERSIST BEFORE USE: the old refresh token is spent now; only a stored
|
|
@@ -383,6 +605,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
383
605
|
const cleared = await store.clear(request).catch(() => [] as string[]);
|
|
384
606
|
return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
|
|
385
607
|
}
|
|
608
|
+
setCookies = [...setCookies, marker(next.podUrl)];
|
|
386
609
|
return {
|
|
387
610
|
status: "refreshed",
|
|
388
611
|
connection: next,
|
|
@@ -391,15 +614,51 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
391
614
|
};
|
|
392
615
|
}
|
|
393
616
|
|
|
617
|
+
/**
|
|
618
|
+
* The client's renewal after a 401: rotate (single-flight per
|
|
619
|
+
* process), PERSIST through `persist`, then hand the new connection over.
|
|
620
|
+
* A rotation that cannot be persisted is not used (persist before use).
|
|
621
|
+
*/
|
|
622
|
+
function renewer(persist: NonNullable<PodAppClientOptions["persist"]>, source: Request | string | null | undefined) {
|
|
623
|
+
return async (connection: PodConnection): Promise<PodConnection> => {
|
|
624
|
+
let next: PodConnection;
|
|
625
|
+
try {
|
|
626
|
+
next = await refreshConnection(connection, flow);
|
|
627
|
+
} catch (err) {
|
|
628
|
+
throw fail(err, "refresh");
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
const cookies = await store.write(next, asRequest(source ?? null));
|
|
632
|
+
await persist([...cookies, marker(next.podUrl)]);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
fail(err, "refresh");
|
|
635
|
+
throw new ConnectError("expired", "The renewed connection could not be stored; connect again");
|
|
636
|
+
}
|
|
637
|
+
return next;
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
394
641
|
return {
|
|
395
642
|
clientId,
|
|
396
643
|
redirectUri,
|
|
397
644
|
metadata,
|
|
398
645
|
paths,
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
646
|
+
cloudConnectAvailable: cloudUrl !== null,
|
|
647
|
+
handlers: { clientMetadata, connect, callback, disconnect, cloudConnect, cloudReturn },
|
|
648
|
+
readConnection: (source) => store.read(asRequest(source)),
|
|
649
|
+
async readLapsed(source) {
|
|
650
|
+
const request = asRequest(source);
|
|
651
|
+
return (await store.read(request)) ? null : lapsedIn(request);
|
|
652
|
+
},
|
|
402
653
|
refreshIfNeeded,
|
|
403
|
-
client
|
|
654
|
+
client(connection, opts = {}) {
|
|
655
|
+
const { persist, request, ...clientOpts } = opts;
|
|
656
|
+
return createPodClient(connection, {
|
|
657
|
+
fetch: config.fetch,
|
|
658
|
+
now: config.now,
|
|
659
|
+
...(persist ? { renew: renewer(persist, request) } : {}),
|
|
660
|
+
...clientOpts,
|
|
661
|
+
});
|
|
662
|
+
},
|
|
404
663
|
};
|
|
405
664
|
}
|