@agentlogin/issuer 0.0.0-stage → 0.2.0-beta.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 (58) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +237 -2
  3. package/dist/agents.d.ts +40 -0
  4. package/dist/agents.js +257 -0
  5. package/dist/agents.js.map +1 -0
  6. package/dist/app.d.ts +3 -0
  7. package/dist/app.js +652 -0
  8. package/dist/app.js.map +1 -0
  9. package/dist/clients.d.ts +20 -0
  10. package/dist/clients.js +272 -0
  11. package/dist/clients.js.map +1 -0
  12. package/dist/constants.d.ts +30 -0
  13. package/dist/constants.js +32 -0
  14. package/dist/constants.js.map +1 -0
  15. package/dist/context.d.ts +37 -0
  16. package/dist/context.js +2 -0
  17. package/dist/context.js.map +1 -0
  18. package/dist/errors.d.ts +10 -0
  19. package/dist/errors.js +17 -0
  20. package/dist/errors.js.map +1 -0
  21. package/dist/index.d.ts +45 -0
  22. package/dist/index.js +107 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/owner-address.d.ts +2 -0
  25. package/dist/owner-address.js +24 -0
  26. package/dist/owner-address.js.map +1 -0
  27. package/dist/safefetch.d.ts +38 -0
  28. package/dist/safefetch.js +164 -0
  29. package/dist/safefetch.js.map +1 -0
  30. package/dist/server.d.ts +1 -0
  31. package/dist/server.js +89 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/signing-configuration.d.ts +14 -0
  34. package/dist/signing-configuration.js +35 -0
  35. package/dist/signing-configuration.js.map +1 -0
  36. package/dist/signing-state.d.ts +22 -0
  37. package/dist/signing-state.js +14 -0
  38. package/dist/signing-state.js.map +1 -0
  39. package/dist/sqlite-store.d.ts +24 -0
  40. package/dist/sqlite-store.js +81 -0
  41. package/dist/sqlite-store.js.map +1 -0
  42. package/dist/state.d.ts +30 -0
  43. package/dist/state.js +77 -0
  44. package/dist/state.js.map +1 -0
  45. package/dist/store.d.ts +693 -0
  46. package/dist/store.js +883 -0
  47. package/dist/store.js.map +1 -0
  48. package/dist/tokens.d.ts +37 -0
  49. package/dist/tokens.js +167 -0
  50. package/dist/tokens.js.map +1 -0
  51. package/dist/transactions.d.ts +90 -0
  52. package/dist/transactions.js +449 -0
  53. package/dist/transactions.js.map +1 -0
  54. package/dist/util.d.ts +29 -0
  55. package/dist/util.js +113 -0
  56. package/dist/util.js.map +1 -0
  57. package/package.json +37 -4
  58. package/static/wait.js +42 -0
package/dist/app.js ADDED
@@ -0,0 +1,652 @@
1
+ // HTTP surface of the issuer. Section numbers refer to Agent Login Core 1.0 draft 0.1.
2
+ import { ownerAddressKey } from './owner-address.js';
3
+ import { Hono } from 'hono';
4
+ import { cors } from 'hono/cors';
5
+ import { getCookie, setCookie } from 'hono/cookie';
6
+ import { readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { AGENT_KEY_ALGS, OWNER_SCOPES, SCOPES, VERSION } from './constants.js';
9
+ import { OAuthError } from './errors.js';
10
+ import { redirectUriMatches, registerClient, resolveClient } from './clients.js';
11
+ import { isTransactionTokenError } from './store.js';
12
+ import * as tx from './transactions.js';
13
+ import { authenticateClient, exchangeCode, userinfo } from './tokens.js';
14
+ import * as agents from './agents.js';
15
+ import { domainServed, isPlainObject, parseBearer, parseHandle, prefersJson, randomId, randomSecret, safeEqual, sha256, acctUri, parseAcctUri } from './util.js';
16
+ const WAIT_JS = readFileSync(fileURLToPath(new URL('../static/wait.js', import.meta.url)), 'utf8');
17
+ const NO_STORE = { 'Cache-Control': 'no-store', Pragma: 'no-cache' };
18
+ /** Section 5.3: only the Issuer's own scripts, never framed. */
19
+ const HTML_HEADERS = {
20
+ 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; img-src https:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
21
+ 'X-Content-Type-Options': 'nosniff',
22
+ 'Referrer-Policy': 'no-referrer',
23
+ };
24
+ /** Lifetime of the one-time code of the Owner email sign-in, seconds. */
25
+ const OWNER_CODE_TTL = 600;
26
+ const escapeHtml = (s) => s.replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
27
+ /** Section 5.3: JSON embedded in HTML with <, >, &, U+2028 and U+2029 escaped. */
28
+ const embedJson = (v) => JSON.stringify(v).replace(/[<>&\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
29
+ export function createIssuerApp(ctx) {
30
+ const app = new Hono();
31
+ const clientCtx = { store: ctx.store, constants: ctx.constants, allowInsecureClients: ctx.allowInsecureClients };
32
+ const base = ctx.issuer;
33
+ const secureCookies = base.startsWith('https:');
34
+ // Section 5: every response of Sections 5 to 7, redirects and CORS preflights included, carries Cache-Control: no-store.
35
+ const noStore = async (c, next) => {
36
+ await next();
37
+ if (!c.res.headers.has('Cache-Control'))
38
+ c.res.headers.set('Cache-Control', 'no-store');
39
+ };
40
+ for (const path of ['/authorize', '/transactions/*', '/token', '/userinfo', '/register', '/agents/*', '/owner/*'])
41
+ app.use(path, noStore);
42
+ // Section 6: CORS for browser-based Agent Clients, never with Access-Control-Allow-Credentials.
43
+ app.use('/transactions/*', cors({ origin: '*', allowHeaders: ['Authorization', 'Content-Type', 'Accept'], allowMethods: ['GET', 'POST', 'OPTIONS'] }));
44
+ app.use('/agents/*', cors({ origin: '*', allowHeaders: ['Authorization', 'Content-Type', 'Accept'], allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'] }));
45
+ app.onError((err, c) => {
46
+ if (err instanceof OAuthError) {
47
+ const body = { error: err.error };
48
+ if (err.description)
49
+ body.error_description = err.description;
50
+ const extra = c.get('txError');
51
+ return c.json({ ...body, ...extra }, err.status, { ...NO_STORE, ...err.headers });
52
+ }
53
+ // Database errors can include complete failing rows in message/detail/cause. Never log that payload.
54
+ const code = 'code' in err && typeof err.code === 'string' && /^[0-9A-Z]{5}$/.test(err.code) ? err.code : undefined;
55
+ console.error('Issuer request failed', { category: 'internal_error', sqlState: code });
56
+ return c.json({ error: 'server_error' }, 500, NO_STORE);
57
+ });
58
+ // ---- Section 2.4, 7.7: discovery and keys -----------------------------------------------------
59
+ app.get('/.well-known/openid-configuration', (c) => c.json({
60
+ issuer: base,
61
+ authorization_endpoint: `${base}/authorize`,
62
+ token_endpoint: `${base}/token`,
63
+ userinfo_endpoint: `${base}/userinfo`,
64
+ jwks_uri: `${base}/.well-known/jwks.json`,
65
+ registration_endpoint: `${base}/register`,
66
+ response_types_supported: ['code'],
67
+ response_modes_supported: ['query'],
68
+ grant_types_supported: ['authorization_code'],
69
+ code_challenge_methods_supported: ['S256'],
70
+ id_token_signing_alg_values_supported: ['ES256'],
71
+ agent_key_algs_supported: [...AGENT_KEY_ALGS],
72
+ subject_types_supported: ['public'],
73
+ authorization_response_iss_parameter_supported: true,
74
+ client_id_metadata_document_supported: true,
75
+ request_parameter_supported: false,
76
+ request_uri_parameter_supported: false,
77
+ scopes_supported: [...SCOPES],
78
+ claims_supported: ['iss', 'sub', 'aud', 'exp', 'iat', 'jti', 'nonce', 'actor_type', 'scope', 'email', 'email_verified', 'name', 'preferred_username', 'owner_sub', 'owner_name', 'owner_email', 'owner_email_verified', 'owner_verified_via'],
79
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
80
+ transaction_endpoint: `${base}/transactions`,
81
+ agents_endpoint: `${base}/agents`,
82
+ handle_domains: ctx.handleDomains,
83
+ agent_login_versions_supported: [VERSION],
84
+ op_policy_uri: ctx.policyUri,
85
+ service_documentation: 'https://github.com/agentlogin/spec',
86
+ }, 200, { 'Cache-Control': 'public, max-age=3600' }));
87
+ app.get('/.well-known/jwks.json', async (c) => c.json({ keys: (await ctx.store.listSigningKeys()).map((key) => ({ ...key.publicJwk, kid: key.kid, use: 'sig', alg: 'ES256' })) }, 200, { 'Cache-Control': `public, max-age=${ctx.jwksCacheSeconds}` }));
88
+ app.get('/policy', (c) => c.json({
89
+ issuer: base,
90
+ owner_deletion_quota_retention_seconds: ctx.ownerDeletionRetentionSeconds,
91
+ description: 'Completed sign-ups remain counted while an Owner exists. After deletion, this fixed retention period links a verified address to its counts only; identity, credentials and consent are never inherited. Zero ends inheritance immediately. Each deletion keeps the period in force when it occurred.',
92
+ }, 200, { 'Cache-Control': 'no-store' }));
93
+ // Section 2.2: WebFinger for handles in the served domains (per-handle lookups only).
94
+ app.get('/.well-known/webfinger', async (c) => {
95
+ // The query value is decoded once by the framework; the `acct:` URI inside it carries its own percent-encoding
96
+ // of the userpart (RFC 7565 Section 7), decoded by parseAcctUri.
97
+ const h = parseAcctUri(c.req.query('resource') ?? '');
98
+ if (!h || !domainServed(h.domain, ctx.handleDomains) || !(await ctx.store.getAgent(h.handle)))
99
+ return c.body(null, 404);
100
+ const rel = c.req.queries('rel') ?? [];
101
+ const links = [{ rel: 'http://openid.net/specs/connect/1.0/issuer', href: base }].filter((l) => rel.length === 0 || rel.includes(l.rel));
102
+ return c.json({ subject: acctUri(h.handle), links }, 200, { 'Content-Type': 'application/jrd+json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600' });
103
+ });
104
+ // ---- Section 5: Authentication Request ---------------------------------------------------------
105
+ const authorize = async (c) => {
106
+ const q = { ...c.req.queries() };
107
+ if (c.req.method === 'POST') {
108
+ const form = await c.req.parseBody({ all: true });
109
+ for (const [k, v] of Object.entries(form))
110
+ q[k] = [...(q[k] ?? []), ...(Array.isArray(v) ? v.map(String) : [String(v)])];
111
+ }
112
+ const json = prefersJson(c.req.header('accept'));
113
+ const one = (k) => {
114
+ const v = q[k];
115
+ if (!v)
116
+ return undefined;
117
+ if (v.length > 1)
118
+ throw new OAuthError(400, 'invalid_request', `repeated parameter ${k}`);
119
+ return v[0];
120
+ };
121
+ // Section 5.2 steps 1 and 2: a failure here is answered 400 directly, never by redirect; as HTML unless JSON is preferred.
122
+ const direct = (text) => c.html(page('Sign-in request rejected', `<p>${escapeHtml(text)}</p>`), 400, { ...NO_STORE, ...HTML_HEADERS });
123
+ let client;
124
+ let redirectUri;
125
+ try {
126
+ const clientId = one('client_id');
127
+ redirectUri = one('redirect_uri') ?? '';
128
+ if (!clientId || !redirectUri)
129
+ throw new OAuthError(400, 'invalid_request', 'client_id and redirect_uri are required');
130
+ client = await resolveClient(clientCtx, clientId);
131
+ }
132
+ catch (e) {
133
+ // Step 1: every way the client fails to resolve, an Open Client document the Issuer refuses included (Sections 3.2, 4.2), is `invalid_request`.
134
+ if (!(e instanceof OAuthError) || e.status !== 400)
135
+ throw e;
136
+ if (!json)
137
+ return direct('The application could not be identified.');
138
+ throw new OAuthError(400, 'invalid_request', e.description);
139
+ }
140
+ if (!redirectUriMatches(client, redirectUri)) {
141
+ if (json)
142
+ throw new OAuthError(400, 'invalid_request', 'redirect_uri does not match');
143
+ return direct('The application sent a redirect address that it did not register.');
144
+ }
145
+ const redirectError = (error, description, state) => {
146
+ const u = new URL(redirectUri);
147
+ u.searchParams.set('error', error);
148
+ u.searchParams.set('error_description', description);
149
+ if (state)
150
+ u.searchParams.set('state', state);
151
+ u.searchParams.set('iss', base);
152
+ if (json)
153
+ return c.json({ error, error_description: description, redirect: u.href }, 400, NO_STORE);
154
+ c.header('Cache-Control', 'no-store');
155
+ return c.redirect(u.href, 303);
156
+ };
157
+ let state;
158
+ try {
159
+ state = one('state');
160
+ // Section 5.2 step 3 applies to every parameter, including unknown extensions and query/body duplicates.
161
+ // Validate after the client and redirect URI so this error uses the validated redirect target.
162
+ for (const k of Object.keys(q))
163
+ one(k);
164
+ const nonce = one('nonce');
165
+ const codeChallenge = one('code_challenge');
166
+ const method = one('code_challenge_method');
167
+ const responseType = one('response_type');
168
+ const scopeRaw = one('scope') ?? '';
169
+ const prompt = one('prompt');
170
+ const loginHintRaw = one('login_hint');
171
+ const responseMode = one('response_mode');
172
+ // RFC 6749 Section 3.1: an optional parameter without a value is omitted, not the numeric value zero.
173
+ const maxAgeRaw = one('max_age') || undefined;
174
+ if (maxAgeRaw !== undefined && !/^\d+$/.test(maxAgeRaw)) {
175
+ return redirectError('invalid_request', 'max_age must be non-negative integer seconds', state);
176
+ }
177
+ // Decimal integers have no protocol upper bound. Saturation preserves their effect over our safe timestamps.
178
+ const maxAge = maxAgeRaw === undefined ? undefined : Math.min(Number(maxAgeRaw), Number.MAX_SAFE_INTEGER);
179
+ for (const k of ['claims', 'display', 'ui_locales', 'id_token_hint', 'acr_values'])
180
+ one(k); // ignored, but not repeatable
181
+ if (one('request') !== undefined)
182
+ return redirectError('request_not_supported', 'request objects are not supported', state);
183
+ if (one('request_uri') !== undefined)
184
+ return redirectError('request_uri_not_supported', 'request_uri is not supported', state);
185
+ if (responseMode !== undefined && responseMode !== 'query')
186
+ return redirectError('invalid_request', 'response_mode must be query', state);
187
+ if (responseType !== 'code')
188
+ return redirectError('unsupported_response_type', 'response_type must be code', state);
189
+ if (!state || !nonce)
190
+ return redirectError('invalid_request', 'state and nonce are required', state);
191
+ if (!codeChallenge || method !== 'S256' || !/^[A-Za-z0-9\-._~]{43,128}$/.test(codeChallenge))
192
+ return redirectError('invalid_request', 'PKCE S256 is required', state);
193
+ const scopes = tx.scopeList(scopeRaw);
194
+ if (!scopes.includes('openid'))
195
+ return redirectError('invalid_scope', 'openid is required', state);
196
+ if (client.type === 'open' && scopes.some((s) => OWNER_SCOPES.includes(s)))
197
+ return redirectError('invalid_scope', 'owner scopes require a registered client', state);
198
+ if (scopes.includes('owner_email') && (!client.tosUri || !client.policyUri))
199
+ return redirectError('invalid_request', 'a client requesting owner_email must publish tos_uri and policy_uri', state);
200
+ const promptValue = prompt?.split(' ').find((p) => p === 'consent' || p === 'none');
201
+ let loginHint;
202
+ if (loginHintRaw !== undefined) {
203
+ const h = parseHandle(loginHintRaw);
204
+ if (!h)
205
+ return redirectError('invalid_request', 'login_hint is not a handle', state);
206
+ loginHint = h.handle;
207
+ }
208
+ const sessionSecret = randomSecret();
209
+ const ua = c.req.header('user-agent');
210
+ const t = await tx.createTransaction(ctx, {
211
+ client,
212
+ redirectUri,
213
+ scopes,
214
+ state,
215
+ nonce,
216
+ codeChallenge,
217
+ prompt: promptValue,
218
+ maxAge,
219
+ loginHint,
220
+ requester: ua ? { user_agent: ua.slice(0, 200) } : undefined,
221
+ sessionSecret,
222
+ });
223
+ if (json)
224
+ return c.json({ ...tx.resource(ctx, t, client, { kind: 'session' }), session_token: sessionSecret }, 200, NO_STORE);
225
+ setCookie(c, 'als', sessionSecret, { path: `/transactions/${t.id}`, httpOnly: true, secure: secureCookies, sameSite: 'Lax', maxAge: ctx.constants.T_OWNER });
226
+ c.header('Cache-Control', 'no-store');
227
+ return c.redirect(`${base}/transactions/${t.id}/wait`, 303);
228
+ }
229
+ catch (e) {
230
+ if (e instanceof OAuthError && e.status === 400)
231
+ return redirectError(e.error, e.description ?? e.error, state);
232
+ throw e;
233
+ }
234
+ };
235
+ app.get('/authorize', authorize);
236
+ app.post('/authorize', authorize);
237
+ // ---- Section 6: the transaction -----------------------------------------------------------------
238
+ /** Loads the transaction, its client and the viewer; stores status/next for error bodies. */
239
+ async function loadTx(c) {
240
+ const initial = await tx.loadTransaction(ctx, c.req.param('id') ?? '');
241
+ const client = (await ctx.store.getClient(initial.clientId));
242
+ // Client resolution may yield across an explicit key revocation. Identify authority from the final read.
243
+ const t = await tx.loadTransaction(ctx, initial.id);
244
+ let viewer;
245
+ try {
246
+ viewer = tx.identifyViewer(t, parseBearer(c.req.header('authorization')), getCookie(c, 'als') ?? null);
247
+ }
248
+ catch (e) {
249
+ c.set('txError', { status: t.status, next: tx.resource(ctx, t, client, { kind: 'none' }).next });
250
+ throw e;
251
+ }
252
+ c.set('txError', { status: t.status, next: tx.resource(ctx, t, client, viewer).next });
253
+ return { t, client, viewer };
254
+ }
255
+ /**
256
+ * Runs a state change. When it fails, the error body reports the transaction as it stands now (Section 6.7), which
257
+ * may differ from what this request first read if another request changed it meanwhile.
258
+ */
259
+ async function current(c, change) {
260
+ try {
261
+ return await change();
262
+ }
263
+ catch (e) {
264
+ let refreshed;
265
+ try {
266
+ refreshed = await loadTx(c);
267
+ }
268
+ catch (latest) {
269
+ if (latest instanceof OAuthError && (latest.error === 'invalid_token' || latest.error === 'invalid_transaction'))
270
+ throw latest;
271
+ }
272
+ if (refreshed && tx.isTerminal(refreshed.t.status) && e instanceof OAuthError && !['invalid_transaction', 'invalid_token'].includes(e.error))
273
+ throw new OAuthError(410, 'transaction_expired');
274
+ if (isTransactionTokenError(e))
275
+ throw new OAuthError(401, 'invalid_token', undefined, { 'WWW-Authenticate': 'Bearer error="invalid_token"' });
276
+ throw e;
277
+ }
278
+ }
279
+ app.get('/transactions/:id', async (c) => {
280
+ const { t, client, viewer } = await loadTx(c);
281
+ return c.json(tx.resource(ctx, t, client, viewer), 200, { ...NO_STORE, Link: `<${tx.txUrl(ctx, t.id)}>; rel="agent-login-transaction"` });
282
+ });
283
+ app.get('/transactions/:id/wait', async (c) => {
284
+ const { t, client, viewer } = await loadTx(c);
285
+ const r = tx.resource(ctx, t, client, viewer);
286
+ const name = escapeHtml(tx.presentedClient(t, client).clientName ?? new URL(t.redirectUri).host);
287
+ const html = page('Sign in with your agent', `<p>Signing in to <strong>${name}</strong>.</p><p id="status">Waiting for your agent to sign in.</p>` +
288
+ `<p class="muted">Transaction <code id="txid"></code></p>` +
289
+ `<script type="application/json" id="agent-login-transaction">${embedJson(r)}</script><script src="${base}/static/wait.js"></script>`);
290
+ return c.html(html, 200, { ...NO_STORE, ...HTML_HEADERS, Link: `<${tx.txUrl(ctx, t.id)}>; rel="agent-login-transaction"` });
291
+ });
292
+ // Section 6.7 precedence: the transaction is loaded (`invalid_transaction`), the credential checked (`invalid_token`)
293
+ // and a terminal status reported (`transaction_expired`) before a malformed body is; every error of an existing
294
+ // transaction then carries `status` and `next`.
295
+ app.post('/transactions/:id/assertion', async (c) => {
296
+ const body = await deferredJsonBody(c);
297
+ const { t, client } = await loadTx(c);
298
+ tx.requireOpen(t);
299
+ const accepted = await current(c, () => tx.assert(ctx, t, client, body()));
300
+ return c.json({ ...tx.resource(ctx, accepted.tx, client, { kind: 'transaction_token' }), transaction_token: accepted.token }, 200, NO_STORE);
301
+ });
302
+ app.post('/transactions/:id/review', async (c) => {
303
+ const body = await deferredJsonBody(c);
304
+ const { t, client, viewer } = await loadTx(c);
305
+ tx.requireTransactionToken(viewer);
306
+ tx.requireOpen(t);
307
+ const reviewed = await current(c, () => tx.review(ctx, t, client, viewer, body()));
308
+ return c.json(tx.resource(ctx, reviewed, client, viewer), 200, NO_STORE);
309
+ });
310
+ app.get('/transactions/:id/continue/:secret', async (c) => {
311
+ const { t, viewer } = await loadTx(c);
312
+ const { redirect } = await current(c, () => tx.cont(ctx, t, c.req.param('secret') ?? '', viewer));
313
+ if (prefersJson(c.req.header('accept')))
314
+ return c.json({ redirect }, 200, NO_STORE);
315
+ c.header('Cache-Control', 'no-store');
316
+ return c.redirect(redirect, 303);
317
+ });
318
+ // ---- Section 5.6, 3.3: tokens --------------------------------------------------------------------
319
+ app.post('/token', async (c) => {
320
+ const form = await c.req.parseBody();
321
+ const str = (k) => (typeof form[k] === 'string' ? form[k] : undefined);
322
+ const auth = c.req.header('authorization');
323
+ let basic;
324
+ if (auth && /^Basic\s+/i.test(auth)) {
325
+ const decoded = Buffer.from(auth.replace(/^Basic\s+/i, ''), 'base64').toString('utf8');
326
+ const i = decoded.indexOf(':');
327
+ if (i < 0)
328
+ throw new OAuthError(401, 'invalid_client');
329
+ try {
330
+ basic = { id: decodeURIComponent(decoded.slice(0, i)), secret: decodeURIComponent(decoded.slice(i + 1)) };
331
+ }
332
+ catch {
333
+ throw new OAuthError(401, 'invalid_client', undefined, { 'WWW-Authenticate': 'Basic realm="token"' });
334
+ }
335
+ }
336
+ const req = { grantType: str('grant_type'), code: str('code'), redirectUri: str('redirect_uri'), codeVerifier: str('code_verifier'), clientIdParam: str('client_id'), clientSecretParam: str('client_secret'), basic };
337
+ if (basic && req.clientIdParam && basic.id !== req.clientIdParam)
338
+ throw new OAuthError(400, 'invalid_request', 'client_id mismatch');
339
+ const client = await authenticateClient(ctx, req);
340
+ return c.json(await exchangeCode(ctx, client, req), 200, NO_STORE);
341
+ });
342
+ app.get('/userinfo', async (c) => c.json(await userinfo(ctx, parseBearer(c.req.header('authorization'))), 200, NO_STORE));
343
+ app.post('/userinfo', async (c) => c.json(await userinfo(ctx, parseBearer(c.req.header('authorization'))), 200, NO_STORE));
344
+ // ---- Section 4.3: registration (RFC 7591 with an initial access token) ---------------------------
345
+ app.post('/register', async (c) => {
346
+ requireAdmin(c);
347
+ return c.json(await registerClient(clientCtx, await jsonBody(c)), 201, NO_STORE);
348
+ });
349
+ // ---- Section 7.2, 7.8: agent management ----------------------------------------------------------
350
+ const handleOf = (c) => {
351
+ const h = parseHandle(c.req.param('handle') ?? ''); // already percent-decoded by the router
352
+ if (!h)
353
+ throw new OAuthError(404, 'not_found');
354
+ return h.handle;
355
+ };
356
+ app.post('/agents/:handle/keys', async (c) => {
357
+ const handle = handleOf(c);
358
+ const auth = await agents.authorizeManagement(ctx, c.req.header('authorization'), handle, true);
359
+ const r = await agents.enrollKey(ctx, handle, await jsonBody(c), auth);
360
+ return c.json({ ...r.record, challenge: r.challenge, proof_url: r.proof_url }, 201, NO_STORE);
361
+ });
362
+ app.post('/agents/:handle/keys/:kid/proof', async (c) => c.json(await agents.proveKey(ctx, handleOf(c), (c.req.param('kid') ?? ''), await jsonBody(c)), 200, NO_STORE));
363
+ app.get('/agents/:handle/keys', async (c) => {
364
+ const handle = handleOf(c);
365
+ const cred = await agents.authorizeManagement(ctx, c.req.header('authorization'), handle, true);
366
+ return c.json({ keys: (await agents.listLiveKeys(ctx, handle, cred)).map((k) => agents.keyRecord(ctx, k)) }, 200, NO_STORE);
367
+ });
368
+ app.get('/agents/:handle/keys/:kid', async (c) => {
369
+ const handle = handleOf(c);
370
+ const cred = await agents.authorizeManagement(ctx, c.req.header('authorization'), handle, true);
371
+ const k = await agents.getLiveKey(ctx, (c.req.param('kid') ?? ''), handle, cred);
372
+ if (!k || k.handle !== handle || k.agentSub !== cred.agentSub)
373
+ throw new OAuthError(404, 'not_found');
374
+ return c.json(agents.keyRecord(ctx, k), 200, NO_STORE);
375
+ });
376
+ app.patch('/agents/:handle/keys/:kid', async (c) => {
377
+ const handle = handleOf(c);
378
+ const body = await jsonBody(c);
379
+ const wantsPermissions = isPlainObject(body) && body.permissions !== undefined;
380
+ const cred = await agents.authorizeManagement(ctx, c.req.header('authorization'), handle, !wantsPermissions);
381
+ const k = await agents.getLiveKey(ctx, (c.req.param('kid') ?? ''), handle, cred);
382
+ if (!k || k.handle !== handle || k.agentSub !== cred.agentSub)
383
+ throw new OAuthError(404, 'not_found');
384
+ if (!isPlainObject(body))
385
+ throw new OAuthError(400, 'invalid_request');
386
+ const patch = {};
387
+ if (wantsPermissions) {
388
+ // Section 7.8: `share_owner` is granted only by an Owner Credential from a session that satisfies Section 6.6; nothing changes otherwise.
389
+ if (!agents.approvalSession(cred))
390
+ throw new OAuthError(403, 'insufficient_permission', 'share_owner requires an Owner Credential from a session of Section 6.6', { 'WWW-Authenticate': 'Bearer error="insufficient_scope"' });
391
+ if (!isPlainObject(body.permissions) || typeof body.permissions.share_owner !== 'boolean')
392
+ throw new OAuthError(400, 'invalid_request', 'permissions.share_owner');
393
+ patch.shareOwner = body.permissions.share_owner;
394
+ }
395
+ if (typeof body.name === 'string')
396
+ patch.name = body.name.slice(0, 200);
397
+ // One atomic field update on this enrollment: it cannot bring back a key that is being revoked at the same time.
398
+ const patched = await ctx.store.patchKey(k.kid, k.enrollment, patch, cred);
399
+ if (!patched)
400
+ throw new OAuthError(404, 'not_found');
401
+ return c.json(agents.keyRecord(ctx, patched), 200, NO_STORE);
402
+ });
403
+ app.delete('/agents/:handle/keys/:kid', async (c) => {
404
+ const handle = handleOf(c);
405
+ const cred = await agents.authorizeManagement(ctx, c.req.header('authorization'), handle, true);
406
+ const k = await agents.getLiveKey(ctx, (c.req.param('kid') ?? ''), handle, cred);
407
+ if (!k || k.handle !== handle || k.agentSub !== cred.agentSub)
408
+ throw new OAuthError(404, 'not_found');
409
+ await agents.revokeKeyEffects(ctx, k, cred);
410
+ return c.body(null, 204, NO_STORE);
411
+ });
412
+ app.delete('/agents/:handle/keys', async (c) => {
413
+ const handle = handleOf(c);
414
+ const cred = await agents.authorizeManagement(ctx, c.req.header('authorization'), handle, true);
415
+ await agents.revokeAllKeys(ctx, handle, cred);
416
+ return c.body(null, 204, NO_STORE);
417
+ });
418
+ app.get('/agents/:handle/accounts', async (c) => {
419
+ const handle = handleOf(c);
420
+ const cred = await agents.authorizeManagement(ctx, c.req.header('authorization'), handle, true);
421
+ const accounts = await ctx.store.readManagementAccounts(handle, cred);
422
+ if (!accounts)
423
+ throw new OAuthError(404, 'not_found');
424
+ return c.json({ accounts: accounts.map(agents.accountRecord) }, 200, NO_STORE);
425
+ });
426
+ app.patch('/agents/:handle/accounts/:client_id', async (c) => {
427
+ const handle = handleOf(c);
428
+ const cred = await agents.authorizeManagement(ctx, c.req.header('authorization'), handle, true);
429
+ const body = await jsonBody(c);
430
+ if (!isPlainObject(body) || (body.status !== 'enabled' && body.status !== 'disabled'))
431
+ throw new OAuthError(400, 'invalid_request', 'status must be enabled or disabled');
432
+ if (cred.kind === 'delegated' && body.status === 'enabled')
433
+ throw new OAuthError(403, 'insufficient_permission', 'a Delegated Credential may only disable', { 'WWW-Authenticate': 'Bearer error="insufficient_scope"' });
434
+ return c.json(await agents.setAccountStatus(ctx, handle, (c.req.param('client_id') ?? ''), body.status, cred), 200, NO_STORE);
435
+ });
436
+ app.delete('/agents/:handle/consents/:client_id', async (c) => {
437
+ const handle = handleOf(c);
438
+ const cred = await agents.authorizeManagement(ctx, c.req.header('authorization'), handle, true);
439
+ if (!(await ctx.store.deleteConsent(handle, (c.req.param('client_id') ?? ''), cred)))
440
+ throw new OAuthError(404, 'not_found');
441
+ return c.body(null, 204, NO_STORE);
442
+ });
443
+ // ---- Section 6.6: Owner approval (API form; an HTML page can be layered on it) --------------------
444
+ async function ownerOf(c) {
445
+ const bearer = parseBearer(c.req.header('authorization'));
446
+ const cred = bearer ? await ctx.store.getCredential(sha256(bearer)) : undefined;
447
+ if (!cred || cred.kind !== 'owner')
448
+ throw new OAuthError(401, 'invalid_token', undefined, { 'WWW-Authenticate': 'Bearer error="invalid_token"' });
449
+ return cred;
450
+ }
451
+ app.get('/owner/transactions/:id', async (c) => {
452
+ const cred = await ownerOf(c);
453
+ const t = await tx.loadTransaction(ctx, c.req.param('id') ?? '');
454
+ if (t.ownerId !== cred.ownerId)
455
+ throw new OAuthError(404, 'invalid_transaction');
456
+ const client = tx.presentedClient(t, (await ctx.store.getClient(t.clientId)));
457
+ // Section 6.6: the approval view names the client with its redirect_host, the requested Owner Claims and the handle;
458
+ // a client registered with relay_issuer is named as a Relay that forwards the Owner Claims under its op_policy_uri,
459
+ // which Section 4.4 has the Issuer read from the Relay's metadata.
460
+ return c.json({
461
+ transaction_id: t.id,
462
+ status: t.status,
463
+ handle: t.handle,
464
+ client: { client_id: client.clientId, client_name: client.clientName, redirect_host: new URL(t.redirectUri).host, tos_uri: client.tosUri, policy_uri: client.policyUri, relay_issuer: client.relayIssuer },
465
+ relay: client.relayIssuer
466
+ ? { issuer: client.relayIssuer, op_policy_uri: client.relayPolicyUri, notice: `This client is a Relay. The Owner Claims will be forwarded to Relying Parties of that Relay under its policy at ${client.relayPolicyUri}.` }
467
+ : undefined,
468
+ requested_owner_claims: t.scopes.filter((s) => OWNER_SCOPES.includes(s)).flatMap((s) => (s === 'owner_profile' ? ['owner_name'] : ['owner_email'])),
469
+ expires_at: t.expiresAt,
470
+ }, 200, NO_STORE);
471
+ });
472
+ app.post('/owner/transactions/:id/decision', async (c) => {
473
+ const cred = await ownerOf(c);
474
+ // Section 6.6: a session established by a one-time code or link alone cannot approve or refuse.
475
+ if (!agents.approvalSession(cred))
476
+ throw new OAuthError(403, 'insufficient_permission', 'Owner approval requires a session of Section 6.6', { 'WWW-Authenticate': 'Bearer error="insufficient_scope"' });
477
+ const loaded = await tx.loadTransaction(ctx, c.req.param('id') ?? '');
478
+ const body = await jsonBody(c);
479
+ const client = (await ctx.store.getClient(loaded.clientId));
480
+ const t = await tx.ownerDecision(ctx, loaded, client, cred.ownerId, isPlainObject(body) ? body.decision : undefined);
481
+ return c.json({ transaction_id: t.id, status: t.status, reason: t.reason }, 200, NO_STORE);
482
+ });
483
+ // ---- Section 7.5 `email`: Owner sign-in by a one-time code ------------------------------------------
484
+ // How an Owner authenticates is outside the wire protocol (Section 1.4). This is the method every Issuer supports;
485
+ // the Owner Credential it issues comes from a session established by a one-time code alone, so under Sections 6.6
486
+ // and 7.8 it manages the Owner's Agents but neither approves a transaction nor grants `share_owner`.
487
+ app.post('/owner/sessions/email', async (c) => {
488
+ const b = await jsonBody(c);
489
+ if (!isPlainObject(b) || typeof b.email !== 'string')
490
+ throw new OAuthError(400, 'invalid_request', 'email required');
491
+ try {
492
+ ownerAddressKey(b.email);
493
+ }
494
+ catch {
495
+ throw new OAuthError(400, 'invalid_request', 'Valid email required');
496
+ }
497
+ if (!ctx.sendOwnerCode)
498
+ throw new OAuthError(501, 'not_supported', 'no delivery channel for one-time codes is configured');
499
+ const owner = await ctx.store.getOwnerByEmail(b.email);
500
+ if (owner) {
501
+ const code = randomId();
502
+ const expiresAt = await ctx.store.currentTime(ctx.now()) + OWNER_CODE_TTL;
503
+ await ctx.store.putOwnerCode({ ownerId: owner.id, hash: sha256(code), expiresAt }); // replaces an earlier code for this Owner
504
+ await ctx.sendOwnerCode({ email: owner.email, code, expiresAt });
505
+ }
506
+ return c.json({ sent: true }, 202, NO_STORE); // the same answer whether or not the address belongs to an Owner
507
+ });
508
+ app.post('/owner/sessions/email/verify', async (c) => {
509
+ const b = await jsonBody(c);
510
+ if (!isPlainObject(b) || typeof b.email !== 'string' || typeof b.code !== 'string')
511
+ throw new OAuthError(400, 'invalid_request', 'email and code required');
512
+ const owner = await ctx.store.getOwnerByEmail(b.email);
513
+ const pending = owner && (await ctx.store.takeOwnerCode(owner.id)); // single use: taken whether or not it matches
514
+ const now = await ctx.store.currentTime(ctx.now());
515
+ if (!owner || !pending || now >= pending.expiresAt || !safeEqual(sha256(b.code), pending.hash))
516
+ throw new OAuthError(401, 'invalid_token', 'the code is wrong, used or expired', { 'WWW-Authenticate': 'Bearer error="invalid_token"' });
517
+ const credential = randomSecret();
518
+ await ctx.store.putCredential({ hash: sha256(credential), ownerId: owner.id, kind: 'owner', sessionAuth: 'email_only', createdAt: now });
519
+ return c.json({ owner_id: owner.id, credential, session_auth: 'email_only' }, 200, NO_STORE);
520
+ });
521
+ // ---- Bootstrap API (outside the specification): owners, agents, credentials -----------------------
522
+ app.post('/admin/owners', async (c) => {
523
+ requireAdmin(c);
524
+ const b = await jsonBody(c);
525
+ if (!isPlainObject(b) || typeof b.email !== 'string')
526
+ throw new OAuthError(400, 'invalid_request', 'email required');
527
+ try {
528
+ ownerAddressKey(b.email);
529
+ }
530
+ catch {
531
+ throw new OAuthError(400, 'invalid_request', 'Valid email required');
532
+ }
533
+ const id = randomId();
534
+ const via = b.verified_via;
535
+ const sessionAuth = parseSessionAuth(b.session_auth);
536
+ // Section 7.5: `owner_verified_via` reports the strongest method used, the one of the credential's session included.
537
+ const verifiedVia = strongest(via === 'oidc' || via === 'domain' || via === 'passkey' ? via : 'email', methodOf(sessionAuth));
538
+ await ctx.store.putOwner({ id, ownerSub: randomSecret(), email: b.email, emailVerified: true, name: typeof b.name === 'string' ? b.name : undefined, verifiedVia, createdAt: ctx.now() });
539
+ const credential = randomSecret();
540
+ await ctx.store.putCredential({ hash: sha256(credential), ownerId: id, kind: 'owner', sessionAuth, createdAt: ctx.now() });
541
+ return c.json({ owner_id: id, credential, session_auth: sessionAuth, verified_via: verifiedVia }, 201, NO_STORE);
542
+ });
543
+ app.delete('/admin/agents/:handle', async (c) => {
544
+ requireAdmin(c);
545
+ const a = await ctx.store.getAgent(handleOf(c));
546
+ if (!a)
547
+ throw new OAuthError(404, 'not_found');
548
+ await agents.deleteAgent(ctx, a);
549
+ return c.body(null, 204, NO_STORE);
550
+ });
551
+ app.post('/admin/agents', async (c) => {
552
+ requireAdmin(c);
553
+ const b = await jsonBody(c);
554
+ if (!isPlainObject(b) || typeof b.handle !== 'string' || typeof b.owner_id !== 'string')
555
+ throw new OAuthError(400, 'invalid_request', 'handle and owner_id required');
556
+ const h = parseHandle(b.handle);
557
+ if (!h || !domainServed(h.domain, ctx.handleDomains))
558
+ throw new OAuthError(400, 'invalid_request', 'handle not in a served domain');
559
+ if (!(await ctx.store.getOwner(b.owner_id)))
560
+ throw new OAuthError(404, 'not_found', 'owner');
561
+ if (await ctx.store.getAgent(h.handle))
562
+ throw new OAuthError(409, 'invalid_request', 'handle exists');
563
+ if (!(await ctx.store.putAgent({ ...h, sub: randomSecret(), ownerId: b.owner_id, name: typeof b.name === 'string' ? b.name : undefined, preferredUsername: typeof b.preferred_username === 'string' ? b.preferred_username : h.local, enabled: true, authorizationVersion: 0, createdAt: ctx.now() })))
564
+ throw new OAuthError(409, 'invalid_request', 'handle exists');
565
+ return c.json({ handle: h.handle, owner_id: b.owner_id }, 201, NO_STORE);
566
+ });
567
+ app.patch('/admin/agents/:handle', async (c) => {
568
+ requireAdmin(c);
569
+ const handle = handleOf(c);
570
+ const a = await ctx.store.getAgent(handle);
571
+ const b = await jsonBody(c);
572
+ if (!a || !isPlainObject(b) || typeof b.enabled !== 'boolean')
573
+ throw new OAuthError(400, 'invalid_request');
574
+ if (!(await agents.setAgentEnabled(ctx, a, b.enabled)))
575
+ throw new OAuthError(404, 'not_found');
576
+ return c.json({ handle, enabled: b.enabled }, 200, NO_STORE);
577
+ });
578
+ app.post('/admin/credentials', async (c) => {
579
+ requireAdmin(c);
580
+ const b = await jsonBody(c);
581
+ if (!isPlainObject(b) || typeof b.owner_id !== 'string')
582
+ throw new OAuthError(400, 'invalid_request', 'owner_id required');
583
+ const kind = b.kind === 'delegated' ? 'delegated' : 'owner';
584
+ const handle = typeof b.handle === 'string' ? parseHandle(b.handle)?.handle : undefined;
585
+ if (kind === 'delegated' && !handle)
586
+ throw new OAuthError(400, 'invalid_request', 'delegated credentials need a handle');
587
+ const owner = await ctx.store.getOwner(b.owner_id);
588
+ if (!owner)
589
+ throw new OAuthError(404, 'not_found', 'owner');
590
+ const credential = randomSecret();
591
+ const sessionAuth = kind === 'owner' ? parseSessionAuth(b.session_auth) : undefined;
592
+ if (sessionAuth && strongest(owner.verifiedVia, methodOf(sessionAuth)) !== owner.verifiedVia) {
593
+ await ctx.store.putOwner({ ...owner, verifiedVia: methodOf(sessionAuth) }, ctx.now()); // Section 7.5: the strongest method used
594
+ }
595
+ await ctx.store.putCredential({ hash: sha256(credential), ownerId: b.owner_id, kind, handle, sessionAuth, createdAt: ctx.now() });
596
+ return c.json({ credential, kind, handle, session_auth: sessionAuth }, 201, NO_STORE);
597
+ });
598
+ app.get('/static/wait.js', (c) => c.body(WAIT_JS, 200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }));
599
+ app.get('/', (c) => c.html(page('Agent Login issuer', `<p>This server is an Agent Login issuer for ${ctx.handleDomains.map(escapeHtml).join(', ')}.</p><p><a href="${base}/.well-known/openid-configuration">Metadata</a></p>`), 200, HTML_HEADERS));
600
+ function requireAdmin(c) {
601
+ const b = parseBearer(c.req.header('authorization'));
602
+ if (!b || !safeEqual(b, ctx.adminToken))
603
+ throw new OAuthError(401, 'invalid_token', undefined, { 'WWW-Authenticate': 'Bearer error="invalid_token"' });
604
+ }
605
+ return app;
606
+ }
607
+ /**
608
+ * Bootstrap `session_auth`: how the session that obtained an Owner Credential authenticated the Owner (Section 6.6).
609
+ * It fails closed: a caller that does not state it gets `email_only`, which neither approves nor grants `share_owner`.
610
+ */
611
+ function parseSessionAuth(v) {
612
+ if (v === undefined)
613
+ return 'email_only';
614
+ if (v === 'passkey' || v === 'oidc' || v === 'domain' || v === 'email_factor' || v === 'email_only')
615
+ return v;
616
+ throw new OAuthError(400, 'invalid_request', 'session_auth must be passkey, oidc, domain, email_factor or email_only');
617
+ }
618
+ /** Section 7.5: the verification methods from weakest to strongest, and the one a session of each kind amounts to. */
619
+ const METHODS = ['email', 'oidc', 'domain', 'passkey'];
620
+ const methodOf = (s) => (s === 'email_only' || s === 'email_factor' ? 'email' : s);
621
+ const strongest = (a, b) => (METHODS.indexOf(a) >= METHODS.indexOf(b) ? a : b);
622
+ async function jsonBody(c) {
623
+ const ct = (c.req.header('content-type') ?? '').split(';')[0].trim().toLowerCase();
624
+ if (ct !== 'application/json')
625
+ throw new OAuthError(400, 'invalid_request', 'Content-Type must be application/json');
626
+ const text = await c.req.text();
627
+ if (text.length > 64 * 1024)
628
+ throw new OAuthError(400, 'invalid_request', 'body too large');
629
+ try {
630
+ return JSON.parse(text);
631
+ }
632
+ catch {
633
+ throw new OAuthError(400, 'invalid_request', 'malformed JSON');
634
+ }
635
+ }
636
+ /** Reads the body now and reports a malformed one only when it is used, so that the errors Section 6.7 ranks higher come first. */
637
+ async function deferredJsonBody(c) {
638
+ try {
639
+ const v = await jsonBody(c);
640
+ return () => v;
641
+ }
642
+ catch (e) {
643
+ return () => {
644
+ throw e;
645
+ };
646
+ }
647
+ }
648
+ /** Section 5.3: every HTML page carries a CSP that permits only the Issuer's own scripts and forbids framing. */
649
+ function page(title, body) {
650
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:36rem;margin:4rem auto;padding:0 1rem;color:#222}.muted{color:#666;font-size:.9em}code{font-size:.9em}</style></head><body><h1>${escapeHtml(title)}</h1>${body}</body></html>`;
651
+ }
652
+ //# sourceMappingURL=app.js.map