@synap-core/sdk 1.2.0 → 1.4.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/README.md +154 -0
- package/dist/chunk-J7XHAAFJ.js +79 -0
- package/dist/chunk-J7XHAAFJ.js.map +1 -0
- package/dist/connect.browser.cjs +22 -0
- package/dist/connect.browser.cjs.map +1 -0
- package/dist/connect.browser.d.cts +2 -0
- package/dist/connect.browser.d.ts +2 -0
- package/dist/connect.browser.js +5 -0
- package/dist/connect.browser.js.map +1 -0
- package/dist/connect.cjs +1487 -0
- package/dist/connect.cjs.map +1 -0
- package/dist/connect.d.cts +647 -0
- package/dist/connect.d.ts +647 -0
- package/dist/connect.js +1365 -0
- package/dist/connect.js.map +1 -0
- package/dist/testing.cjs +431 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +160 -1
- package/dist/testing.d.ts +160 -1
- package/dist/testing.js +404 -19
- package/dist/testing.js.map +1 -1
- package/dist/types-C2KGNXEn.d.cts +95 -0
- package/dist/types-C2KGNXEn.d.ts +95 -0
- package/package.json +18 -10
- package/src/connect/auth.ts +535 -0
- package/src/connect/browser.ts +10 -0
- package/src/connect/client.ts +299 -0
- package/src/connect/cloud.ts +206 -0
- package/src/connect/discovery.ts +263 -0
- package/src/connect/encoding.ts +93 -0
- package/src/connect/errors.ts +115 -0
- package/src/connect/flow.ts +489 -0
- package/src/connect/guard.ts +25 -0
- package/src/connect/index.ts +87 -0
- package/src/connect/seal.ts +119 -0
- package/src/connect/store.ts +189 -0
- package/src/connect/types.ts +100 -0
- package/src/testing/auth-server.ts +512 -0
- package/src/testing/cloud.ts +84 -0
- package/src/testing/index.ts +7 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createPodAppAuth(config)`: pod-app sign-in as Web-standard route handlers
|
|
3
|
+
* (`Request → Response`, so Next route handlers, Hono, Remix, workers) plus
|
|
4
|
+
* the `proxy.ts` refresh helper. The route files stay two lines:
|
|
5
|
+
*
|
|
6
|
+
* // app/api/synap/connect/route.ts
|
|
7
|
+
* export const POST = synap.handlers.connect;
|
|
8
|
+
*
|
|
9
|
+
* Handlers:
|
|
10
|
+
* GET <metadata> the app's client metadata document (its client_id URL)
|
|
11
|
+
* POST <connect> form { pod, next?, space?, prompt? } → 303 to the pod's /authorize
|
|
12
|
+
* GET <callback> the pod's answer → sealed session cookie → 303 to `next`
|
|
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.
|
|
20
|
+
*
|
|
21
|
+
* `connect` and `disconnect` change state, so they are POST and same-origin
|
|
22
|
+
* only (Origin, else Sec-Fetch-Site): a third-party page cannot start a
|
|
23
|
+
* sign-in into an attacker's pod for a visitor (login CSRF) or sign them out.
|
|
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".
|
|
29
|
+
*/
|
|
30
|
+
import { acceptCloudReturn, cloudBaseUrl, CLOUD_TRANSACTION_TTL_MS, startCloudConnect } from "./cloud";
|
|
31
|
+
import { safeNextPath, canonicalPodUrl } from "./discovery";
|
|
32
|
+
import { ConnectError, isConnectError } from "./errors";
|
|
33
|
+
import { serializeCookie, parseCookies } from "./encoding";
|
|
34
|
+
import { createPodClient, type PodClient, type PodClientOptions } from "./client";
|
|
35
|
+
import { assertPermissions, handleCallback, refreshConnection, revokeConnection, startConnect, TRANSACTION_TTL_MS } from "./flow";
|
|
36
|
+
import { assertServerRuntime } from "./guard";
|
|
37
|
+
import { assertAppSecret, type AppSecret } from "./seal";
|
|
38
|
+
import { applySetCookies, cookieNames, createSealedCookieStore, markerCookie, readMarker, type ConnectionStore } from "./store";
|
|
39
|
+
import type { AppRequest, ClientMetadataDocument, PodConnection } from "./types";
|
|
40
|
+
|
|
41
|
+
export interface PodAppPaths {
|
|
42
|
+
/** The client metadata document; its absolute URL is the client_id. */
|
|
43
|
+
metadata: string;
|
|
44
|
+
connect: string;
|
|
45
|
+
callback: string;
|
|
46
|
+
disconnect: string;
|
|
47
|
+
/** Synap Cloud: POST starts it, GET is the pod chooser's return (forwarded from the connect page). */
|
|
48
|
+
cloud: string;
|
|
49
|
+
/** The app's connect page (also the CIMD `initiate_login_uri`). */
|
|
50
|
+
connectPage: string;
|
|
51
|
+
/** Where a failed callback lands, with `?error=<code>`. */
|
|
52
|
+
resultPage: string;
|
|
53
|
+
/** Where a sign-in lands when it named no `next`. */
|
|
54
|
+
afterConnect: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_PATHS: PodAppPaths = {
|
|
58
|
+
metadata: "/.well-known/synap-client.json",
|
|
59
|
+
connect: "/api/synap/connect",
|
|
60
|
+
callback: "/api/synap/callback",
|
|
61
|
+
disconnect: "/api/synap/disconnect",
|
|
62
|
+
cloud: "/api/synap/cloud",
|
|
63
|
+
connectPage: "/connect",
|
|
64
|
+
resultPage: "/connect/result",
|
|
65
|
+
afterConnect: "/",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Refresh only inside the last 5 minutes of the access token's life. */
|
|
69
|
+
export const REFRESH_WINDOW_MS = 5 * 60 * 1000;
|
|
70
|
+
|
|
71
|
+
export interface PodAppAuthConfig {
|
|
72
|
+
/** The app's origin, e.g. https://studio.example.com (http only on localhost). */
|
|
73
|
+
appUrl: string;
|
|
74
|
+
/** Shown on the pod's consent screen (at most 100 characters). */
|
|
75
|
+
clientName: string;
|
|
76
|
+
/** What the app asks for, each with the reason the consent screen shows. */
|
|
77
|
+
requests: readonly AppRequest[];
|
|
78
|
+
/** Workspace-template slugs the consent may offer to create (`synap_templates`). */
|
|
79
|
+
templates?: readonly string[];
|
|
80
|
+
/** SERVER-ONLY secret, at least 32 characters (SYNAP_APP_SESSION_SECRET). Several to rotate. */
|
|
81
|
+
secret: AppSecret;
|
|
82
|
+
paths?: Partial<PodAppPaths>;
|
|
83
|
+
/** Defaults to the sealed session cookie. */
|
|
84
|
+
store?: ConnectionStore;
|
|
85
|
+
/** RFC 8707 resource asked for (default the Hub API). */
|
|
86
|
+
resource?: "api/hub" | "mcp" | null;
|
|
87
|
+
/**
|
|
88
|
+
* The Synap Control Plane's URL (e.g. https://api.synap.live): offers
|
|
89
|
+
* "Continue with Synap Cloud" (`cloudConnectAvailable`). Unset = address only.
|
|
90
|
+
*/
|
|
91
|
+
cloudUrl?: string | null;
|
|
92
|
+
/** Accept pods (and a Control Plane) on http://localhost (development against a local pod). */
|
|
93
|
+
allowInsecureLoopback?: boolean;
|
|
94
|
+
/** Developer log of every refused sign-in; messages never carry a token. Default: console.warn. */
|
|
95
|
+
onError?: (error: ConnectError, where: "connect" | "callback" | "disconnect" | "refresh") => void;
|
|
96
|
+
fetch?: typeof fetch;
|
|
97
|
+
now?: () => number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type RefreshStatus =
|
|
101
|
+
/** No connection in this request. */
|
|
102
|
+
| "none"
|
|
103
|
+
/** Prefetch or static asset: nothing refreshed (the connection is read as is). */
|
|
104
|
+
| "skipped"
|
|
105
|
+
/** Valid beyond the refresh window, or no refresh token to use. */
|
|
106
|
+
| "fresh"
|
|
107
|
+
/** Rotated: send `setCookies`, forward `cookieHeader`. */
|
|
108
|
+
| "refreshed"
|
|
109
|
+
/** The refresh failed (a lost rotation race, the pod busy) but the access token still works: keep it, set nothing. */
|
|
110
|
+
| "kept"
|
|
111
|
+
/** The access token is spent and cannot be renewed: connect again. */
|
|
112
|
+
| "expired"
|
|
113
|
+
/** The pod refused the refresh token (access removed): connect again. */
|
|
114
|
+
| "revoked"
|
|
115
|
+
/** The pod could not be reached and the access token is spent: try later. */
|
|
116
|
+
| "unavailable";
|
|
117
|
+
|
|
118
|
+
export interface RefreshOutcome {
|
|
119
|
+
status: RefreshStatus;
|
|
120
|
+
/** A usable connection, or null. */
|
|
121
|
+
connection: PodConnection | null;
|
|
122
|
+
/** Set-Cookie values to append to the response. */
|
|
123
|
+
setCookies: string[];
|
|
124
|
+
/** The request's Cookie header with the refreshed value, to forward to the render (null = unchanged). */
|
|
125
|
+
cookieHeader: string | null;
|
|
126
|
+
error?: ConnectError;
|
|
127
|
+
/**
|
|
128
|
+
* With `none`: this browser WAS connected to that pod and the connection
|
|
129
|
+
* lapsed (its sealed cookie is gone, idle past its life). Say "expired,
|
|
130
|
+
* connect again" with the pod pre-filled, not "connect your pod".
|
|
131
|
+
*/
|
|
132
|
+
lapsed?: { podUrl: string };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface PodAppAuth {
|
|
136
|
+
clientId: string;
|
|
137
|
+
redirectUri: string;
|
|
138
|
+
metadata: ClientMetadataDocument;
|
|
139
|
+
paths: PodAppPaths;
|
|
140
|
+
/** Whether "Continue with Synap Cloud" can be offered (`cloudUrl` is configured). */
|
|
141
|
+
cloudConnectAvailable: boolean;
|
|
142
|
+
handlers: {
|
|
143
|
+
clientMetadata(request: Request): Promise<Response>;
|
|
144
|
+
connect(request: Request): Promise<Response>;
|
|
145
|
+
callback(request: Request): Promise<Response>;
|
|
146
|
+
disconnect(request: Request): Promise<Response>;
|
|
147
|
+
/** POST: start a Synap Cloud sign-in (same-origin, form { next?, space?, prompt? }). */
|
|
148
|
+
cloudConnect(request: Request): Promise<Response>;
|
|
149
|
+
/** GET: the Synap Cloud pod chooser's return (`?iss&state`), checked against this browser's cookie. */
|
|
150
|
+
cloudReturn(request: Request): Promise<Response>;
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* The stored connection (no refresh, no expiry check), or null. Takes the
|
|
154
|
+
* request, or just its Cookie header (a server component has no Request:
|
|
155
|
+
* pass `(await headers()).get("cookie")`).
|
|
156
|
+
*/
|
|
157
|
+
readConnection(source: Request | string | null): Promise<PodConnection | null>;
|
|
158
|
+
/**
|
|
159
|
+
* The pod this browser WAS connected to, when no connection is stored any
|
|
160
|
+
* more (it lapsed); null when one is stored or this browser never connected
|
|
161
|
+
* (or disconnected). Same sources as `readConnection`.
|
|
162
|
+
*/
|
|
163
|
+
readLapsed(source: Request | string | null): Promise<{ podUrl: string } | null>;
|
|
164
|
+
/** For `proxy.ts` / a route handler: rotate near expiry, lost-race safe. */
|
|
165
|
+
refreshIfNeeded(request: Request): Promise<RefreshOutcome>;
|
|
166
|
+
/** A Hub REST client for the connection. */
|
|
167
|
+
client(connection: PodConnection, opts?: PodClientOptions): PodClient;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
171
|
+
const TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
|
|
172
|
+
const MAX_METADATA_BYTES = 5 * 1024;
|
|
173
|
+
|
|
174
|
+
function appOriginOf(appUrl: string): { origin: string; secure: boolean } {
|
|
175
|
+
let url: URL;
|
|
176
|
+
try {
|
|
177
|
+
url = new URL(appUrl);
|
|
178
|
+
} catch {
|
|
179
|
+
throw new ConnectError("misconfigured", "appUrl is not a URL");
|
|
180
|
+
}
|
|
181
|
+
const secure = url.protocol === "https:";
|
|
182
|
+
if (!secure && !(url.protocol === "http:" && LOOPBACK.has(url.hostname))) {
|
|
183
|
+
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
|
|
184
|
+
}
|
|
185
|
+
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
186
|
+
throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
|
|
187
|
+
}
|
|
188
|
+
return { origin: url.origin, secure };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function assertPath(name: string, path: string): void {
|
|
192
|
+
if (!path.startsWith("/") || path.startsWith("//") || /[?#\\]/.test(path)) {
|
|
193
|
+
throw new ConnectError("misconfigured", `paths.${name} must be an absolute path on the app`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The CIMD document (spec 17 §6.1), validated the way the pod will validate it. */
|
|
198
|
+
export function buildClientMetadata(opts: {
|
|
199
|
+
appUrl: string;
|
|
200
|
+
clientName: string;
|
|
201
|
+
requests: readonly AppRequest[];
|
|
202
|
+
templates?: readonly string[];
|
|
203
|
+
paths?: Partial<PodAppPaths>;
|
|
204
|
+
}): ClientMetadataDocument {
|
|
205
|
+
const { origin } = appOriginOf(opts.appUrl);
|
|
206
|
+
const paths = { ...DEFAULT_PATHS, ...opts.paths };
|
|
207
|
+
for (const [name, path] of Object.entries(paths)) assertPath(name, path);
|
|
208
|
+
const clientName = opts.clientName.trim();
|
|
209
|
+
if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
|
|
210
|
+
assertPermissions(opts.requests.map((r) => r.permission));
|
|
211
|
+
for (const r of opts.requests) {
|
|
212
|
+
if (r.permission.includes("*")) throw new ConnectError("misconfigured", "a permission cannot contain *");
|
|
213
|
+
if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
|
|
214
|
+
throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const templates = [...new Set(opts.templates ?? [])];
|
|
218
|
+
if (templates.length > 8 || templates.some((t) => !TEMPLATE_SLUG_RE.test(t))) {
|
|
219
|
+
throw new ConnectError("misconfigured", "at most 8 template slugs (lowercase letters, digits, . _ / -)");
|
|
220
|
+
}
|
|
221
|
+
const doc: ClientMetadataDocument = {
|
|
222
|
+
client_id: `${origin}${paths.metadata}`,
|
|
223
|
+
client_name: clientName,
|
|
224
|
+
client_uri: origin,
|
|
225
|
+
redirect_uris: [`${origin}${paths.callback}`],
|
|
226
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
227
|
+
response_types: ["code"],
|
|
228
|
+
token_endpoint_auth_method: "none",
|
|
229
|
+
initiate_login_uri: `${origin}${paths.connectPage}`,
|
|
230
|
+
synap_requests: opts.requests.map((r) => ({ permission: r.permission, reason: r.reason.trim() })),
|
|
231
|
+
...(templates.length > 0 ? { synap_templates: templates } : {}),
|
|
232
|
+
};
|
|
233
|
+
if (new TextEncoder().encode(JSON.stringify(doc)).length > MAX_METADATA_BYTES) {
|
|
234
|
+
throw new ConnectError("misconfigured", "the client metadata document exceeds the pod's 5 KB limit");
|
|
235
|
+
}
|
|
236
|
+
return doc;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function redirect(location: string, setCookies: readonly string[] = []): Response {
|
|
240
|
+
const headers = new Headers({ Location: location, "Cache-Control": "no-store" });
|
|
241
|
+
for (const c of setCookies) headers.append("Set-Cookie", c);
|
|
242
|
+
return new Response(null, { status: 303, headers });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function withQuery(origin: string, path: string, params: Record<string, string | null | undefined>): string {
|
|
246
|
+
const url = new URL(path, origin);
|
|
247
|
+
for (const [k, v] of Object.entries(params)) if (v) url.searchParams.set(k, v);
|
|
248
|
+
return url.href;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A state-changing request must come from the app's own pages. */
|
|
252
|
+
function sameOrigin(request: Request, origin: string): boolean {
|
|
253
|
+
const from = request.headers.get("origin");
|
|
254
|
+
if (from !== null) return from === origin;
|
|
255
|
+
return request.headers.get("sec-fetch-site") === "same-origin";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function formField(form: FormData | null, name: string): string | null {
|
|
259
|
+
const v = form?.get(name);
|
|
260
|
+
return typeof v === "string" ? v : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function readForm(request: Request): Promise<FormData | null> {
|
|
264
|
+
try {
|
|
265
|
+
return await request.formData();
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A prefetch or a static asset: `refreshIfNeeded` answers `skipped` for these.
|
|
273
|
+
*
|
|
274
|
+
* LIMIT (Next.js 16 `proxy.ts` on the Node runtime): Next strips its router
|
|
275
|
+
* headers (`next-router-prefetch`, `rsc`, …, `FLIGHT_HEADERS` in
|
|
276
|
+
* next/dist/server/web/adapter.js) and the `_rsc` query BEFORE the proxy runs,
|
|
277
|
+
* so a router prefetch is invisible here. Skip prefetches in the proxy
|
|
278
|
+
* MATCHER, which still sees the raw headers:
|
|
279
|
+
*
|
|
280
|
+
* export const config = { matcher: [{ source: "/((?!_next/static|_next/image|.*\\.\\w+$).*)",
|
|
281
|
+
* missing: [{ type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }] }] };
|
|
282
|
+
*
|
|
283
|
+
* Route handlers and other frameworks see the headers, and this still checks them.
|
|
284
|
+
*/
|
|
285
|
+
export function isPrefetchOrAsset(request: Request): boolean {
|
|
286
|
+
const h = request.headers;
|
|
287
|
+
if (h.get("next-router-prefetch") || h.get("next-router-segment-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
288
|
+
if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
|
|
289
|
+
const path = new URL(request.url).pathname;
|
|
290
|
+
return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
294
|
+
assertServerRuntime();
|
|
295
|
+
assertAppSecret(config.secret);
|
|
296
|
+
const { origin, secure } = appOriginOf(config.appUrl);
|
|
297
|
+
const paths: PodAppPaths = { ...DEFAULT_PATHS, ...config.paths };
|
|
298
|
+
const metadata = buildClientMetadata({ ...config, paths });
|
|
299
|
+
const cloudUrl = config.cloudUrl?.trim() ? cloudBaseUrl(config.cloudUrl, config) : null;
|
|
300
|
+
const clientId = metadata.client_id;
|
|
301
|
+
const redirectUri = metadata.redirect_uris[0];
|
|
302
|
+
const permissions = config.requests.map((r) => r.permission);
|
|
303
|
+
const names = cookieNames(secure);
|
|
304
|
+
const now = config.now ?? Date.now;
|
|
305
|
+
const store = config.store ?? createSealedCookieStore({ secret: config.secret, secure, now });
|
|
306
|
+
const flow = { fetch: config.fetch, now: config.now };
|
|
307
|
+
const report =
|
|
308
|
+
config.onError ??
|
|
309
|
+
((err: ConnectError, where: string) => {
|
|
310
|
+
console.warn(`[synap/connect] ${where}: ${err.code}: ${err.message}`);
|
|
311
|
+
});
|
|
312
|
+
const fail = (err: unknown, where: "connect" | "callback" | "disconnect" | "refresh"): ConnectError => {
|
|
313
|
+
const e = isConnectError(err) ? err : new ConnectError("server_error", err instanceof Error ? err.message : "Unexpected failure");
|
|
314
|
+
report(e, where);
|
|
315
|
+
return e;
|
|
316
|
+
};
|
|
317
|
+
const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
|
|
318
|
+
const clearCloud = serializeCookie(names.cloud, "", { maxAgeSec: 0, secure });
|
|
319
|
+
const clearMarker = serializeCookie(names.marker, "", { maxAgeSec: 0, secure });
|
|
320
|
+
const marker = (podUrl: string) => markerCookie(podUrl, { secure, name: names.marker });
|
|
321
|
+
const asRequest = (source: Request | string | null) =>
|
|
322
|
+
source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} });
|
|
323
|
+
const lapsedIn = (request: Request) => {
|
|
324
|
+
const podUrl = readMarker(request.headers.get("cookie"), { secure, name: names.marker, allowInsecureLoopback: config.allowInsecureLoopback });
|
|
325
|
+
return podUrl ? { podUrl } : null;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** Off to the pod's consent: fresh PKCE + state, the transaction cookie set (extra cookies first). */
|
|
329
|
+
async function toPod(opts: { pod: string; next: string; space: string | null; prompt: "consent" | null }, extra: string[] = []) {
|
|
330
|
+
const started = await startConnect({
|
|
331
|
+
...flow,
|
|
332
|
+
pod: opts.pod,
|
|
333
|
+
clientId,
|
|
334
|
+
redirectUri,
|
|
335
|
+
permissions,
|
|
336
|
+
secret: config.secret,
|
|
337
|
+
resource: config.resource,
|
|
338
|
+
spaceHint: opts.space && /^[A-Za-z0-9_-]{1,64}$/.test(opts.space) ? opts.space : null,
|
|
339
|
+
next: opts.next,
|
|
340
|
+
prompt: opts.prompt,
|
|
341
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
342
|
+
});
|
|
343
|
+
return redirect(started.authorizeUrl, [
|
|
344
|
+
...extra,
|
|
345
|
+
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1000, secure }),
|
|
346
|
+
]);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A returning person already connected to this pod goes straight on (unless they asked to see the consent again). */
|
|
350
|
+
async function liveConnectionTo(request: Request, pod: string): Promise<boolean> {
|
|
351
|
+
const existing = await store.read(request);
|
|
352
|
+
return Boolean(existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function clientMetadata(): Promise<Response> {
|
|
356
|
+
return new Response(JSON.stringify(metadata), {
|
|
357
|
+
status: 200,
|
|
358
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=300" },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function connect(request: Request): Promise<Response> {
|
|
363
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
364
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
365
|
+
const form = await readForm(request);
|
|
366
|
+
const pod = formField(form, "pod")?.slice(0, 512) ?? "";
|
|
367
|
+
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
368
|
+
const space = formField(form, "space");
|
|
369
|
+
const prompt = formField(form, "prompt") === "consent" ? "consent" : null;
|
|
370
|
+
try {
|
|
371
|
+
if (!prompt && (await liveConnectionTo(request, pod))) return redirect(new URL(next, origin).href);
|
|
372
|
+
return await toPod({ pod, next, space, prompt });
|
|
373
|
+
} catch (err) {
|
|
374
|
+
const e = fail(err, "connect");
|
|
375
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function callback(request: Request): Promise<Response> {
|
|
380
|
+
if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
|
|
381
|
+
const transaction = parseCookies(request.headers.get("cookie")).get(names.transaction) ?? null;
|
|
382
|
+
try {
|
|
383
|
+
const { connection, next } = await handleCallback({
|
|
384
|
+
...flow,
|
|
385
|
+
callbackUrl: request.url,
|
|
386
|
+
transaction,
|
|
387
|
+
clientId,
|
|
388
|
+
redirectUri,
|
|
389
|
+
secret: config.secret,
|
|
390
|
+
});
|
|
391
|
+
const cookies = await store.write(connection, request);
|
|
392
|
+
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies, marker(connection.podUrl)]);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
const e = fail(err, "callback");
|
|
395
|
+
return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function disconnect(request: Request): Promise<Response> {
|
|
400
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
401
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
402
|
+
const next = safeNextPath(formField(await readForm(request), "next"), origin) ?? paths.connectPage;
|
|
403
|
+
const connection = await store.read(request);
|
|
404
|
+
if (connection) {
|
|
405
|
+
const { revoked } = await revokeConnection(connection, flow);
|
|
406
|
+
if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
|
|
407
|
+
}
|
|
408
|
+
// A sign-out is not a lapse: the marker goes too.
|
|
409
|
+
return redirect(new URL(next, origin).href, [...(await store.clear(request)), clearMarker]);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function cloudConnect(request: Request): Promise<Response> {
|
|
413
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
414
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
415
|
+
const form = await readForm(request);
|
|
416
|
+
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
417
|
+
const space = formField(form, "space");
|
|
418
|
+
try {
|
|
419
|
+
const started = await startCloudConnect({
|
|
420
|
+
cloudUrl,
|
|
421
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
422
|
+
clientId,
|
|
423
|
+
secret: config.secret,
|
|
424
|
+
next,
|
|
425
|
+
spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
|
|
426
|
+
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
427
|
+
now,
|
|
428
|
+
});
|
|
429
|
+
return redirect(started.url, [
|
|
430
|
+
serializeCookie(names.cloud, started.transaction, { maxAgeSec: CLOUD_TRANSACTION_TTL_MS / 1000, secure }),
|
|
431
|
+
]);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
const e = fail(err, "connect");
|
|
434
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code }));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function cloudReturn(request: Request): Promise<Response> {
|
|
439
|
+
if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
|
|
440
|
+
let pod: string | null = null;
|
|
441
|
+
try {
|
|
442
|
+
// The state is checked against THIS browser's cookie before the pod is used.
|
|
443
|
+
const back = await acceptCloudReturn({
|
|
444
|
+
url: request.url,
|
|
445
|
+
transaction: parseCookies(request.headers.get("cookie")).get(names.cloud) ?? null,
|
|
446
|
+
clientId,
|
|
447
|
+
secret: config.secret,
|
|
448
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
449
|
+
now,
|
|
450
|
+
});
|
|
451
|
+
pod = back.pod;
|
|
452
|
+
const next = back.next ?? paths.afterConnect;
|
|
453
|
+
if (!back.prompt && (await liveConnectionTo(request, back.pod))) return redirect(new URL(next, origin).href, [clearCloud]);
|
|
454
|
+
return await toPod({ pod: back.pod, next, space: back.spaceHint, prompt: back.prompt }, [clearCloud]);
|
|
455
|
+
} catch (err) {
|
|
456
|
+
const e = fail(err, "connect");
|
|
457
|
+
// The pod is echoed only once the state matched: a forged return pre-fills nothing.
|
|
458
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod: pod ? new URL(pod).host : null }), [clearCloud]);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function refreshIfNeeded(request: Request): Promise<RefreshOutcome> {
|
|
463
|
+
const connection = await store.read(request);
|
|
464
|
+
const unchanged = (status: RefreshStatus, conn: PodConnection | null, error?: ConnectError): RefreshOutcome => ({
|
|
465
|
+
status,
|
|
466
|
+
connection: conn,
|
|
467
|
+
setCookies: [],
|
|
468
|
+
cookieHeader: null,
|
|
469
|
+
...(error ? { error } : {}),
|
|
470
|
+
});
|
|
471
|
+
if (!connection) {
|
|
472
|
+
const lapsed = lapsedIn(request);
|
|
473
|
+
return lapsed ? { ...unchanged("none", null), lapsed } : unchanged("none", null);
|
|
474
|
+
}
|
|
475
|
+
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
476
|
+
const t = now();
|
|
477
|
+
const accessLives = connection.accessExpiresAt > t;
|
|
478
|
+
if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
479
|
+
if (!connection.refreshToken) {
|
|
480
|
+
// The pod issued none (before PB3): the connection ends with its access token.
|
|
481
|
+
return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
|
|
482
|
+
}
|
|
483
|
+
let next: PodConnection;
|
|
484
|
+
try {
|
|
485
|
+
next = await refreshConnection(connection, flow);
|
|
486
|
+
} catch (err) {
|
|
487
|
+
const e = fail(err, "refresh");
|
|
488
|
+
// LOST-RACE SAFE: another instance may have redeemed the token at the
|
|
489
|
+
// same moment; the pod refuses the concurrent loser without a family
|
|
490
|
+
// revoke (PB3), and the access token we hold is still good. Keep it and
|
|
491
|
+
// set NOTHING, so the winner's cookie stands. (Within one process the
|
|
492
|
+
// race never reaches the pod: `refreshConnection` is single-flight.)
|
|
493
|
+
// Never clear the cookie here either: a clear racing the winner's
|
|
494
|
+
// Set-Cookie would sign the person out.
|
|
495
|
+
if (accessLives) return unchanged("kept", connection, e);
|
|
496
|
+
if (e.code === "revoked" || e.code === "expired") return unchanged(e.code, null, e);
|
|
497
|
+
return unchanged("unavailable", null, e);
|
|
498
|
+
}
|
|
499
|
+
// PERSIST BEFORE USE: the old refresh token is spent now; only a stored
|
|
500
|
+
// successor keeps the connection. If it cannot be stored, the new tokens
|
|
501
|
+
// are not handed out, and the stored (spent) one is cleared so no later
|
|
502
|
+
// request replays it (a replay revokes the family and the live key).
|
|
503
|
+
let setCookies: string[];
|
|
504
|
+
try {
|
|
505
|
+
setCookies = await store.write(next, request);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
const e = fail(err, "refresh");
|
|
508
|
+
const cleared = await store.clear(request).catch(() => [] as string[]);
|
|
509
|
+
return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
|
|
510
|
+
}
|
|
511
|
+
setCookies = [...setCookies, marker(next.podUrl)];
|
|
512
|
+
return {
|
|
513
|
+
status: "refreshed",
|
|
514
|
+
connection: next,
|
|
515
|
+
setCookies,
|
|
516
|
+
cookieHeader: applySetCookies(request.headers.get("cookie"), setCookies),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
clientId,
|
|
522
|
+
redirectUri,
|
|
523
|
+
metadata,
|
|
524
|
+
paths,
|
|
525
|
+
cloudConnectAvailable: cloudUrl !== null,
|
|
526
|
+
handlers: { clientMetadata, connect, callback, disconnect, cloudConnect, cloudReturn },
|
|
527
|
+
readConnection: (source) => store.read(asRequest(source)),
|
|
528
|
+
async readLapsed(source) {
|
|
529
|
+
const request = asRequest(source);
|
|
530
|
+
return (await store.read(request)) ? null : lapsedIn(request);
|
|
531
|
+
},
|
|
532
|
+
refreshIfNeeded,
|
|
533
|
+
client: (connection, opts) => createPodClient(connection, { fetch: config.fetch, now: config.now, ...opts }),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a BROWSER bundle gets for `@synap-core/sdk/connect` (the `browser`
|
|
3
|
+
* export condition): no exports, and a throw on load. See ./guard.ts.
|
|
4
|
+
*/
|
|
5
|
+
throw new Error(
|
|
6
|
+
"@synap-core/sdk/connect is server-only: it holds the pod's tokens and the app secret. " +
|
|
7
|
+
"Import it from route handlers, proxy.ts or server code, never from a client component.",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export {};
|