@synap-core/sdk 1.3.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 +81 -7
- package/dist/{client-CVUT1Mcf.d.cts → client-C5sF20f1.d.cts} +1 -1
- package/dist/{client-CVUT1Mcf.d.ts → client-C5sF20f1.d.ts} +1 -1
- package/dist/connect.cjs +397 -116
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +220 -15
- package/dist/connect.d.ts +220 -15
- package/dist/connect.js +389 -116
- package/dist/connect.js.map +1 -1
- package/dist/public.d.cts +2 -2
- package/dist/public.d.ts +2 -2
- package/dist/testing.cjs +138 -11
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +92 -4
- package/dist/testing.d.ts +92 -4
- package/dist/testing.js +134 -11
- package/dist/testing.js.map +1 -1
- package/dist/{types-DpqPNaaa.d.cts → types-C2KGNXEn.d.cts} +20 -1
- package/dist/{types-DpqPNaaa.d.ts → types-C2KGNXEn.d.ts} +20 -1
- package/package.json +8 -8
- package/src/connect/auth.ts +161 -31
- package/src/connect/client.ts +107 -1
- package/src/connect/cloud.ts +206 -0
- package/src/connect/discovery.ts +15 -14
- package/src/connect/errors.ts +1 -1
- package/src/connect/flow.ts +41 -1
- package/src/connect/index.ts +39 -11
- package/src/connect/seal.ts +1 -1
- package/src/connect/store.ts +41 -3
- package/src/connect/types.ts +20 -0
- package/src/testing/auth-server.ts +144 -12
- package/src/testing/cloud.ts +84 -0
- package/src/testing/index.ts +3 -1
package/src/connect/discovery.ts
CHANGED
|
@@ -15,9 +15,7 @@
|
|
|
15
15
|
* resolves to a private address cannot be refused without a resolver; a
|
|
16
16
|
* deployment that cares adds egress rules.
|
|
17
17
|
*
|
|
18
|
-
* Connecting through Synap Cloud (the CP pod chooser, wave C1) is
|
|
19
|
-
* available yet: `cloudConnectAvailable()` is false and `startCloudConnect`
|
|
20
|
-
* throws `cp_unavailable`.
|
|
18
|
+
* Connecting through Synap Cloud (the CP pod chooser, wave C1) is ./cloud.ts.
|
|
21
19
|
*/
|
|
22
20
|
import { ConnectError, retryAfterSeconds } from "./errors";
|
|
23
21
|
import type { PodIssuer } from "./types";
|
|
@@ -195,16 +193,6 @@ export async function discoverPod(input: string, opts: DiscoverOptions = {}): Pr
|
|
|
195
193
|
}
|
|
196
194
|
}
|
|
197
195
|
|
|
198
|
-
/** Whether "Continue with Synap Cloud" can be offered. False until the CP pod chooser (C1) ships. */
|
|
199
|
-
export function cloudConnectAvailable(): boolean {
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** The Synap Cloud pod chooser (C1). Not available yet: always throws `cp_unavailable`. */
|
|
204
|
-
export function startCloudConnect(_opts: { clientId: string; state?: string }): never {
|
|
205
|
-
throw new ConnectError("cp_unavailable", "cp: not available — connecting through Synap Cloud is not built yet; use a pod address");
|
|
206
|
-
}
|
|
207
|
-
|
|
208
196
|
/**
|
|
209
197
|
* A same-origin path to return to after connecting, or null. Accepts a
|
|
210
198
|
* relative path (never `//host` or `/\host`) or an absolute URL on the app's
|
|
@@ -235,12 +223,21 @@ export interface LaunchHint {
|
|
|
235
223
|
next: string | null;
|
|
236
224
|
/** A space to preselect on consent (`synap_space_hint`). */
|
|
237
225
|
spaceHint: string | null;
|
|
226
|
+
/**
|
|
227
|
+
* The `state` of a Synap Cloud return (the CP pod chooser answers
|
|
228
|
+
* `initiate_login_uri?iss=…&state=…`, or `?state=…&error=access_denied` when
|
|
229
|
+
* the person cancelled), shape-checked only. NOT verified here:
|
|
230
|
+
* send the request's query to the cloud handler (GET `paths.cloud`), which
|
|
231
|
+
* checks it against this browser's sealed cookie. Never act on `pod` because
|
|
232
|
+
* a state is present.
|
|
233
|
+
*/
|
|
234
|
+
cloudState: string | null;
|
|
238
235
|
}
|
|
239
236
|
|
|
240
237
|
/**
|
|
241
238
|
* Read a third-party-initiated login (OIDC Core §4, spec 17 §7.2): the browser
|
|
242
239
|
* opens the app's `initiate_login_uri?iss=…&target_link_uri=…`. One parser for
|
|
243
|
-
* the browser launch and
|
|
240
|
+
* the browser launch and the Synap Cloud chooser's return (`cloudState`).
|
|
244
241
|
*/
|
|
245
242
|
export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allowInsecureLoopback?: boolean } = {}): LaunchHint {
|
|
246
243
|
const params = new URL(url).searchParams;
|
|
@@ -254,9 +251,13 @@ export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allow
|
|
|
254
251
|
}
|
|
255
252
|
}
|
|
256
253
|
const hint = params.get("synap_space_hint");
|
|
254
|
+
const state = params.get("state");
|
|
257
255
|
return {
|
|
258
256
|
pod,
|
|
259
257
|
next: safeNextPath(params.get("target_link_uri"), appOrigin),
|
|
260
258
|
spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null,
|
|
259
|
+
// A browser launch never carries a state: any well-formed one is a Synap Cloud return
|
|
260
|
+
// (a pick: iss + state; a cancel: state + error=access_denied, no iss).
|
|
261
|
+
cloudState: state && /^[A-Za-z0-9._~-]{16,256}$/.test(state) ? state : null,
|
|
261
262
|
};
|
|
262
263
|
}
|
package/src/connect/errors.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type ConnectErrorCode =
|
|
|
13
13
|
| "pod_unreachable"
|
|
14
14
|
/** A Synap pod, but too old for app sign-in (no CIMD, no `iss`, no S256). */
|
|
15
15
|
| "pod_unsupported"
|
|
16
|
-
/** Connecting through Synap Cloud is not
|
|
16
|
+
/** Connecting through Synap Cloud is not configured for this app (no Control Plane URL). */
|
|
17
17
|
| "cp_unavailable"
|
|
18
18
|
/** The person said no on the consent screen. */
|
|
19
19
|
| "denied"
|
package/src/connect/flow.ts
CHANGED
|
@@ -66,6 +66,12 @@ export interface StartConnectOptions extends FlowOptions {
|
|
|
66
66
|
spaceHint?: string | null;
|
|
67
67
|
/** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
|
|
68
68
|
next?: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* `"consent"`: the person asked to see the consent screen again (to widen or
|
|
71
|
+
* narrow what the app may touch). Sent as OIDC `prompt=consent`; the sign-in
|
|
72
|
+
* is otherwise the same (fresh PKCE verifier and state).
|
|
73
|
+
*/
|
|
74
|
+
prompt?: "consent" | null;
|
|
69
75
|
allowInsecureLoopback?: boolean;
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -114,6 +120,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
|
|
|
114
120
|
url.searchParams.set("code_challenge_method", "S256");
|
|
115
121
|
if (resource) url.searchParams.set("resource", resource);
|
|
116
122
|
if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
|
|
123
|
+
if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
|
|
117
124
|
|
|
118
125
|
const tx: ConnectTransaction = {
|
|
119
126
|
v: 1,
|
|
@@ -270,6 +277,32 @@ function parseGrant(value: unknown): PodGrant[] | null {
|
|
|
270
277
|
return out;
|
|
271
278
|
}
|
|
272
279
|
|
|
280
|
+
const APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
281
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `synap_app` (Synap extension): the pod's id for this app connection and its
|
|
285
|
+
* page for it. The page is on the pod's console, which may be another host
|
|
286
|
+
* than the issuer, so it is kept when it is an absolute https URL (http only on
|
|
287
|
+
* loopback) without credentials; anything else is dropped.
|
|
288
|
+
*/
|
|
289
|
+
function parseApp(value: unknown): { appId?: string; manageUrl?: string } {
|
|
290
|
+
if (!value || typeof value !== "object") return {};
|
|
291
|
+
const { id, manageUrl } = value as Record<string, unknown>;
|
|
292
|
+
const out: { appId?: string; manageUrl?: string } = {};
|
|
293
|
+
if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
|
|
294
|
+
if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
|
|
295
|
+
try {
|
|
296
|
+
const url = new URL(manageUrl);
|
|
297
|
+
const secure = url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
|
|
298
|
+
if (secure && !url.username && !url.password) out.manageUrl = url.href;
|
|
299
|
+
} catch {
|
|
300
|
+
// not a URL: no link
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
273
306
|
function parseUser(value: unknown): PodUser | null {
|
|
274
307
|
if (!value || typeof value !== "object") return null;
|
|
275
308
|
const { id, name } = value as Record<string, unknown>;
|
|
@@ -278,7 +311,9 @@ function parseUser(value: unknown): PodUser | null {
|
|
|
278
311
|
|
|
279
312
|
/**
|
|
280
313
|
* A token response → a connection. On a refresh, `previous` supplies what the
|
|
281
|
-
* response may omit (grant, user).
|
|
314
|
+
* response may omit (grant, user, app). `expires_in` is read as sent: a refresh
|
|
315
|
+
* answered within the pod's reuse grace (PB3b) hands back the live key the
|
|
316
|
+
* first redeem got, with less than an hour left. A refresh response WITHOUT a new
|
|
282
317
|
* refresh_token drops the old one: with single-use rotation, resending a
|
|
283
318
|
* consumed token reads as theft and revokes the whole family.
|
|
284
319
|
*/
|
|
@@ -322,6 +357,11 @@ export function parseTokenResponse(
|
|
|
322
357
|
user,
|
|
323
358
|
connectedAt: ctx.previous?.connectedAt ?? ctx.now,
|
|
324
359
|
};
|
|
360
|
+
const app = parseApp(body.synap_app);
|
|
361
|
+
const appId = app.appId ?? ctx.previous?.appId;
|
|
362
|
+
const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
|
|
363
|
+
if (appId !== undefined) connection.appId = appId;
|
|
364
|
+
if (manageUrl !== undefined) connection.manageUrl = manageUrl;
|
|
325
365
|
if (typeof refresh_token === "string" && refresh_token !== "") {
|
|
326
366
|
connection.refreshToken = refresh_token;
|
|
327
367
|
const idle =
|
package/src/connect/index.ts
CHANGED
|
@@ -15,17 +15,26 @@ import { assertServerRuntime } from "./guard";
|
|
|
15
15
|
|
|
16
16
|
assertServerRuntime();
|
|
17
17
|
|
|
18
|
-
export { createPodAppAuth, buildClientMetadata, DEFAULT_PATHS, REFRESH_WINDOW_MS } from "./auth";
|
|
18
|
+
export { createPodAppAuth, buildClientMetadata, isPrefetchOrAsset, DEFAULT_PATHS, REFRESH_WINDOW_MS } from "./auth";
|
|
19
19
|
export type { PodAppAuth, PodAppAuthConfig, PodAppPaths, RefreshOutcome, RefreshStatus } from "./auth";
|
|
20
|
+
export { discoverPod, canonicalPodUrl, acceptLaunch, safeNextPath } from "./discovery";
|
|
21
|
+
export type { DiscoverOptions, LaunchHint } from "./discovery";
|
|
20
22
|
export {
|
|
21
|
-
discoverPod,
|
|
22
|
-
canonicalPodUrl,
|
|
23
|
-
acceptLaunch,
|
|
24
|
-
safeNextPath,
|
|
25
23
|
cloudConnectAvailable,
|
|
26
24
|
startCloudConnect,
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
acceptCloudReturn,
|
|
26
|
+
cloudBaseUrl,
|
|
27
|
+
CLOUD_TRANSACTION_TTL_MS,
|
|
28
|
+
} from "./cloud";
|
|
29
|
+
export type {
|
|
30
|
+
AcceptCloudReturnOptions,
|
|
31
|
+
CloudConnectConfig,
|
|
32
|
+
CloudReturn,
|
|
33
|
+
CloudTransaction,
|
|
34
|
+
SealedCloudTransaction,
|
|
35
|
+
StartCloudConnectOptions,
|
|
36
|
+
StartedCloudConnect,
|
|
37
|
+
} from "./cloud";
|
|
29
38
|
export {
|
|
30
39
|
startConnect,
|
|
31
40
|
openTransaction,
|
|
@@ -46,9 +55,28 @@ export type {
|
|
|
46
55
|
StartConnectOptions,
|
|
47
56
|
StartedConnect,
|
|
48
57
|
} from "./flow";
|
|
49
|
-
export { createPodClient, can, reviewUrlFor } from "./client";
|
|
50
|
-
export type {
|
|
51
|
-
|
|
58
|
+
export { createPodClient, can, spaces, reviewUrlFor } from "./client";
|
|
59
|
+
export type {
|
|
60
|
+
ListPendingProposalsOptions,
|
|
61
|
+
PendingProposal,
|
|
62
|
+
PendingProposals,
|
|
63
|
+
PodClient,
|
|
64
|
+
PodClientOptions,
|
|
65
|
+
PodError,
|
|
66
|
+
PodErrorCode,
|
|
67
|
+
PodResult,
|
|
68
|
+
PodWriteResult,
|
|
69
|
+
QueryValue,
|
|
70
|
+
} from "./client";
|
|
71
|
+
export {
|
|
72
|
+
createSealedCookieStore,
|
|
73
|
+
cookieNames,
|
|
74
|
+
applySetCookies,
|
|
75
|
+
connectionExpiresAt,
|
|
76
|
+
markerCookie,
|
|
77
|
+
readMarker,
|
|
78
|
+
MARKER_TTL_SEC,
|
|
79
|
+
} from "./store";
|
|
52
80
|
export type { ConnectionStore, CookieNames, SealedCookieStoreOptions } from "./store";
|
|
53
81
|
export { seal, unseal, MIN_SECRET_LENGTH } from "./seal";
|
|
54
82
|
export type { AppSecret, Opened, SealPurpose } from "./seal";
|
|
@@ -56,4 +84,4 @@ export { pkceChallenge } from "./encoding";
|
|
|
56
84
|
export { ConnectError, isConnectError, parseConnectErrorCode, CONNECT_ERROR_CODES } from "./errors";
|
|
57
85
|
export type { ConnectErrorCode } from "./errors";
|
|
58
86
|
export { isBrowserRuntime } from "./guard";
|
|
59
|
-
export type { AppRequest, ClientMetadataDocument, PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
|
|
87
|
+
export type { AppRequest, ClientMetadataDocument, PodConnection, PodGrant, PodIssuer, PodSpace, PodUser } from "./types";
|
package/src/connect/seal.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { ConnectError } from "./errors";
|
|
14
14
|
import { base64url, fromBase64url, fromUtf8, randomBytes, utf8 } from "./encoding";
|
|
15
15
|
|
|
16
|
-
export type SealPurpose = "synap-connect/transaction" | "synap-connect/session";
|
|
16
|
+
export type SealPurpose = "synap-connect/transaction" | "synap-connect/session" | "synap-connect/cloud";
|
|
17
17
|
|
|
18
18
|
/** The app secret: one value, or several to rotate (the first seals, all open). */
|
|
19
19
|
export type AppSecret = string | readonly string[];
|
package/src/connect/store.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Bring your own (a database, a KV) by implementing `ConnectionStore`; it
|
|
8
8
|
* answers with the Set-Cookie values to send (e.g. its own session id).
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { canonicalPodUrl } from "./discovery";
|
|
11
|
+
import { base64url, fromBase64url, fromUtf8, parseCookies, serializeCookie, utf8 } from "./encoding";
|
|
11
12
|
import { ConnectError } from "./errors";
|
|
12
13
|
import { seal, unseal, type AppSecret } from "./seal";
|
|
13
14
|
import type { PodConnection, PodGrant } from "./types";
|
|
@@ -24,13 +25,50 @@ export interface ConnectionStore {
|
|
|
24
25
|
export interface CookieNames {
|
|
25
26
|
session: string;
|
|
26
27
|
transaction: string;
|
|
28
|
+
/** A Synap Cloud sign-in in progress (sealed state, 10 minutes). */
|
|
29
|
+
cloud: string;
|
|
30
|
+
/** "This browser was connected to <pod>": the pod's URL only, no secret (`markerCookie`). */
|
|
31
|
+
marker: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** `__Host-` names on https (Secure, Path=/, no Domain: bound to this exact host). */
|
|
30
35
|
export function cookieNames(secure: boolean): CookieNames {
|
|
31
36
|
return secure
|
|
32
|
-
? { session: "__Host-synap_session", transaction: "__Host-synap_tx" }
|
|
33
|
-
: { session: "synap_session", transaction: "synap_tx" };
|
|
37
|
+
? { session: "__Host-synap_session", transaction: "__Host-synap_tx", cloud: "__Host-synap_cloud", marker: "__Host-synap_seen" }
|
|
38
|
+
: { session: "synap_session", transaction: "synap_tx", cloud: "synap_cloud", marker: "synap_seen" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The marker outlives the connection: as long as browsers keep a cookie (400 days). */
|
|
42
|
+
export const MARKER_TTL_SEC = 400 * 24 * 60 * 60;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The marker cookie: which pod this browser was connected to, so a connection
|
|
46
|
+
* that lapsed (idle past the refresh token's life, its sealed cookie gone)
|
|
47
|
+
* reads as "expired, connect again to <pod>" rather than "never connected".
|
|
48
|
+
* It holds the pod's public URL and NOTHING else: no token, no user, nothing
|
|
49
|
+
* sealed. A forged value can only pre-fill the address the person confirms.
|
|
50
|
+
*/
|
|
51
|
+
export function markerCookie(podUrl: string, opts: { secure: boolean; name?: string }): string {
|
|
52
|
+
return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
|
|
53
|
+
maxAgeSec: MARKER_TTL_SEC,
|
|
54
|
+
secure: opts.secure,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The marker's pod URL, or null (absent, or not a canonical pod address). */
|
|
59
|
+
export function readMarker(
|
|
60
|
+
cookieHeader: string | null,
|
|
61
|
+
opts: { secure: boolean; name?: string; allowInsecureLoopback?: boolean },
|
|
62
|
+
): string | null {
|
|
63
|
+
const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
|
|
64
|
+
const bytes = raw ? fromBase64url(raw) : null;
|
|
65
|
+
if (!bytes || bytes.length > 512) return null;
|
|
66
|
+
const podUrl = fromUtf8(bytes);
|
|
67
|
+
try {
|
|
68
|
+
return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
34
72
|
}
|
|
35
73
|
|
|
36
74
|
/** Browsers cap a cookie near 4096 bytes; a big grant spans several. */
|
package/src/connect/types.ts
CHANGED
|
@@ -63,6 +63,26 @@ export interface PodConnection {
|
|
|
63
63
|
user: PodUser;
|
|
64
64
|
/** ms since epoch. */
|
|
65
65
|
connectedAt: number;
|
|
66
|
+
/**
|
|
67
|
+
* The pod's id for this app connection (`app_…`, the grant's client id), when
|
|
68
|
+
* the token response names it (`synap_app.id`). Absent from pods that do not.
|
|
69
|
+
*/
|
|
70
|
+
appId?: string;
|
|
71
|
+
/**
|
|
72
|
+
* The pod's own page for this app connection (pod-admin `/apps/<appId>`:
|
|
73
|
+
* what it may touch, narrow it, apply automatically, revoke), when the token
|
|
74
|
+
* response names it (`synap_app.manageUrl`). Absent otherwise: hide the link,
|
|
75
|
+
* never guess one.
|
|
76
|
+
*/
|
|
77
|
+
manageUrl?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One space of the grant, its permissions together (`spaces(connection)`). */
|
|
81
|
+
export interface PodSpace {
|
|
82
|
+
workspaceId: string;
|
|
83
|
+
/** "" when the pod sent no name. */
|
|
84
|
+
workspaceName: string;
|
|
85
|
+
permissions: string[];
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/** The CIMD document an app serves at its client_id URL (spec 17 §6.1). */
|
|
@@ -13,13 +13,23 @@
|
|
|
13
13
|
* a token presented AGAIN after its use is a reuse that revokes its whole
|
|
14
14
|
* family AND the live access key; a new code ends the client's older
|
|
15
15
|
* families; /revoke with a refresh token kills its family and access key.
|
|
16
|
+
* The reuse grace (PB3b): within 30 s of its use, a spent token presented
|
|
17
|
+
* again with its own client_id gets the SAME pair its first use got (the
|
|
18
|
+
* successor refresh token and the live key, `expires_in` = what that key has
|
|
19
|
+
* left), as long as the successor is unused; past it, a reuse.
|
|
16
20
|
* `refresh: false` is a pod before PB3 (no refresh_token at all).
|
|
17
21
|
*
|
|
22
|
+
* Hub REST: `GET /users/me`, `GET /entities` (an ARRAY of entities, as the pod
|
|
23
|
+
* answers; the seeds plus what applied writes created), `GET /proposals`
|
|
24
|
+
* (`{ proposals, limit, offset, total, hasMore }`, rows only in spaces where
|
|
25
|
+
* the token holds `proposal.read`, as the pod's grant clause), governed writes.
|
|
26
|
+
*
|
|
18
27
|
* mock.authorize(authorizeUrl) → the callback URL (approved)
|
|
19
28
|
* mock.authorize(authorizeUrl, { deny: true }) → error=access_denied
|
|
20
29
|
* mock.authorize(authorizeUrl, { iss: "https://evil.example" }) → a mix-up attempt
|
|
21
|
-
* mock.loseNextRefreshRace() → the next redeem is a concurrent loser (refused, no revoke)
|
|
22
|
-
* mock.
|
|
30
|
+
* mock.loseNextRefreshRace() → the next redeem is a concurrent loser that found no successor (refused, no revoke)
|
|
31
|
+
* mock.redeemElsewhere(t) → another app instance rotated t (presenting it within 30 s gets that pair)
|
|
32
|
+
* mock.consumeRefreshToken(t) → the token was used elsewhere with no successor (presenting it is a reuse)
|
|
23
33
|
* mock.revokeAll() → the person removed the app in their pod
|
|
24
34
|
*/
|
|
25
35
|
import { pkceChallenge, randomToken } from "../connect/encoding";
|
|
@@ -47,6 +57,15 @@ export interface MockAuthServerOptions {
|
|
|
47
57
|
refreshIdleSec?: number;
|
|
48
58
|
/** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
|
|
49
59
|
advertiseRefreshExpiry?: boolean;
|
|
60
|
+
/** The reuse grace, seconds (PB3b). Default 30 (the pod's); 0 = off (a pod before PB3b). */
|
|
61
|
+
refreshGraceSec?: number;
|
|
62
|
+
/**
|
|
63
|
+
* `synap_app` on token responses: the connection's id and its page on the
|
|
64
|
+
* pod console. Default `MOCK_APP`; null = a pod that does not send it.
|
|
65
|
+
*/
|
|
66
|
+
app?: { id: string; manageUrl: string | null } | null;
|
|
67
|
+
/** Entities `GET /entities` answers (with `workspaceId` / `profileSlug` filters). */
|
|
68
|
+
entities?: Array<Record<string, unknown>>;
|
|
50
69
|
grant?: PodGrant[];
|
|
51
70
|
user?: PodUser;
|
|
52
71
|
/** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
|
|
@@ -76,12 +95,41 @@ export interface AuthorizeDecision {
|
|
|
76
95
|
state?: string;
|
|
77
96
|
}
|
|
78
97
|
|
|
98
|
+
/** A proposal a governed write filed (`GET /proposals` lists the pending ones). */
|
|
99
|
+
export interface MockProposal {
|
|
100
|
+
id: string;
|
|
101
|
+
proposalType: string;
|
|
102
|
+
targetType: string;
|
|
103
|
+
targetId: string | null;
|
|
104
|
+
status: "pending" | "approved" | "rejected";
|
|
105
|
+
workspaceId: string | null;
|
|
106
|
+
createdAt: string;
|
|
107
|
+
correlationId: string | null;
|
|
108
|
+
sessionId: string | null;
|
|
109
|
+
agentUserId: string | null;
|
|
110
|
+
summary?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The connection's `synap_app` the mock pod answers by default (backend `appTokenResponse`). */
|
|
114
|
+
export const MOCK_APP = { id: "app_mock_studio", manageUrl: "https://pod-admin.mock-synap.test/apps/app_mock_studio" };
|
|
115
|
+
|
|
116
|
+
/** The app's agent on the mock pod: who files its proposals. */
|
|
117
|
+
export const MOCK_APP_AGENT_ID = "agent_mock_app";
|
|
118
|
+
|
|
79
119
|
export interface MockAuthServer {
|
|
80
120
|
issuer: string;
|
|
81
121
|
fetch: typeof fetch;
|
|
82
122
|
requests: MockAuthRequest[];
|
|
83
123
|
/** Hub writes seen, in order. */
|
|
84
124
|
writes: Array<{ method: string; path: string; body: unknown }>;
|
|
125
|
+
/** Proposals filed by proposed writes, in order. */
|
|
126
|
+
proposals: MockProposal[];
|
|
127
|
+
/** The person approved or rejected one: it leaves the pending list. */
|
|
128
|
+
settleProposal(id: string, status: "approved" | "rejected"): void;
|
|
129
|
+
/** Another instance of the app rotated `token` (its own refresh): returns the pair the pod answered it. */
|
|
130
|
+
redeemElsewhere(token: string): Promise<{ access_token: string; refresh_token: string; expires_in: number }>;
|
|
131
|
+
/** Refresh redeems answered within the reuse grace (the same pair again). */
|
|
132
|
+
graceAnswers: number;
|
|
85
133
|
/** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
|
|
86
134
|
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
87
135
|
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
@@ -109,6 +157,7 @@ interface AccessRow {
|
|
|
109
157
|
expiresAt: number;
|
|
110
158
|
revoked: boolean;
|
|
111
159
|
family: string | null;
|
|
160
|
+
scope: string[];
|
|
112
161
|
}
|
|
113
162
|
|
|
114
163
|
interface RefreshRow {
|
|
@@ -118,6 +167,10 @@ interface RefreshRow {
|
|
|
118
167
|
used: boolean;
|
|
119
168
|
revoked: boolean;
|
|
120
169
|
scope: string[];
|
|
170
|
+
/** When it was spent, and the pair that use was answered (the grace re-answers it). */
|
|
171
|
+
usedAt?: number;
|
|
172
|
+
successor?: string;
|
|
173
|
+
pairAccess?: string;
|
|
121
174
|
}
|
|
122
175
|
|
|
123
176
|
function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
|
@@ -144,7 +197,12 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
144
197
|
const refreshTokens = new Map<string, RefreshRow>();
|
|
145
198
|
const requests: MockAuthRequest[] = [];
|
|
146
199
|
const writes: Array<{ method: string; path: string; body: unknown }> = [];
|
|
200
|
+
const proposals: MockProposal[] = [];
|
|
201
|
+
const entities: Array<Record<string, unknown>> = [...(options.entities ?? [])];
|
|
202
|
+
const graceMs = (options.refreshGraceSec ?? 30) * 1000;
|
|
203
|
+
const app = options.app === undefined ? MOCK_APP : options.app;
|
|
147
204
|
let proposalSeq = 0;
|
|
205
|
+
let graceAnswers = 0;
|
|
148
206
|
|
|
149
207
|
const metadata = (): Record<string, unknown> => {
|
|
150
208
|
const base: Record<string, unknown> = {
|
|
@@ -175,18 +233,25 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
175
233
|
for (const a of access.values()) if (a.family === family) a.revoked = true;
|
|
176
234
|
};
|
|
177
235
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
236
|
+
/** The token response; `pair` re-answers an existing access key + refresh token (the grace). */
|
|
237
|
+
const tokenBody = (scope: string[], clientId: string, family: string, pair?: { accessToken: string; refreshToken: string }) => {
|
|
238
|
+
const accessToken = pair?.accessToken ?? `synap_hub_mock_${randomToken()}`;
|
|
239
|
+
if (!pair) access.set(accessToken, { expiresAt: now() + accessTtl * 1000, revoked: false, family: refresh ? family : null, scope });
|
|
240
|
+
const row = access.get(accessToken)!;
|
|
181
241
|
const body: Record<string, unknown> = {
|
|
182
242
|
access_token: accessToken,
|
|
183
243
|
token_type: "Bearer",
|
|
184
|
-
|
|
244
|
+
// As the pod: what the key has left, never an assumed hour.
|
|
245
|
+
expires_in: Math.max(0, Math.floor((row.expiresAt - now()) / 1000)),
|
|
185
246
|
scope: scope.join(" "),
|
|
186
247
|
synap_grant: grant.filter((g) => scope.includes(g.permission)),
|
|
187
248
|
synap_user: user,
|
|
249
|
+
...(app ? { synap_app: app } : {}),
|
|
188
250
|
};
|
|
189
|
-
if (
|
|
251
|
+
if (pair) {
|
|
252
|
+
body.refresh_token = pair.refreshToken;
|
|
253
|
+
if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = Math.floor((refreshTokens.get(pair.refreshToken)!.idleUntil - now()) / 1000);
|
|
254
|
+
} else if (refresh) {
|
|
190
255
|
const refreshToken = randomToken();
|
|
191
256
|
refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1000, used: false, revoked: false, scope });
|
|
192
257
|
body.refresh_token = refreshToken;
|
|
@@ -220,6 +285,14 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
220
285
|
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
221
286
|
}
|
|
222
287
|
if (row.used) {
|
|
288
|
+
// PB3b: the same pair again, inside the grace, while the successor is unused and its key lives.
|
|
289
|
+
const successor = row.successor ? refreshTokens.get(row.successor) : undefined;
|
|
290
|
+
const key = row.pairAccess ? access.get(row.pairAccess) : undefined;
|
|
291
|
+
const inGrace = graceMs > 0 && row.usedAt !== undefined && now() - row.usedAt < graceMs;
|
|
292
|
+
if (inGrace && successor && !successor.used && !successor.revoked && key && !key.revoked && key.expiresAt > now()) {
|
|
293
|
+
graceAnswers++;
|
|
294
|
+
return json(200, tokenBody(row.scope, row.clientId, row.family, { accessToken: row.pairAccess!, refreshToken: row.successor! }));
|
|
295
|
+
}
|
|
223
296
|
reuses++;
|
|
224
297
|
revokeFamily(row.family);
|
|
225
298
|
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
@@ -230,12 +303,18 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
230
303
|
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
231
304
|
}
|
|
232
305
|
row.used = true;
|
|
233
|
-
|
|
306
|
+
row.usedAt = now();
|
|
307
|
+
// One live key per connection: the rotation retires the family's older keys.
|
|
308
|
+
for (const a of access.values()) if (a.family === row.family) a.revoked = true;
|
|
309
|
+
const body = tokenBody(row.scope, row.clientId, row.family);
|
|
310
|
+
row.successor = body.refresh_token as string;
|
|
311
|
+
row.pairAccess = body.access_token as string;
|
|
312
|
+
return json(200, body);
|
|
234
313
|
}
|
|
235
314
|
return oauthError("unsupported_grant_type", "Unsupported grant_type");
|
|
236
315
|
}
|
|
237
316
|
|
|
238
|
-
function hub(method: string, path: string, headers: Record<string, string>, body: unknown): Response | "reject" {
|
|
317
|
+
function hub(method: string, path: string, query: URLSearchParams, headers: Record<string, string>, body: unknown): Response | "reject" {
|
|
239
318
|
const failure = options.failures?.[path];
|
|
240
319
|
if (failure === "unreachable") return "reject";
|
|
241
320
|
const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
|
|
@@ -258,11 +337,48 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
258
337
|
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
259
338
|
}
|
|
260
339
|
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
261
|
-
if (method === "GET" && path === "/entities")
|
|
340
|
+
if (method === "GET" && path === "/entities") {
|
|
341
|
+
// The pod answers an ARRAY (rest/entities.ts), not a page object.
|
|
342
|
+
const ws = query.get("workspaceId");
|
|
343
|
+
const kind = query.get("profileSlug");
|
|
344
|
+
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
345
|
+
const limit = Number(query.get("limit") ?? 50) || 50;
|
|
346
|
+
const rows = entities.filter((e) => (!ws || e.workspaceId === ws) && (!kind || e.profileSlug === kind));
|
|
347
|
+
return json(200, rows.slice(offset, offset + limit));
|
|
348
|
+
}
|
|
349
|
+
if (method === "GET" && path === "/proposals") {
|
|
350
|
+
// The grant clause: proposals are read only where the token holds proposal.read (else none, not a 403).
|
|
351
|
+
const readable = new Set(grant.filter((g) => g.permission === "proposal.read" && row.scope.includes(g.permission)).map((g) => g.workspaceId));
|
|
352
|
+
const status = query.get("status") ?? "pending";
|
|
353
|
+
const ws = query.get("workspaceId");
|
|
354
|
+
const limit = Math.min(Math.max(Number(query.get("limit") ?? 50) || 50, 1), 200);
|
|
355
|
+
const offset = Number(query.get("offset") ?? 0) || 0;
|
|
356
|
+
const rows = proposals
|
|
357
|
+
.filter((p) => p.workspaceId !== null && readable.has(p.workspaceId))
|
|
358
|
+
.filter((p) => (status === "all" || p.status === status) && (!ws || p.workspaceId === ws))
|
|
359
|
+
.reverse(); // newest first
|
|
360
|
+
const page = rows.slice(offset, offset + limit);
|
|
361
|
+
return json(200, { proposals: page, limit, offset, total: rows.length, hasMore: offset + page.length < rows.length });
|
|
362
|
+
}
|
|
262
363
|
if (method !== "GET") {
|
|
263
364
|
writes.push({ method, path, body });
|
|
365
|
+
const fields = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
366
|
+
const workspaceId = typeof fields.workspaceId === "string" ? fields.workspaceId : null;
|
|
264
367
|
if ((options.writeMode ?? "proposed") === "proposed") {
|
|
265
368
|
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
369
|
+
proposals.push({
|
|
370
|
+
id: proposalId,
|
|
371
|
+
proposalType: method === "POST" ? "create" : method === "DELETE" ? "delete" : "update",
|
|
372
|
+
targetType: path.split("/")[1]?.replace(/s$/, "") ?? "entity",
|
|
373
|
+
targetId: null,
|
|
374
|
+
status: "pending",
|
|
375
|
+
workspaceId,
|
|
376
|
+
createdAt: new Date(now()).toISOString(),
|
|
377
|
+
correlationId: null,
|
|
378
|
+
sessionId: null,
|
|
379
|
+
agentUserId: MOCK_APP_AGENT_ID,
|
|
380
|
+
...(typeof fields.title === "string" ? { summary: `Create "${fields.title}"` } : {}),
|
|
381
|
+
});
|
|
266
382
|
return json(200, {
|
|
267
383
|
status: "proposed",
|
|
268
384
|
message: "Sent for review",
|
|
@@ -270,7 +386,9 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
270
386
|
reviewUrl: `${issuer}/open/${proposalId}`,
|
|
271
387
|
});
|
|
272
388
|
}
|
|
273
|
-
|
|
389
|
+
const id = `ent_mock_${writes.length}`;
|
|
390
|
+
if (method === "POST" && path === "/entities") entities.push({ id, ...fields });
|
|
391
|
+
return json(200, { status: "created", id });
|
|
274
392
|
}
|
|
275
393
|
return json(404, { error: "Not found" });
|
|
276
394
|
}
|
|
@@ -311,7 +429,7 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
311
429
|
} catch {
|
|
312
430
|
body = rawBody;
|
|
313
431
|
}
|
|
314
|
-
const res = hub(method, hubPath, headers, body);
|
|
432
|
+
const res = hub(method, hubPath, url.searchParams, headers, body);
|
|
315
433
|
if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
|
|
316
434
|
return res;
|
|
317
435
|
}
|
|
@@ -323,6 +441,20 @@ export function createMockAuthServer(options: MockAuthServerOptions = {}): MockA
|
|
|
323
441
|
fetch: mockFetch as typeof fetch,
|
|
324
442
|
requests,
|
|
325
443
|
writes,
|
|
444
|
+
proposals,
|
|
445
|
+
settleProposal(id, status) {
|
|
446
|
+
const p = proposals.find((x) => x.id === id);
|
|
447
|
+
if (p) p.status = status;
|
|
448
|
+
},
|
|
449
|
+
async redeemElsewhere(t) {
|
|
450
|
+
const row = refreshTokens.get(t);
|
|
451
|
+
const res = await token({ grant_type: "refresh_token", refresh_token: t, client_id: row?.clientId ?? "" });
|
|
452
|
+
if (res.status !== 200) throw new Error(`redeemElsewhere: the pod answered ${res.status}`);
|
|
453
|
+
return (await res.json()) as { access_token: string; refresh_token: string; expires_in: number };
|
|
454
|
+
},
|
|
455
|
+
get graceAnswers() {
|
|
456
|
+
return graceAnswers;
|
|
457
|
+
},
|
|
326
458
|
authorize(authorizeUrl, decision = {}) {
|
|
327
459
|
const url = new URL(authorizeUrl);
|
|
328
460
|
if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
|