@synap-core/sdk 1.2.0 → 1.3.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 +80 -0
- package/dist/chunk-J7XHAAFJ.js +79 -0
- package/dist/chunk-J7XHAAFJ.js.map +1 -0
- package/dist/{client-C5sF20f1.d.cts → client-CVUT1Mcf.d.cts} +1 -1
- package/dist/{client-C5sF20f1.d.ts → client-CVUT1Mcf.d.ts} +1 -1
- 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 +1206 -0
- package/dist/connect.cjs.map +1 -0
- package/dist/connect.d.cts +442 -0
- package/dist/connect.d.ts +442 -0
- package/dist/connect.js +1092 -0
- package/dist/connect.js.map +1 -0
- package/dist/public.d.cts +2 -2
- package/dist/public.d.ts +2 -2
- package/dist/testing.cjs +304 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +73 -2
- package/dist/testing.d.ts +73 -2
- package/dist/testing.js +281 -19
- package/dist/testing.js.map +1 -1
- package/dist/types-DpqPNaaa.d.cts +76 -0
- package/dist/types-DpqPNaaa.d.ts +76 -0
- package/package.json +11 -3
- package/src/connect/auth.ts +405 -0
- package/src/connect/browser.ts +10 -0
- package/src/connect/client.ts +193 -0
- package/src/connect/discovery.ts +262 -0
- package/src/connect/encoding.ts +93 -0
- package/src/connect/errors.ts +115 -0
- package/src/connect/flow.ts +449 -0
- package/src/connect/guard.ts +25 -0
- package/src/connect/index.ts +59 -0
- package/src/connect/seal.ts +119 -0
- package/src/connect/store.ts +151 -0
- package/src/connect/types.ts +80 -0
- package/src/testing/auth-server.ts +380 -0
- package/src/testing/index.ts +5 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a PodConnection lives between requests. The default keeps it in the
|
|
3
|
+
* browser as a SEALED cookie (AES-GCM under the app secret, HttpOnly, Secure
|
|
4
|
+
* on https, SameSite=Lax, Path=/, `__Host-` prefix on https): the browser holds
|
|
5
|
+
* ciphertext it cannot read or forge, the pod token never reaches JS.
|
|
6
|
+
*
|
|
7
|
+
* Bring your own (a database, a KV) by implementing `ConnectionStore`; it
|
|
8
|
+
* answers with the Set-Cookie values to send (e.g. its own session id).
|
|
9
|
+
*/
|
|
10
|
+
import { parseCookies, serializeCookie } from "./encoding";
|
|
11
|
+
import { ConnectError } from "./errors";
|
|
12
|
+
import { seal, unseal, type AppSecret } from "./seal";
|
|
13
|
+
import type { PodConnection, PodGrant } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface ConnectionStore {
|
|
16
|
+
/** The request's connection, or null (absent, tampered, foreign, expired). */
|
|
17
|
+
read(request: Request): Promise<PodConnection | null>;
|
|
18
|
+
/** Persist; returns the Set-Cookie header values to send. */
|
|
19
|
+
write(connection: PodConnection, request: Request): Promise<string[]>;
|
|
20
|
+
/** Forget; returns the Set-Cookie header values to send. */
|
|
21
|
+
clear(request: Request): Promise<string[]>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CookieNames {
|
|
25
|
+
session: string;
|
|
26
|
+
transaction: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `__Host-` names on https (Secure, Path=/, no Domain: bound to this exact host). */
|
|
30
|
+
export function cookieNames(secure: boolean): CookieNames {
|
|
31
|
+
return secure
|
|
32
|
+
? { session: "__Host-synap_session", transaction: "__Host-synap_tx" }
|
|
33
|
+
: { session: "synap_session", transaction: "synap_tx" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Browsers cap a cookie near 4096 bytes; a big grant spans several. */
|
|
37
|
+
const CHUNK_SIZE = 3800;
|
|
38
|
+
const MAX_CHUNKS = 4;
|
|
39
|
+
|
|
40
|
+
function chunkName(name: string, i: number): string {
|
|
41
|
+
return i === 0 ? name : `${name}.${i}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The sealed form: the grant grouped per space (it repeats the space otherwise). */
|
|
45
|
+
interface StoredConnection extends Omit<PodConnection, "grant"> {
|
|
46
|
+
g: Array<[workspaceId: string, workspaceName: string, permissions: string[]]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pack(c: PodConnection): StoredConnection {
|
|
50
|
+
const bySpace = new Map<string, [string, string, string[]]>();
|
|
51
|
+
for (const g of c.grant) {
|
|
52
|
+
const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
|
|
53
|
+
row[2].push(g.permission);
|
|
54
|
+
bySpace.set(g.workspaceId, row);
|
|
55
|
+
}
|
|
56
|
+
const { grant: _grant, ...rest } = c;
|
|
57
|
+
return { ...rest, g: [...bySpace.values()] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unpack(s: StoredConnection): PodConnection | null {
|
|
61
|
+
if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
|
|
62
|
+
const { g, ...rest } = s;
|
|
63
|
+
const grant: PodGrant[] = g.flatMap(([workspaceId, workspaceName, permissions]) =>
|
|
64
|
+
permissions.map((permission) => ({ permission, workspaceId, workspaceName })),
|
|
65
|
+
);
|
|
66
|
+
return { ...rest, grant };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** When the stored value stops being useful. */
|
|
70
|
+
export function connectionExpiresAt(c: PodConnection): number {
|
|
71
|
+
return c.refreshToken && c.refreshExpiresAt !== undefined ? Math.max(c.refreshExpiresAt, c.accessExpiresAt) : c.accessExpiresAt;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SealedCookieStoreOptions {
|
|
75
|
+
secret: AppSecret;
|
|
76
|
+
/** True on https (the default when the app URL is https). */
|
|
77
|
+
secure: boolean;
|
|
78
|
+
name?: string;
|
|
79
|
+
now?: () => number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createSealedCookieStore(opts: SealedCookieStoreOptions): ConnectionStore {
|
|
83
|
+
const name = opts.name ?? cookieNames(opts.secure).session;
|
|
84
|
+
const now = opts.now ?? Date.now;
|
|
85
|
+
|
|
86
|
+
const presentChunks = (request: Request) => {
|
|
87
|
+
const cookies = parseCookies(request.headers.get("cookie"));
|
|
88
|
+
const names: string[] = [];
|
|
89
|
+
for (let i = 0; i < MAX_CHUNKS; i++) if (cookies.has(chunkName(name, i))) names.push(chunkName(name, i));
|
|
90
|
+
return { cookies, names };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
async read(request) {
|
|
95
|
+
const { cookies } = presentChunks(request);
|
|
96
|
+
let sealed = "";
|
|
97
|
+
for (let i = 0; i < MAX_CHUNKS; i++) {
|
|
98
|
+
const part = cookies.get(chunkName(name, i));
|
|
99
|
+
if (part === undefined) break;
|
|
100
|
+
sealed += part;
|
|
101
|
+
}
|
|
102
|
+
if (!sealed) return null;
|
|
103
|
+
const opened = await unseal<StoredConnection>(sealed, { secret: opts.secret, purpose: "synap-connect/session", now: now() });
|
|
104
|
+
return opened.ok ? unpack(opened.value) : null;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async write(connection, request) {
|
|
108
|
+
const expiresAt = connectionExpiresAt(connection);
|
|
109
|
+
const sealed = await seal(pack(connection), { secret: opts.secret, purpose: "synap-connect/session", expiresAt });
|
|
110
|
+
const parts: string[] = [];
|
|
111
|
+
for (let i = 0; i < sealed.length; i += CHUNK_SIZE) parts.push(sealed.slice(i, i + CHUNK_SIZE));
|
|
112
|
+
if (parts.length > MAX_CHUNKS) {
|
|
113
|
+
throw new ConnectError(
|
|
114
|
+
"misconfigured",
|
|
115
|
+
"The connection is too large for a cookie (a very wide grant); pass a server-side ConnectionStore",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const maxAgeSec = (expiresAt - now()) / 1000;
|
|
119
|
+
const out = parts.map((value, i) => serializeCookie(chunkName(name, i), value, { maxAgeSec, secure: opts.secure }));
|
|
120
|
+
// Drop chunks a previous, larger value left behind.
|
|
121
|
+
for (const stale of presentChunks(request).names.slice(parts.length)) {
|
|
122
|
+
out.push(serializeCookie(stale, "", { maxAgeSec: 0, secure: opts.secure }));
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async clear(request) {
|
|
128
|
+
const { names } = presentChunks(request);
|
|
129
|
+
return (names.length > 0 ? names : [name]).map((n) => serializeCookie(n, "", { maxAgeSec: 0, secure: opts.secure }));
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Apply Set-Cookie values to a request's Cookie header, so code that runs
|
|
136
|
+
* later IN THE SAME REQUEST (a server component after `proxy.ts`) reads the
|
|
137
|
+
* refreshed value. A `Max-Age=0` removes the cookie.
|
|
138
|
+
*/
|
|
139
|
+
export function applySetCookies(cookieHeader: string | null, setCookies: readonly string[]): string {
|
|
140
|
+
const cookies = parseCookies(cookieHeader);
|
|
141
|
+
for (const sc of setCookies) {
|
|
142
|
+
const [pair, ...attrs] = sc.split(";");
|
|
143
|
+
const eq = pair.indexOf("=");
|
|
144
|
+
if (eq < 0) continue;
|
|
145
|
+
const name = pair.slice(0, eq).trim();
|
|
146
|
+
const removed = attrs.some((a) => /^\s*max-age\s*=\s*0\s*$/i.test(a));
|
|
147
|
+
if (removed) cookies.delete(name);
|
|
148
|
+
else cookies.set(name, pair.slice(eq + 1).trim());
|
|
149
|
+
}
|
|
150
|
+
return [...cookies].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
151
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire and session shapes of pod-app sign-in.
|
|
3
|
+
*
|
|
4
|
+
* DRIFT RISK: hand-written from the pod's OAuth AS (synap-backend
|
|
5
|
+
* `routers/oauth/routes.ts` /token for a CIMD client, `protocol.ts`
|
|
6
|
+
* `buildAuthorizationServerMetadata`, spec 17 §6.6). The contract fixture
|
|
7
|
+
* `src/__tests__/connect.contract.test.ts` copies the token response; when the
|
|
8
|
+
* backend changes, update both.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One permission the app asks for, with the sentence the consent screen shows. */
|
|
12
|
+
export interface AppRequest {
|
|
13
|
+
/** Grant grammar, e.g. `entity.post.read`, `document.update`. No `*`. */
|
|
14
|
+
permission: string;
|
|
15
|
+
/** Why the app needs it (at most 200 characters). */
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What the person approved: one row per permission per space. */
|
|
20
|
+
export interface PodGrant {
|
|
21
|
+
permission: string;
|
|
22
|
+
workspaceId: string;
|
|
23
|
+
workspaceName: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PodUser {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A pod's authorization server, as `discoverPod` validated it. */
|
|
32
|
+
export interface PodIssuer {
|
|
33
|
+
/** The canonical issuer, which is also the pod's base URL. */
|
|
34
|
+
issuer: string;
|
|
35
|
+
authorizationEndpoint: string;
|
|
36
|
+
tokenEndpoint: string;
|
|
37
|
+
revocationEndpoint: string | null;
|
|
38
|
+
/** The AS advertises `grant_types_supported: [..., "refresh_token"]`. */
|
|
39
|
+
refreshSupported: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A live connection of this app to one person's pod. Server-only: it holds the
|
|
44
|
+
* bearer token. Serialisable, so a custom store can keep it anywhere.
|
|
45
|
+
*/
|
|
46
|
+
export interface PodConnection {
|
|
47
|
+
/** The pod's canonical issuer = its base URL (no trailing slash). */
|
|
48
|
+
podUrl: string;
|
|
49
|
+
/** The app's client_id (its CIMD URL) the tokens were issued to. */
|
|
50
|
+
clientId: string;
|
|
51
|
+
tokenEndpoint: string;
|
|
52
|
+
revocationEndpoint: string | null;
|
|
53
|
+
accessToken: string;
|
|
54
|
+
/** ms since epoch. */
|
|
55
|
+
accessExpiresAt: number;
|
|
56
|
+
/** Absent when the pod issued none (then the connection ends at `accessExpiresAt`). */
|
|
57
|
+
refreshToken?: string;
|
|
58
|
+
/** ms since epoch; the refresh token's idle life, renewed by each rotation. */
|
|
59
|
+
refreshExpiresAt?: number;
|
|
60
|
+
/** The approved permissions (the token response's `scope`). */
|
|
61
|
+
scope: string[];
|
|
62
|
+
grant: PodGrant[];
|
|
63
|
+
user: PodUser;
|
|
64
|
+
/** ms since epoch. */
|
|
65
|
+
connectedAt: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The CIMD document an app serves at its client_id URL (spec 17 §6.1). */
|
|
69
|
+
export interface ClientMetadataDocument {
|
|
70
|
+
client_id: string;
|
|
71
|
+
client_name: string;
|
|
72
|
+
client_uri: string;
|
|
73
|
+
redirect_uris: string[];
|
|
74
|
+
grant_types: string[];
|
|
75
|
+
response_types: string[];
|
|
76
|
+
token_endpoint_auth_method: "none";
|
|
77
|
+
initiate_login_uri: string;
|
|
78
|
+
synap_requests: AppRequest[];
|
|
79
|
+
synap_templates?: string[];
|
|
80
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mock pod for pod-app sign-in: its OAuth authorization server (RFC 8414
|
|
3
|
+
* metadata, /authorize as a function standing in for the consent screen,
|
|
4
|
+
* /token, /revoke) and a few Hub REST doors behind the Bearer token. Inject
|
|
5
|
+
* `fetch` into the REAL `@synap-core/sdk/connect` (`createPodAppAuth({ fetch })`)
|
|
6
|
+
* so everything above the wire is the production path. No network.
|
|
7
|
+
*
|
|
8
|
+
* Wire behaviour mirrors synap-backend `routers/oauth/routes.ts` for a CIMD
|
|
9
|
+
* client: `iss` on every authorization answer (RFC 9207), PKCE S256 checked at
|
|
10
|
+
* /token, single-use codes bound to client_id + redirect_uri + resource, the
|
|
11
|
+
* token response's `synap_grant` / `synap_user`. Refresh mirrors PB3
|
|
12
|
+
* (`routers/oauth/refresh.ts`): rotating and single-use, 30 days from issue;
|
|
13
|
+
* a token presented AGAIN after its use is a reuse that revokes its whole
|
|
14
|
+
* family AND the live access key; a new code ends the client's older
|
|
15
|
+
* families; /revoke with a refresh token kills its family and access key.
|
|
16
|
+
* `refresh: false` is a pod before PB3 (no refresh_token at all).
|
|
17
|
+
*
|
|
18
|
+
* mock.authorize(authorizeUrl) → the callback URL (approved)
|
|
19
|
+
* mock.authorize(authorizeUrl, { deny: true }) → error=access_denied
|
|
20
|
+
* 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.consumeRefreshToken(t) → the token was used elsewhere (presenting it is a reuse)
|
|
23
|
+
* mock.revokeAll() → the person removed the app in their pod
|
|
24
|
+
*/
|
|
25
|
+
import { pkceChallenge, randomToken } from "../connect/encoding";
|
|
26
|
+
import type { PodGrant, PodUser } from "../connect/types";
|
|
27
|
+
|
|
28
|
+
/** A placeholder pod origin; never contacted. */
|
|
29
|
+
export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
30
|
+
|
|
31
|
+
export const MOCK_GRANT: PodGrant[] = [
|
|
32
|
+
{ permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
33
|
+
{ permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const MOCK_USER: PodUser = { id: "user_mock_ada", name: "Ada Lovelace" };
|
|
37
|
+
|
|
38
|
+
export type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
|
|
39
|
+
|
|
40
|
+
export interface MockAuthServerOptions {
|
|
41
|
+
issuer?: string;
|
|
42
|
+
/** Issue rotating refresh tokens (PB3). Default true; false = a pod before PB3. */
|
|
43
|
+
refresh?: boolean;
|
|
44
|
+
/** Default 3600 (the pod's 1 h). */
|
|
45
|
+
accessTtlSec?: number;
|
|
46
|
+
/** Default 30 days. */
|
|
47
|
+
refreshIdleSec?: number;
|
|
48
|
+
/** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
|
|
49
|
+
advertiseRefreshExpiry?: boolean;
|
|
50
|
+
grant?: PodGrant[];
|
|
51
|
+
user?: PodUser;
|
|
52
|
+
/** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
|
|
53
|
+
metadata?: Record<string, unknown>;
|
|
54
|
+
/** Hub writes: "proposed" (ask-first, the default for an app) or "applied". */
|
|
55
|
+
writeMode?: "proposed" | "applied";
|
|
56
|
+
/** Hub paths (after /api/hub) that answer one failure. */
|
|
57
|
+
failures?: Record<string, MockHubFailure>;
|
|
58
|
+
now?: () => number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MockAuthRequest {
|
|
62
|
+
method: string;
|
|
63
|
+
url: string;
|
|
64
|
+
/** Lower-cased header names. */
|
|
65
|
+
headers: Record<string, string>;
|
|
66
|
+
/** The form body of a /token or /revoke call. */
|
|
67
|
+
form: Record<string, string> | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AuthorizeDecision {
|
|
71
|
+
/** The person said no. */
|
|
72
|
+
deny?: boolean;
|
|
73
|
+
/** Override `iss` on the answer (null = omit it): a mix-up attack. */
|
|
74
|
+
iss?: string | null;
|
|
75
|
+
/** Override `state` on the answer: a forged answer. */
|
|
76
|
+
state?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MockAuthServer {
|
|
80
|
+
issuer: string;
|
|
81
|
+
fetch: typeof fetch;
|
|
82
|
+
requests: MockAuthRequest[];
|
|
83
|
+
/** Hub writes seen, in order. */
|
|
84
|
+
writes: Array<{ method: string; path: string; body: unknown }>;
|
|
85
|
+
/** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
|
|
86
|
+
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
87
|
+
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
88
|
+
consumeRefreshToken(token: string): void;
|
|
89
|
+
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
90
|
+
loseNextRefreshRace(): void;
|
|
91
|
+
/** Refresh-token redeems that were reuses (each revoked a family). */
|
|
92
|
+
reuses: number;
|
|
93
|
+
/** The person revoked the app: every token stops working. */
|
|
94
|
+
revokeAll(): void;
|
|
95
|
+
/** Live (unrevoked) refresh tokens. */
|
|
96
|
+
liveRefreshTokens(): string[];
|
|
97
|
+
reset(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface CodeRow {
|
|
101
|
+
clientId: string;
|
|
102
|
+
redirectUri: string;
|
|
103
|
+
challenge: string;
|
|
104
|
+
resource: string | null;
|
|
105
|
+
scope: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface AccessRow {
|
|
109
|
+
expiresAt: number;
|
|
110
|
+
revoked: boolean;
|
|
111
|
+
family: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface RefreshRow {
|
|
115
|
+
clientId: string;
|
|
116
|
+
family: string;
|
|
117
|
+
idleUntil: number;
|
|
118
|
+
used: boolean;
|
|
119
|
+
revoked: boolean;
|
|
120
|
+
scope: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
|
124
|
+
return new Response(JSON.stringify(body), {
|
|
125
|
+
status,
|
|
126
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function oauthError(error: string, description: string, status = 400): Response {
|
|
131
|
+
return json(status, { error, error_description: description });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createMockAuthServer(options: MockAuthServerOptions = {}): MockAuthServer {
|
|
135
|
+
const issuer = options.issuer ?? MOCK_AUTH_ISSUER;
|
|
136
|
+
const now = options.now ?? Date.now;
|
|
137
|
+
const refresh = options.refresh ?? true;
|
|
138
|
+
const accessTtl = options.accessTtlSec ?? 3600;
|
|
139
|
+
const refreshIdle = options.refreshIdleSec ?? 30 * 24 * 3600;
|
|
140
|
+
const grant = options.grant ?? MOCK_GRANT;
|
|
141
|
+
const user = options.user ?? MOCK_USER;
|
|
142
|
+
const codes = new Map<string, CodeRow>();
|
|
143
|
+
const access = new Map<string, AccessRow>();
|
|
144
|
+
const refreshTokens = new Map<string, RefreshRow>();
|
|
145
|
+
const requests: MockAuthRequest[] = [];
|
|
146
|
+
const writes: Array<{ method: string; path: string; body: unknown }> = [];
|
|
147
|
+
let proposalSeq = 0;
|
|
148
|
+
|
|
149
|
+
const metadata = (): Record<string, unknown> => {
|
|
150
|
+
const base: Record<string, unknown> = {
|
|
151
|
+
issuer,
|
|
152
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
153
|
+
token_endpoint: `${issuer}/token`,
|
|
154
|
+
registration_endpoint: `${issuer}/register`,
|
|
155
|
+
revocation_endpoint: `${issuer}/revoke`,
|
|
156
|
+
revocation_endpoint_auth_methods_supported: ["none"],
|
|
157
|
+
response_types_supported: ["code"],
|
|
158
|
+
grant_types_supported: refresh ? ["authorization_code", "refresh_token"] : ["authorization_code"],
|
|
159
|
+
code_challenge_methods_supported: ["S256"],
|
|
160
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
161
|
+
authorization_response_iss_parameter_supported: true,
|
|
162
|
+
client_id_metadata_document_supported: true,
|
|
163
|
+
};
|
|
164
|
+
for (const [k, v] of Object.entries(options.metadata ?? {})) {
|
|
165
|
+
if (v === undefined) delete base[k];
|
|
166
|
+
else base[k] = v;
|
|
167
|
+
}
|
|
168
|
+
return base;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let loseNextRace = false;
|
|
172
|
+
let reuses = 0;
|
|
173
|
+
const revokeFamily = (family: string) => {
|
|
174
|
+
for (const r of refreshTokens.values()) if (r.family === family) r.revoked = true;
|
|
175
|
+
for (const a of access.values()) if (a.family === family) a.revoked = true;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const tokenBody = (scope: string[], clientId: string, family: string) => {
|
|
179
|
+
const accessToken = `synap_hub_mock_${randomToken()}`;
|
|
180
|
+
access.set(accessToken, { expiresAt: now() + accessTtl * 1000, revoked: false, family: refresh ? family : null });
|
|
181
|
+
const body: Record<string, unknown> = {
|
|
182
|
+
access_token: accessToken,
|
|
183
|
+
token_type: "Bearer",
|
|
184
|
+
expires_in: accessTtl,
|
|
185
|
+
scope: scope.join(" "),
|
|
186
|
+
synap_grant: grant.filter((g) => scope.includes(g.permission)),
|
|
187
|
+
synap_user: user,
|
|
188
|
+
};
|
|
189
|
+
if (refresh) {
|
|
190
|
+
const refreshToken = randomToken();
|
|
191
|
+
refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1000, used: false, revoked: false, scope });
|
|
192
|
+
body.refresh_token = refreshToken;
|
|
193
|
+
if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = refreshIdle;
|
|
194
|
+
}
|
|
195
|
+
return body;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
async function token(form: Record<string, string>): Promise<Response> {
|
|
199
|
+
if (form.grant_type === "authorization_code") {
|
|
200
|
+
const row = form.code ? codes.get(form.code) : undefined;
|
|
201
|
+
if (form.code) codes.delete(form.code); // claim first: a failed exchange burns the code
|
|
202
|
+
if (!row) return oauthError("invalid_grant", "Invalid, expired, or already-redeemed authorization code");
|
|
203
|
+
if (row.clientId !== form.client_id) return oauthError("invalid_grant", "client_id does not match the code");
|
|
204
|
+
if (row.redirectUri !== form.redirect_uri) return oauthError("invalid_grant", "redirect_uri mismatch");
|
|
205
|
+
if (!form.code_verifier || (await pkceChallenge(form.code_verifier)) !== row.challenge) {
|
|
206
|
+
return oauthError("invalid_grant", "PKCE verification failed");
|
|
207
|
+
}
|
|
208
|
+
if (form.resource !== undefined && row.resource && form.resource !== row.resource) {
|
|
209
|
+
return oauthError("invalid_target", "resource does not match the authorization request");
|
|
210
|
+
}
|
|
211
|
+
// The latest connect wins: a new code ends this client's older families.
|
|
212
|
+
for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
|
|
213
|
+
return json(200, tokenBody(row.scope, row.clientId, randomToken()));
|
|
214
|
+
}
|
|
215
|
+
if (form.grant_type === "refresh_token") {
|
|
216
|
+
if (!refresh) return oauthError("unsupported_grant_type", 'Only grant_type "authorization_code" is supported');
|
|
217
|
+
if (!form.refresh_token || !form.client_id) return oauthError("invalid_request", "refresh_token and client_id are required");
|
|
218
|
+
const row = refreshTokens.get(form.refresh_token);
|
|
219
|
+
if (!row || row.clientId !== form.client_id || row.revoked) {
|
|
220
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
221
|
+
}
|
|
222
|
+
if (row.used) {
|
|
223
|
+
reuses++;
|
|
224
|
+
revokeFamily(row.family);
|
|
225
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
226
|
+
}
|
|
227
|
+
if (row.idleUntil <= now()) return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
228
|
+
if (loseNextRace) {
|
|
229
|
+
loseNextRace = false;
|
|
230
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
231
|
+
}
|
|
232
|
+
row.used = true;
|
|
233
|
+
return json(200, tokenBody(row.scope, row.clientId, row.family));
|
|
234
|
+
}
|
|
235
|
+
return oauthError("unsupported_grant_type", "Unsupported grant_type");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function hub(method: string, path: string, headers: Record<string, string>, body: unknown): Response | "reject" {
|
|
239
|
+
const failure = options.failures?.[path];
|
|
240
|
+
if (failure === "unreachable") return "reject";
|
|
241
|
+
const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
|
|
242
|
+
const row = bearer ? access.get(bearer) : undefined;
|
|
243
|
+
if (!row || row.revoked || row.expiresAt <= now()) return json(401, { error: "Unauthorized" });
|
|
244
|
+
switch (failure) {
|
|
245
|
+
case "rate_limited":
|
|
246
|
+
return json(429, { error: "Too many requests" }, { "Retry-After": "30" });
|
|
247
|
+
case "forbidden":
|
|
248
|
+
return json(403, { error: "Forbidden" });
|
|
249
|
+
case "door_unsupported":
|
|
250
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
251
|
+
case "server":
|
|
252
|
+
return json(500, { error: "Internal error" });
|
|
253
|
+
case "unavailable":
|
|
254
|
+
return json(503, { error: "Unavailable" });
|
|
255
|
+
}
|
|
256
|
+
// The grant fence (spec 17 §5): aggregate reads are refused to a scoped token.
|
|
257
|
+
if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
|
|
258
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
259
|
+
}
|
|
260
|
+
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
261
|
+
if (method === "GET" && path === "/entities") return json(200, { entities: [], total: 0 });
|
|
262
|
+
if (method !== "GET") {
|
|
263
|
+
writes.push({ method, path, body });
|
|
264
|
+
if ((options.writeMode ?? "proposed") === "proposed") {
|
|
265
|
+
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
266
|
+
return json(200, {
|
|
267
|
+
status: "proposed",
|
|
268
|
+
message: "Sent for review",
|
|
269
|
+
proposalId,
|
|
270
|
+
reviewUrl: `${issuer}/open/${proposalId}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return json(200, { status: "created", id: `ent_mock_${writes.length}` });
|
|
274
|
+
}
|
|
275
|
+
return json(404, { error: "Not found" });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const mockFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
279
|
+
const request = typeof input === "object" && "method" in input ? input : null;
|
|
280
|
+
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
281
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
282
|
+
const headers: Record<string, string> = {};
|
|
283
|
+
new Headers(init?.headers ?? request?.headers).forEach((value, name) => {
|
|
284
|
+
headers[name.toLowerCase()] = value;
|
|
285
|
+
});
|
|
286
|
+
const rawBody = typeof init?.body === "string" ? init.body : request ? await request.clone().text() : "";
|
|
287
|
+
const isForm = (headers["content-type"] ?? "").includes("application/x-www-form-urlencoded");
|
|
288
|
+
const form = isForm ? Object.fromEntries(new URLSearchParams(rawBody)) : null;
|
|
289
|
+
requests.push({ method, url: url.href, headers, form });
|
|
290
|
+
|
|
291
|
+
if (url.origin !== new URL(issuer).origin) throw new TypeError(`Failed to fetch: ${url.origin} is not the mock pod`);
|
|
292
|
+
const path = url.pathname;
|
|
293
|
+
if (method === "GET" && path === "/.well-known/oauth-authorization-server") return json(200, metadata());
|
|
294
|
+
if (method === "POST" && path === "/token") return token(form ?? {});
|
|
295
|
+
if (method === "POST" && path === "/revoke") {
|
|
296
|
+
const t = form?.token ?? "";
|
|
297
|
+
const r = refreshTokens.get(t);
|
|
298
|
+
if (r && form?.client_id && form.client_id !== r.clientId) {
|
|
299
|
+
return oauthError("unauthorized_client", "The token was not issued to this client");
|
|
300
|
+
}
|
|
301
|
+
if (r) revokeFamily(r.family);
|
|
302
|
+
const a = access.get(t);
|
|
303
|
+
if (a) a.revoked = true;
|
|
304
|
+
return new Response(null, { status: 200 });
|
|
305
|
+
}
|
|
306
|
+
const hubPath = /^\/api\/hub(\/.*)$/.exec(path)?.[1];
|
|
307
|
+
if (hubPath) {
|
|
308
|
+
let body: unknown = undefined;
|
|
309
|
+
try {
|
|
310
|
+
body = rawBody ? JSON.parse(rawBody) : undefined;
|
|
311
|
+
} catch {
|
|
312
|
+
body = rawBody;
|
|
313
|
+
}
|
|
314
|
+
const res = hub(method, hubPath, headers, body);
|
|
315
|
+
if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
|
|
316
|
+
return res;
|
|
317
|
+
}
|
|
318
|
+
return json(404, { error: "Not found" });
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
issuer,
|
|
323
|
+
fetch: mockFetch as typeof fetch,
|
|
324
|
+
requests,
|
|
325
|
+
writes,
|
|
326
|
+
authorize(authorizeUrl, decision = {}) {
|
|
327
|
+
const url = new URL(authorizeUrl);
|
|
328
|
+
if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
|
|
329
|
+
const q = url.searchParams;
|
|
330
|
+
const redirectUri = q.get("redirect_uri");
|
|
331
|
+
const clientId = q.get("client_id");
|
|
332
|
+
// Phase 1 (shown, never redirected): no verified redirect yet.
|
|
333
|
+
if (!clientId || !redirectUri) throw new Error("Authorization request rejected: client_id and redirect_uri are required");
|
|
334
|
+
const answer = new URL(redirectUri);
|
|
335
|
+
const state = decision.state ?? q.get("state");
|
|
336
|
+
const finish = (fields: Record<string, string>) => {
|
|
337
|
+
for (const [k, v] of Object.entries(fields)) answer.searchParams.set(k, v);
|
|
338
|
+
if (state !== null) answer.searchParams.set("state", state);
|
|
339
|
+
const iss = decision.iss === undefined ? issuer : decision.iss;
|
|
340
|
+
if (iss !== null) answer.searchParams.set("iss", iss);
|
|
341
|
+
return answer.href;
|
|
342
|
+
};
|
|
343
|
+
if (q.get("response_type") !== "code") return finish({ error: "unsupported_response_type" });
|
|
344
|
+
if (q.get("code_challenge_method") !== "S256" || !q.get("code_challenge")) {
|
|
345
|
+
return finish({ error: "invalid_request", error_description: "PKCE S256 is required" });
|
|
346
|
+
}
|
|
347
|
+
const resource = q.get("resource");
|
|
348
|
+
if (resource !== null && resource !== `${issuer}/api/hub` && resource !== `${issuer}/mcp`) {
|
|
349
|
+
return finish({ error: "invalid_target" });
|
|
350
|
+
}
|
|
351
|
+
const scope = (q.get("scope") ?? "").split(/\s+/).filter(Boolean);
|
|
352
|
+
if (scope.length === 0) return finish({ error: "invalid_scope" });
|
|
353
|
+
if (decision.deny) return finish({ error: "access_denied" });
|
|
354
|
+
const code = randomToken();
|
|
355
|
+
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge")!, resource, scope });
|
|
356
|
+
return finish({ code });
|
|
357
|
+
},
|
|
358
|
+
consumeRefreshToken(t) {
|
|
359
|
+
const row = refreshTokens.get(t);
|
|
360
|
+
if (row) row.used = true;
|
|
361
|
+
},
|
|
362
|
+
loseNextRefreshRace() {
|
|
363
|
+
loseNextRace = true;
|
|
364
|
+
},
|
|
365
|
+
get reuses() {
|
|
366
|
+
return reuses;
|
|
367
|
+
},
|
|
368
|
+
revokeAll() {
|
|
369
|
+
for (const row of access.values()) row.revoked = true;
|
|
370
|
+
for (const row of refreshTokens.values()) row.revoked = true;
|
|
371
|
+
},
|
|
372
|
+
liveRefreshTokens() {
|
|
373
|
+
return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
|
|
374
|
+
},
|
|
375
|
+
reset() {
|
|
376
|
+
requests.length = 0;
|
|
377
|
+
writes.length = 0;
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* tokens MOCK_TOKENS.form / .share / .site the demo form, share and site
|
|
16
16
|
* tokens MOCK_FAILURE_TOKENS.* one failure on every door
|
|
17
17
|
* anything else the uniform 404
|
|
18
|
+
*
|
|
19
|
+
* Pod-app sign-in (`@synap-core/sdk/connect`): `createMockAuthServer()`, see
|
|
20
|
+
* ./auth-server.ts.
|
|
18
21
|
*/
|
|
19
22
|
import type {
|
|
20
23
|
PublicCollectionPage,
|
|
@@ -28,6 +31,8 @@ import { MOCK_FORM, MOCK_SHARE, MOCK_SITE, type MockSite } from "./fixtures";
|
|
|
28
31
|
|
|
29
32
|
export { MOCK_FORM, MOCK_SHARE, MOCK_SITE };
|
|
30
33
|
export type { MockCollection, MockSite } from "./fixtures";
|
|
34
|
+
// Pod-app sign-in: a mock authorization server + Hub REST doors.
|
|
35
|
+
export * from "./auth-server";
|
|
31
36
|
|
|
32
37
|
/** A placeholder origin; never contacted. */
|
|
33
38
|
export const MOCK_POD_URL = "https://mock.synap.invalid";
|