@plurnk/plurnk-agui 3.1.0 → 4.0.1

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.
Files changed (74) hide show
  1. package/.env.defaults +17 -8
  2. package/README.md +33 -13
  3. package/SPEC.md +589 -268
  4. package/dist/AguiPlus.d.ts +24 -29
  5. package/dist/AguiPlus.d.ts.map +1 -1
  6. package/dist/AguiPlus.js +25 -36
  7. package/dist/AguiPlus.js.map +1 -1
  8. package/dist/AguiSurface.d.ts +1 -2
  9. package/dist/AguiSurface.d.ts.map +1 -1
  10. package/dist/AguiSurface.js +23 -16
  11. package/dist/AguiSurface.js.map +1 -1
  12. package/dist/AuthorizationDocuments.d.ts +33 -0
  13. package/dist/AuthorizationDocuments.d.ts.map +1 -0
  14. package/dist/AuthorizationDocuments.js +84 -0
  15. package/dist/AuthorizationDocuments.js.map +1 -0
  16. package/dist/BuiltinActions.d.ts +11 -5
  17. package/dist/BuiltinActions.d.ts.map +1 -1
  18. package/dist/BuiltinActions.js +73 -125
  19. package/dist/BuiltinActions.js.map +1 -1
  20. package/dist/EventRouter.d.ts +11 -3
  21. package/dist/EventRouter.d.ts.map +1 -1
  22. package/dist/EventRouter.js +17 -32
  23. package/dist/EventRouter.js.map +1 -1
  24. package/dist/MessageAddress.d.ts +7 -5
  25. package/dist/MessageAddress.d.ts.map +1 -1
  26. package/dist/MessageAddress.js +14 -12
  27. package/dist/MessageAddress.js.map +1 -1
  28. package/dist/Module.d.ts +5 -4
  29. package/dist/Module.d.ts.map +1 -1
  30. package/dist/Module.js +221 -82
  31. package/dist/Module.js.map +1 -1
  32. package/dist/OAuthServer.d.ts +30 -0
  33. package/dist/OAuthServer.d.ts.map +1 -0
  34. package/dist/OAuthServer.js +607 -0
  35. package/dist/OAuthServer.js.map +1 -0
  36. package/dist/Portal.d.ts +39 -9
  37. package/dist/Portal.d.ts.map +1 -1
  38. package/dist/Portal.js +414 -141
  39. package/dist/Portal.js.map +1 -1
  40. package/dist/ProposalHitl.d.ts +17 -4
  41. package/dist/ProposalHitl.d.ts.map +1 -1
  42. package/dist/ProposalHitl.js +30 -43
  43. package/dist/ProposalHitl.js.map +1 -1
  44. package/dist/RunHandler.d.ts +18 -8
  45. package/dist/RunHandler.d.ts.map +1 -1
  46. package/dist/RunHandler.js +214 -96
  47. package/dist/RunHandler.js.map +1 -1
  48. package/dist/ThreadAddress.d.ts +8 -0
  49. package/dist/ThreadAddress.d.ts.map +1 -0
  50. package/dist/ThreadAddress.js +18 -0
  51. package/dist/ThreadAddress.js.map +1 -0
  52. package/dist/Translator.d.ts +52 -2
  53. package/dist/Translator.d.ts.map +1 -1
  54. package/dist/Translator.js +390 -117
  55. package/dist/Translator.js.map +1 -1
  56. package/dist/client.d.ts +2 -0
  57. package/dist/client.d.ts.map +1 -0
  58. package/dist/client.js +2 -0
  59. package/dist/client.js.map +1 -0
  60. package/dist/config.d.ts +22 -2
  61. package/dist/config.d.ts.map +1 -1
  62. package/dist/config.js +65 -13
  63. package/dist/config.js.map +1 -1
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/run-events.d.ts +1 -0
  69. package/dist/run-events.d.ts.map +1 -1
  70. package/dist/run-events.js +4 -2
  71. package/dist/run-events.js.map +1 -1
  72. package/dist/types.d.ts +4 -22
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +13 -9
@@ -0,0 +1 @@
1
+ {"version":3,"file":"OAuthServer.d.ts","sourceRoot":"","sources":["../src/OAuthServer.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,EAAE,mBAAmB,EAAoB,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAEtG,OAAO,EAA2B,KAAK,qBAAqB,EAAE,MAAM,aAAa,CAAC;AA2ClF,MAAM,MAAM,SAAS,GACf;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GACjF;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAqE3F,MAAM,CAAC,OAAO,OAAO,WAAW;;IAU5B,YAAY,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,GAAc,EAAE,EAAE;QACvD,QAAQ,CAAC,WAAW,EAAE,mBAAmB,CAAC;QAC1C,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;QACrC,QAAQ,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,KAAK,MAAM,CAAC;QACnE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;KAC/B,EAmBA;IAGD,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,GAAG,MAAM,CAAwC;IAE/F,uCAAuC;IACvC,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgBvE;IAED,uCAAuC;IACvC,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ/E;IAGD,KAAK,CAAC,GAAG,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAkCpD;IAED,wGAAwG;IAClG,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,OAAO,CAAC,CAaxE;CA8WJ"}
@@ -0,0 +1,607 @@
1
+ // {§agui-oauth} — the daemon's OAuth 2.1 authorization server, the issuing face of the daemon's one
2
+ // credential authority ({§credential-authority}): discovery (RFC 8414, RFC 9728), the operator's login
3
+ // and authorization code with PKCE (RFC 6749 §4.1, RFC 7636), client credentials (§4.4), refresh with
4
+ // rotation, revocation (RFC 7009) and public-client registration (RFC 7591), all on the daemon's one
5
+ // listener. Bearer admission for a protected resource is here too (RFC 6750).
6
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
7
+ import { AuthorizationDocuments, renderAuthorizationDocument } from "./AuthorizationDocuments.js";
8
+ import { OPERATOR_CLIENT, SCOPES } from "./config.js";
9
+ import { httpProblem } from "./run-events.js";
10
+ // Mechanism, not choices ({§operator-config-only-home}); each is registered in the root's mechanism list.
11
+ const CODE_TTL_MS = 60_000;
12
+ const MAX_BODY_BYTES = 65_536;
13
+ const MAX_REGISTERED_CLIENTS = 1_000;
14
+ // {§agui-oauth-login} — the one local operator, the subject of every grant made for them whatever password
15
+ // signed them in. Their built-in client shares the spelling; a client id is not a subject.
16
+ const OPERATOR_SUBJECT = "operator";
17
+ // {§agui-oauth-session} — the browser session's cookie, scoped to the authorization server's paths.
18
+ const SESSION_COOKIE = "plurnk_session";
19
+ const BROWSER = "browser";
20
+ const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
21
+ const SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]+$/u;
22
+ // RFC 6749 §5.2 — an error code, its description and the status it is answered with.
23
+ class OAuthError extends Error {
24
+ code;
25
+ description;
26
+ status;
27
+ constructor(code, description, status = 400) {
28
+ super(description);
29
+ this.code = code;
30
+ this.description = description;
31
+ this.status = status;
32
+ }
33
+ }
34
+ const sha256 = (value) => createHash("sha256").update(value).digest();
35
+ // Constant-time equality over digests: lengths never leak, and a secret compares in fixed time.
36
+ const same = (a, b) => timingSafeEqual(sha256(a), sha256(b));
37
+ const mint = () => randomBytes(32).toString("base64url");
38
+ const readBody = (req) => new Promise((resolve, reject) => {
39
+ const chunks = [];
40
+ let size = 0;
41
+ req.on("data", (chunk) => {
42
+ size += chunk.length;
43
+ if (size > MAX_BODY_BYTES) {
44
+ reject(new OAuthError("invalid_request", "The request body is too large.", 413));
45
+ req.destroy();
46
+ return;
47
+ }
48
+ chunks.push(chunk);
49
+ });
50
+ req.once("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
51
+ req.once("error", reject);
52
+ });
53
+ const isForm = (req) => (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase() === "application/x-www-form-urlencoded";
54
+ const isJson = (req) => (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase() === "application/json";
55
+ const cookieValue = (req, name) => {
56
+ for (const part of (req.headers.cookie ?? "").split(";")) {
57
+ const separator = part.indexOf("=");
58
+ if (separator !== -1 && part.slice(0, separator).trim() === name)
59
+ return part.slice(separator + 1).trim();
60
+ }
61
+ return null;
62
+ };
63
+ const absoluteHttpUrl = (value) => {
64
+ if (typeof value !== "string")
65
+ return null;
66
+ try {
67
+ const url = new URL(value);
68
+ return (url.protocol === "http:" || url.protocol === "https:") && url.hash === "" ? url : null;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ };
74
+ // RFC 8252 §7.3 — a loopback redirect may change its port between registration and use.
75
+ const redirectMatches = (registered, presented) => {
76
+ if (registered === presented)
77
+ return true;
78
+ const a = absoluteHttpUrl(registered);
79
+ const b = absoluteHttpUrl(presented);
80
+ if (a === null || b === null)
81
+ return false;
82
+ const loopback = (url) => url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "localhost";
83
+ return loopback(a) && loopback(b) && a.protocol === b.protocol && a.hostname === b.hostname
84
+ && a.pathname === b.pathname && a.search === b.search;
85
+ };
86
+ export default class OAuthServer {
87
+ #credentials;
88
+ #opts;
89
+ #issuer;
90
+ #clients = new Map();
91
+ #codes = new Map();
92
+ // {§agui-oauth-login} — failures per source address within the configured window.
93
+ #now;
94
+ #registered = 0;
95
+ constructor({ credentials, opts, issuer, now = Date.now }) {
96
+ this.#credentials = credentials;
97
+ this.#opts = opts;
98
+ this.#issuer = issuer;
99
+ this.#now = now;
100
+ // {§agui-oauth-clients} — the operator's own client holds the authority's operator secret,
101
+ // configured or minted for this boot ({§credential-authority}), and every scope; an authority
102
+ // without one has no such client, and the operator signs in or declares clients instead.
103
+ const operatorSecret = credentials.operatorSecret;
104
+ if (operatorSecret !== null)
105
+ this.#clients.set(OPERATOR_CLIENT, { id: OPERATOR_CLIENT, kind: "declared", secret: operatorSecret, stylesheet: null, scopes: SCOPES, redirectUris: [], name: null });
106
+ for (const [id, declared] of Object.entries(opts.clients)) {
107
+ if (credentials.client(id) !== null)
108
+ throw new Error(`PLURNK_AGUI_CLIENT_${id}: '${id}' is the id of a module's own client; declare another id.`);
109
+ this.#clients.set(id, { id, kind: "declared", secret: declared.secret, stylesheet: null, scopes: declared.scopes, redirectUris: [], name: null });
110
+ }
111
+ // {§agui-oauth-session} — a code not yet exchanged ends with the family it would issue into, so
112
+ // a sign-in ended between authorization and exchange stays ended.
113
+ credentials.onRevoked((family) => {
114
+ for (const [code, pending] of this.#codes)
115
+ if (pending.family === family)
116
+ this.#codes.delete(code);
117
+ });
118
+ }
119
+ // {§http-request-origin} — every address this server advertises is answered for the request that asks.
120
+ resource(req) { return `${this.#issuer(req)}/agui`; }
121
+ /** {§agui-oauth-endpoints} RFC 8414 */
122
+ metadata(req) {
123
+ const issuer = this.#issuer(req);
124
+ return {
125
+ issuer,
126
+ authorization_endpoint: `${issuer}/oauth/authorize`,
127
+ token_endpoint: `${issuer}/oauth/token`,
128
+ revocation_endpoint: `${issuer}/oauth/revoke`,
129
+ registration_endpoint: `${issuer}/oauth/register`,
130
+ response_types_supported: ["code"],
131
+ response_modes_supported: ["query"],
132
+ grant_types_supported: ["authorization_code", "client_credentials", "refresh_token"],
133
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
134
+ revocation_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
135
+ code_challenge_methods_supported: ["S256"],
136
+ scopes_supported: [...SCOPES],
137
+ };
138
+ }
139
+ /** {§agui-oauth-endpoints} RFC 9728 */
140
+ resourceMetadata(req) {
141
+ return {
142
+ resource: this.resource(req),
143
+ authorization_servers: [this.#issuer(req)],
144
+ scopes_supported: [...SCOPES],
145
+ bearer_methods_supported: ["header"],
146
+ resource_name: "Plurnk AG-UI",
147
+ };
148
+ }
149
+ // {§agui-http-authorization} — bearer admission for the AG-UI resource (RFC 6750 §3).
150
+ admit(req, scope) {
151
+ const header = req.headers.authorization;
152
+ const challenge = (parameters) => `Bearer realm="plurnk"${parameters}, resource_metadata="${this.#issuer(req)}/.well-known/oauth-protected-resource"`;
153
+ const presented = header === undefined ? null : /^Bearer(?:\s+(.*))?$/iu.exec(header);
154
+ if (presented === null) {
155
+ return {
156
+ ok: false,
157
+ challenge: challenge(""),
158
+ problem: httpProblem("bearer-token-required", 401, "The request did not provide a bearer token.", {
159
+ stage: "authorization", recovery: "Present an access token issued by this daemon as `Authorization: Bearer <token>`.", retryable: false,
160
+ }),
161
+ };
162
+ }
163
+ const token = (presented[1] ?? "").trim();
164
+ const claims = this.#credentials.verify(token);
165
+ if (claims === null || claims.kind !== "access") {
166
+ return {
167
+ ok: false,
168
+ challenge: challenge(', error="invalid_token", error_description="The access token is unknown, expired or revoked."'),
169
+ problem: httpProblem("bearer-token-invalid", 401, "The access token is unknown, expired or revoked.", {
170
+ stage: "authorization", recovery: "Obtain a new access token from this daemon's token endpoint.", retryable: false,
171
+ }),
172
+ };
173
+ }
174
+ if (!claims.scopes.includes(scope)) {
175
+ return {
176
+ ok: false,
177
+ challenge: challenge(`, error="insufficient_scope", scope="${scope}"`),
178
+ problem: httpProblem("bearer-scope-insufficient", 403, `The access token does not carry the scope ${scope}.`, {
179
+ stage: "authorization", scope, recovery: `Obtain an access token with the scope ${scope}.`, retryable: false,
180
+ }),
181
+ };
182
+ }
183
+ return { ok: true, principal: claims.subject, family: claims.family };
184
+ }
185
+ /** Routes one request under `/oauth` or a well-known document; answers true when it was one of ours. */
186
+ async handle(req, res) {
187
+ const pathname = new URL(req.url ?? "/", "http://host").pathname;
188
+ const route = this.#routes()[pathname];
189
+ if (route === undefined)
190
+ return false;
191
+ try {
192
+ await route(req, res);
193
+ }
194
+ catch (cause) {
195
+ const error = cause instanceof OAuthError ? cause : new OAuthError("server_error", "The authorization server failed.", 500);
196
+ if (!(cause instanceof OAuthError))
197
+ console.error("OAuth request failed:", cause);
198
+ this.#json(res, error.status, { error: error.code, error_description: error.description }, error.code === "invalid_client" && /^Basic\s/iu.test(req.headers.authorization ?? "") ? { "www-authenticate": 'Basic realm="plurnk"' } : {});
199
+ }
200
+ return true;
201
+ }
202
+ #routes() {
203
+ return {
204
+ "/.well-known/oauth-authorization-server": async (req, res) => this.#document(req, res, this.metadata(req)),
205
+ "/.well-known/oauth-protected-resource": async (req, res) => this.#document(req, res, this.resourceMetadata(req)),
206
+ "/oauth/authorize": (req, res) => this.#authorize(req, res),
207
+ "/oauth/token": (req, res) => this.#token(req, res),
208
+ "/oauth/revoke": (req, res) => this.#revoke(req, res),
209
+ "/oauth/register": (req, res) => this.#register(req, res),
210
+ "/oauth/session": (req, res) => this.#sessionPage(req, res),
211
+ };
212
+ }
213
+ // ── session ───────────────────────────────────────────────────────────
214
+ // {§agui-oauth-session} — the browser's session is a credential of the authority, presented as a cookie.
215
+ #session(req) {
216
+ const token = cookieValue(req, SESSION_COOKIE);
217
+ if (token === null)
218
+ return null;
219
+ const claims = this.#credentials.verify(token);
220
+ return claims !== null && claims.kind === "session" ? claims : null;
221
+ }
222
+ #establishSession(req, res) {
223
+ const ttlMs = this.#opts.refreshTtlSec * 1000;
224
+ const family = randomUUID();
225
+ const issued = this.#credentials.issue({ kind: "session", subject: OPERATOR_SUBJECT, clientId: BROWSER, scopes: SCOPES, family, ttlMs });
226
+ res.setHeader("set-cookie", `${SESSION_COOKIE}=${issued.token}; Path=/oauth; Max-Age=${this.#opts.refreshTtlSec}; HttpOnly; SameSite=Lax${this.#issuer(req).startsWith("https:") ? "; Secure" : ""}`);
227
+ return { kind: "session", subject: OPERATOR_SUBJECT, clientId: BROWSER, scopes: SCOPES, family, expiresAt: issued.expiresAt };
228
+ }
229
+ #endSession(req, res) {
230
+ const session = this.#session(req);
231
+ if (session !== null)
232
+ this.#credentials.revokeFamily(session.family);
233
+ res.setHeader("set-cookie", `${SESSION_COOKIE}=; Path=/oauth; Max-Age=0; HttpOnly; SameSite=Lax`);
234
+ }
235
+ // {§agui-oauth-clients} {§credential-module-client} — a module's own client is the authority's to know; every other client is
236
+ // this server's. A module client naming a scope this server does not issue is a fault of the module.
237
+ // Its redirect addresses are its registered paths on the issuer, formed when asked.
238
+ #client(id, req) {
239
+ const registered = this.#credentials.client(id);
240
+ if (registered === null)
241
+ return this.#clients.get(id);
242
+ const foreign = registered.scopes.find((scope) => !SCOPES.includes(scope));
243
+ if (foreign !== undefined)
244
+ throw new Error(`the module client '${id}' registered the scope '${foreign}', which this authorization server does not issue`);
245
+ const issuer = this.#issuer(req);
246
+ return { id, kind: "module", secret: null, stylesheet: registered.stylesheetPath ?? null, scopes: registered.scopes, redirectUris: registered.redirectPaths.map((path) => `${issuer}${path}`), name: null };
247
+ }
248
+ // {§agui-oauth-endpoints} — a module's client is answered only at its path on the issuer; any other
249
+ // client at a registered address, a loopback one on any port (RFC 8252 §7.3).
250
+ static #redirects(client, presented) {
251
+ return client.kind === "module"
252
+ ? client.redirectUris.includes(presented)
253
+ : client.redirectUris.some((registered) => redirectMatches(registered, presented));
254
+ }
255
+ async #sessionPage(req, res) {
256
+ if (req.method === "POST") {
257
+ const form = await this.#form(req);
258
+ if (form.get("action") !== "end")
259
+ throw new OAuthError("invalid_request", "POST action=end to sign this browser out.");
260
+ this.#endSession(req, res);
261
+ this.#html(res, 200, AuthorizationDocuments.signedOut(), this.#stylesheet(form.get("client_id")));
262
+ return;
263
+ }
264
+ if (req.method !== "GET")
265
+ throw new OAuthError("invalid_request", "Use GET, or POST a form.", 405);
266
+ const clientId = new URL(req.url ?? "/", "http://host").searchParams.get("client_id");
267
+ const stylesheet = this.#stylesheet(clientId);
268
+ this.#html(res, 200, this.#session(req) === null
269
+ ? AuthorizationDocuments.notSignedIn()
270
+ : AuthorizationDocuments.signedIn(stylesheet === null ? {} : { client_id: clientId }), stylesheet);
271
+ }
272
+ // {§agui-oauth-documents} — a stylesheet is a module client's registration, which the request can
273
+ // only name, never supply.
274
+ #stylesheet(clientId) {
275
+ return clientId === null ? null : this.#credentials.client(clientId)?.stylesheetPath ?? null;
276
+ }
277
+ async #document(req, res, body) {
278
+ if (req.method !== "GET" && req.method !== "HEAD")
279
+ throw new OAuthError("invalid_request", "Use GET.", 405);
280
+ this.#json(res, 200, body);
281
+ }
282
+ #json(res, status, body, headers = {}) {
283
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", pragma: "no-cache", ...headers });
284
+ res.end(JSON.stringify(body));
285
+ }
286
+ #html(res, status, document, stylesheet, headers = {}) {
287
+ const { html, policy } = renderAuthorizationDocument(document, stylesheet);
288
+ res.writeHead(status, {
289
+ "content-type": "text/html; charset=utf-8",
290
+ "cache-control": "no-store",
291
+ "content-security-policy": policy,
292
+ "x-frame-options": "DENY",
293
+ ...headers,
294
+ });
295
+ res.end(html);
296
+ }
297
+ // ── authorization ───────────────────────────────────────────────────────────────────────────
298
+ async #authorize(req, res) {
299
+ let params;
300
+ if (req.method === "GET")
301
+ params = new URL(req.url ?? "/", "http://host").searchParams;
302
+ else if (req.method === "POST" && isForm(req))
303
+ params = new URLSearchParams(await readBody(req));
304
+ else
305
+ throw new OAuthError("invalid_request", "Use GET, or POST a form.", 405);
306
+ // RFC 6749 §4.1.2.1 — a bad client or redirect is told to the person, never redirected to.
307
+ const client = this.#client(params.get("client_id") ?? "", req);
308
+ const redirectUri = params.get("redirect_uri");
309
+ if (client === undefined || redirectUri === null || !OAuthServer.#redirects(client, redirectUri)) {
310
+ this.#html(res, 400, AuthorizationDocuments.refused(), null);
311
+ return;
312
+ }
313
+ const state = params.get("state");
314
+ const fail = (code, description) => {
315
+ const target = new URL(redirectUri);
316
+ target.searchParams.set("error", code);
317
+ target.searchParams.set("error_description", description);
318
+ if (state !== null)
319
+ target.searchParams.set("state", state);
320
+ res.writeHead(302, { location: target.toString(), "cache-control": "no-store" });
321
+ res.end();
322
+ };
323
+ if (params.get("response_type") !== "code") {
324
+ fail("unsupported_response_type", "Only response_type=code is supported.");
325
+ return;
326
+ }
327
+ const challenge = params.get("code_challenge") ?? "";
328
+ if (!PKCE_VERIFIER.test(challenge) || (params.get("code_challenge_method") ?? "") !== "S256") {
329
+ fail("invalid_request", "A code_challenge with code_challenge_method=S256 is required.");
330
+ return;
331
+ }
332
+ const scopes = this.#scopes(params.get("scope"), client);
333
+ if (scopes === null) {
334
+ fail("invalid_scope", `The scope must be from ${SCOPES.join(" ")} and within the client's.`);
335
+ return;
336
+ }
337
+ const resource = params.get("resource");
338
+ if (resource !== null && resource !== this.resource(req)) {
339
+ fail("invalid_target", `The resource must be ${this.resource(req)}.`);
340
+ return;
341
+ }
342
+ // {§agui-oauth-session} — a module's own client is authorized by the browser's sign-in and by
343
+ // nothing else, so its grant joins that sign-in's family: ending either ends both. Any other
344
+ // client's grant is a standing of its own, in a fresh family.
345
+ const issue = (session) => {
346
+ const code = mint();
347
+ this.#sweepCodes();
348
+ const family = client.kind === "module" ? session.family : randomUUID();
349
+ this.#codes.set(code, { clientId: client.id, redirectUri, challenge, scopes, family, expiresAt: this.#now() + CODE_TTL_MS });
350
+ const target = new URL(redirectUri);
351
+ target.searchParams.set("code", code);
352
+ if (state !== null)
353
+ target.searchParams.set("state", state);
354
+ res.writeHead(302, { location: target.toString(), "cache-control": "no-store" });
355
+ res.end();
356
+ };
357
+ const request = {
358
+ client: client.name ?? client.id,
359
+ scopes,
360
+ hidden: Object.fromEntries(["client_id", "redirect_uri", "response_type", "code_challenge", "code_challenge_method", "scope", "state", "resource"]
361
+ .filter((name) => params.has(name)).map((name) => [name, params.get(name)])),
362
+ };
363
+ // {§agui-oauth-session} — a browser is the operator only once it has signed in. A consent
364
+ // decision counts only with the session the request carries, which a cross-site POST cannot.
365
+ const session = this.#session(req);
366
+ if (session !== null) {
367
+ // A module of this daemon registered this client: the operator's session authorizes it.
368
+ if (client.kind === "module") {
369
+ issue(session);
370
+ return;
371
+ }
372
+ const decision = req.method === "POST" ? params.get("consent") : null;
373
+ if (decision === "allow") {
374
+ issue(session);
375
+ return;
376
+ }
377
+ if (decision === "deny") {
378
+ fail("access_denied", "The operator declined.");
379
+ return;
380
+ }
381
+ this.#html(res, 200, AuthorizationDocuments.consent(request), client.stylesheet);
382
+ return;
383
+ }
384
+ // {§agui-oauth-login} — the operator signs in here. Without a password no browser can: the page
385
+ // says the password is empty and where to set it, and issues nothing.
386
+ if (this.#opts.password.length === 0) {
387
+ this.#html(res, 503, AuthorizationDocuments.noPassword(), client.stylesheet);
388
+ return;
389
+ }
390
+ const password = params.get("password");
391
+ if (req.method === "GET" || password === null) {
392
+ this.#html(res, 200, AuthorizationDocuments.signIn(request, null), client.stylesheet);
393
+ return;
394
+ }
395
+ if (!same(password, this.#opts.password)) {
396
+ this.#html(res, 401, AuthorizationDocuments.signIn(request, "The password is wrong."), client.stylesheet);
397
+ return;
398
+ }
399
+ // A sign-in is the operator's own act, so it issues at once, and the browser keeps the session.
400
+ issue(this.#establishSession(req, res));
401
+ }
402
+ #scopes(requested, client) {
403
+ if (requested === null || requested.trim().length === 0)
404
+ return client.scopes;
405
+ const scopes = [...new Set(requested.trim().split(/\s+/u))];
406
+ if (scopes.some((scope) => !SCOPE_TOKEN.test(scope) || !client.scopes.includes(scope)))
407
+ return null;
408
+ return Object.freeze(scopes);
409
+ }
410
+ #sweepCodes() {
411
+ const now = this.#now();
412
+ for (const [code, issued] of this.#codes)
413
+ if (issued.expiresAt <= now)
414
+ this.#codes.delete(code);
415
+ }
416
+ // ── token ───────────────────────────────────────────────────────────────────────────────────
417
+ async #form(req) {
418
+ if (req.method !== "POST")
419
+ throw new OAuthError("invalid_request", "Use POST.", 405);
420
+ if (!isForm(req))
421
+ throw new OAuthError("invalid_request", "POST an application/x-www-form-urlencoded body.");
422
+ return new URLSearchParams(await readBody(req));
423
+ }
424
+ // RFC 6749 §2.3.1 — client_secret_basic or client_secret_post; a public client by its id alone.
425
+ #authenticateClient(req, form) {
426
+ const header = req.headers.authorization;
427
+ let id = null;
428
+ let secret = null;
429
+ if (header !== undefined && /^Basic\s/iu.test(header)) {
430
+ const decoded = Buffer.from(header.replace(/^Basic\s+/iu, ""), "base64").toString("utf8");
431
+ const colon = decoded.indexOf(":");
432
+ if (colon < 0)
433
+ throw new OAuthError("invalid_client", "The Basic credentials are malformed.", 401);
434
+ try {
435
+ id = decodeURIComponent(decoded.slice(0, colon));
436
+ secret = decodeURIComponent(decoded.slice(colon + 1));
437
+ }
438
+ catch {
439
+ throw new OAuthError("invalid_client", "The Basic credentials are malformed.", 401);
440
+ }
441
+ if (form.has("client_secret"))
442
+ throw new OAuthError("invalid_request", "Authenticate by one method only.");
443
+ }
444
+ else {
445
+ id = form.get("client_id");
446
+ secret = form.get("client_secret");
447
+ }
448
+ if (id === null || id.length === 0)
449
+ throw new OAuthError("invalid_client", "The client did not identify itself.", 401);
450
+ const client = this.#client(id, req);
451
+ if (client === undefined)
452
+ throw new OAuthError("invalid_client", "The client is not registered with this daemon.", 401);
453
+ if (client.kind === "public") {
454
+ if (secret !== null && secret.length > 0)
455
+ throw new OAuthError("invalid_client", "A public client presents no secret.", 401);
456
+ return client;
457
+ }
458
+ const matches = secret !== null && (client.kind === "module"
459
+ ? this.#credentials.authenticateClient(client.id, secret)
460
+ : same(secret, client.secret ?? ""));
461
+ if (!matches)
462
+ throw new OAuthError("invalid_client", "The client secret is wrong.", 401);
463
+ return client;
464
+ }
465
+ async #token(req, res) {
466
+ const form = await this.#form(req);
467
+ const client = this.#authenticateClient(req, form);
468
+ const grant = form.get("grant_type");
469
+ let issued;
470
+ if (grant === "authorization_code") {
471
+ const code = this.#codes.get(form.get("code") ?? "");
472
+ this.#codes.delete(form.get("code") ?? "");
473
+ if (code === undefined || code.expiresAt <= this.#now()) {
474
+ throw new OAuthError("invalid_grant", "The authorization code is unknown, used or expired.");
475
+ }
476
+ if (code.clientId !== client.id) {
477
+ // RFC 6749 §4.1.2 — a code presented by another client ends everything it stood for.
478
+ this.#credentials.revokeFamily(code.family);
479
+ throw new OAuthError("invalid_grant", "The authorization code was issued to another client.");
480
+ }
481
+ const verifier = form.get("code_verifier") ?? "";
482
+ if (!PKCE_VERIFIER.test(verifier) || !same(createHash("sha256").update(verifier).digest("base64url"), code.challenge)) {
483
+ throw new OAuthError("invalid_grant", "The code_verifier does not match the code_challenge.");
484
+ }
485
+ if (form.get("redirect_uri") !== code.redirectUri)
486
+ throw new OAuthError("invalid_grant", "The redirect_uri differs from the authorization request's.");
487
+ issued = { claims: { subject: OPERATOR_SUBJECT, clientId: client.id, scopes: code.scopes, family: code.family }, refresh: true };
488
+ }
489
+ else if (grant === "refresh_token") {
490
+ const presented = form.get("refresh_token") ?? "";
491
+ // Read before consuming: a refused scope leaves the token usable; a reuse is told as such.
492
+ const claims = this.#credentials.verify(presented);
493
+ if (claims === null || claims.kind !== "refresh") {
494
+ const outcome = this.#credentials.consume(presented);
495
+ throw new OAuthError("invalid_grant", "reusedFamily" in outcome && outcome.reusedFamily !== null
496
+ ? "The refresh token was already used; its grant is revoked."
497
+ : "The refresh token is unknown, expired or revoked.");
498
+ }
499
+ if (claims.clientId !== client.id) {
500
+ this.#credentials.revokeFamily(claims.family);
501
+ throw new OAuthError("invalid_grant", "The refresh token was issued to another client.");
502
+ }
503
+ const requested = form.get("scope");
504
+ const scopes = requested === null ? claims.scopes : this.#scopes(requested, { ...client, scopes: claims.scopes });
505
+ if (scopes === null)
506
+ throw new OAuthError("invalid_scope", "A refreshed scope is within the grant's.");
507
+ if (!("claims" in this.#credentials.consume(presented)))
508
+ throw new OAuthError("invalid_grant", "The refresh token is unknown, expired or revoked.");
509
+ issued = { claims: { subject: claims.subject, clientId: client.id, scopes, family: claims.family }, refresh: true };
510
+ }
511
+ else if (grant === "client_credentials") {
512
+ if (client.kind === "public")
513
+ throw new OAuthError("unauthorized_client", "Only a confidential client may use client_credentials.");
514
+ // {§agui-oauth-clients} — a module's client stands for the operator who authorized it, never for itself.
515
+ if (client.kind === "module")
516
+ throw new OAuthError("unauthorized_client", "A module's client obtains tokens through the operator's authorization.");
517
+ const scopes = this.#scopes(form.get("scope"), client);
518
+ if (scopes === null)
519
+ throw new OAuthError("invalid_scope", `The scope must be from ${SCOPES.join(" ")} and within the client's.`);
520
+ issued = { claims: { subject: client.id === OPERATOR_CLIENT ? OPERATOR_SUBJECT : client.id, clientId: client.id, scopes, family: randomUUID() }, refresh: false };
521
+ }
522
+ else {
523
+ throw new OAuthError("unsupported_grant_type", "The grant type is not authorization_code, refresh_token or client_credentials.");
524
+ }
525
+ const resource = form.get("resource");
526
+ if (resource !== null && resource !== this.resource(req))
527
+ throw new OAuthError("invalid_target", `The resource must be ${this.resource(req)}.`);
528
+ const access = this.#credentials.issue({ kind: "access", ...issued.claims, ttlMs: this.#opts.accessTtlSec * 1000 });
529
+ const body = {
530
+ access_token: access.token,
531
+ token_type: "Bearer",
532
+ expires_in: this.#opts.accessTtlSec,
533
+ scope: issued.claims.scopes.join(" "),
534
+ };
535
+ if (issued.refresh) {
536
+ body.refresh_token = this.#credentials.issue({ kind: "refresh", ...issued.claims, ttlMs: this.#opts.refreshTtlSec * 1000 }).token;
537
+ }
538
+ this.#json(res, 200, body);
539
+ }
540
+ // ── revocation (RFC 7009) ───────────────────────────────────────────────────────────────────
541
+ async #revoke(req, res) {
542
+ const form = await this.#form(req);
543
+ const client = this.#authenticateClient(req, form);
544
+ const token = form.get("token");
545
+ if (token === null)
546
+ throw new OAuthError("invalid_request", "A token is required.");
547
+ const claims = this.#credentials.verify(token);
548
+ // §2.2 — a token that is not the client's, or not live, revokes nothing and still answers 200.
549
+ if (claims !== null && claims.clientId === client.id)
550
+ this.#credentials.revokeFamily(claims.family);
551
+ res.writeHead(200, { "cache-control": "no-store" });
552
+ res.end();
553
+ }
554
+ // ── registration (RFC 7591) ─────────────────────────────────────────────────────────────────
555
+ async #register(req, res) {
556
+ if (req.method !== "POST")
557
+ throw new OAuthError("invalid_request", "Use POST.", 405);
558
+ if (!isJson(req))
559
+ throw new OAuthError("invalid_client_metadata", "POST an application/json body.");
560
+ let metadata;
561
+ try {
562
+ const parsed = JSON.parse(await readBody(req));
563
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
564
+ throw new Error("not an object");
565
+ metadata = parsed;
566
+ }
567
+ catch {
568
+ throw new OAuthError("invalid_client_metadata", "The body is not a JSON object.");
569
+ }
570
+ const redirectUris = metadata.redirect_uris;
571
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0 || redirectUris.some((uri) => absoluteHttpUrl(uri) === null)) {
572
+ throw new OAuthError("invalid_redirect_uri", "redirect_uris must list absolute http(s) URLs without fragments.");
573
+ }
574
+ const method = metadata.token_endpoint_auth_method ?? "none";
575
+ if (method !== "none") {
576
+ throw new OAuthError("invalid_client_metadata", "Only public clients register here; a confidential client is declared in the operator's configuration.");
577
+ }
578
+ const grantTypes = metadata.grant_types ?? ["authorization_code", "refresh_token"];
579
+ if (!Array.isArray(grantTypes) || grantTypes.some((type) => type !== "authorization_code" && type !== "refresh_token")) {
580
+ throw new OAuthError("invalid_client_metadata", "grant_types may name authorization_code and refresh_token.");
581
+ }
582
+ const responseTypes = metadata.response_types ?? ["code"];
583
+ if (!Array.isArray(responseTypes) || responseTypes.some((type) => type !== "code")) {
584
+ throw new OAuthError("invalid_client_metadata", "response_types may name code.");
585
+ }
586
+ const scopes = typeof metadata.scope === "string" ? this.#scopes(metadata.scope, { id: "", kind: "public", secret: null, stylesheet: null, scopes: SCOPES, redirectUris: [], name: null }) : SCOPES;
587
+ if (scopes === null)
588
+ throw new OAuthError("invalid_client_metadata", `scope must be from ${SCOPES.join(" ")}.`);
589
+ const name = typeof metadata.client_name === "string" && metadata.client_name.length > 0 ? metadata.client_name : null;
590
+ if (this.#registered >= MAX_REGISTERED_CLIENTS)
591
+ throw new OAuthError("invalid_client_metadata", "This daemon holds no more registrations.", 503);
592
+ const id = mint();
593
+ this.#registered += 1;
594
+ this.#clients.set(id, { id, kind: "public", secret: null, stylesheet: null, scopes, redirectUris: Object.freeze(redirectUris), name });
595
+ this.#json(res, 201, {
596
+ client_id: id,
597
+ client_id_issued_at: Math.floor(this.#now() / 1000),
598
+ redirect_uris: redirectUris,
599
+ token_endpoint_auth_method: "none",
600
+ grant_types: grantTypes,
601
+ response_types: responseTypes,
602
+ scope: scopes.join(" "),
603
+ ...(name === null ? {} : { client_name: name }),
604
+ });
605
+ }
606
+ }
607
+ //# sourceMappingURL=OAuthServer.js.map