@adonis-agora/authkit-server 0.86.0 → 0.87.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.
Files changed (37) hide show
  1. package/README.md +2 -2
  2. package/build/index.d.ts +1 -2
  3. package/build/index.js +0 -1
  4. package/build/src/agents/config.d.ts +1 -1
  5. package/build/src/agents/delegation_service.js +1 -1
  6. package/build/src/agents/poppy/client_registry.d.ts +5 -0
  7. package/build/src/agents/poppy/client_registry.js +35 -1
  8. package/build/src/agents/poppy/config.d.ts +111 -80
  9. package/build/src/agents/poppy/config.js +55 -35
  10. package/build/src/agents/poppy/dpop.d.ts +2 -2
  11. package/build/src/agents/poppy/dpop.js +3 -3
  12. package/build/src/agents/poppy/email_code_sign_in.d.ts +4 -4
  13. package/build/src/agents/poppy/email_code_sign_in.js +5 -5
  14. package/build/src/agents/poppy/errors.d.ts +7 -2
  15. package/build/src/agents/poppy/errors.js +5 -2
  16. package/build/src/agents/poppy/resource.d.ts +30 -25
  17. package/build/src/agents/poppy/resource.js +24 -20
  18. package/build/src/agents/poppy/safe_fetch.js +8 -2
  19. package/build/src/agents/poppy/service.d.ts +69 -62
  20. package/build/src/agents/poppy/service.js +540 -546
  21. package/build/src/agents/poppy/session_jwt.d.ts +30 -0
  22. package/build/src/agents/poppy/session_jwt.js +133 -0
  23. package/build/src/agents/poppy/store.d.ts +17 -22
  24. package/build/src/agents/poppy/store.js +22 -30
  25. package/build/src/agents/poppy/web_session.d.ts +23 -16
  26. package/build/src/agents/poppy/web_session.js +42 -24
  27. package/build/src/host/admin_sessions_service.js +3 -3
  28. package/build/src/host/controllers/account_apps_controller.js +1 -1
  29. package/build/src/host/controllers/poppy_consent_controller.d.ts +2 -2
  30. package/build/src/host/controllers/poppy_consent_controller.js +2 -2
  31. package/build/src/host/controllers/poppy_controller.d.ts +19 -11
  32. package/build/src/host/controllers/poppy_controller.js +93 -68
  33. package/build/src/host/csrf.d.ts +2 -4
  34. package/build/src/host/csrf.js +2 -3
  35. package/build/src/host/register_auth_host.js +10 -5
  36. package/build/src/schema/ensure.js +14 -9
  37. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  import { PoppyError } from '../../agents/poppy/errors.js';
2
- import { publicRequestUrl, writePoppyAuthError } from '../../agents/poppy/resource.js';
2
+ import { publicRequestUrl, writePoppyAuthError, } from '../../agents/poppy/resource.js';
3
3
  import { loginPoppyBrowser } from '../../agents/poppy/web_session.js';
4
4
  import { personalAgentsFor } from '../../agents/runtime.js';
5
5
  function noStore(ctx) {
@@ -15,6 +15,9 @@ function oauthError(ctx, error) {
15
15
  noStore(ctx);
16
16
  for (const [k, v] of Object.entries(error.headers))
17
17
  ctx.response.header(k, v);
18
+ // Limite de taxa: 429 com `Retry-After`, sem corpo próprio (§5.6).
19
+ if (error.status === 429)
20
+ return ctx.response.status(429).send('');
18
21
  if (error.code === 'invalid_dpop_proof' || error.code === 'use_dpop_nonce') {
19
22
  ctx.response.header('WWW-Authenticate', `DPoP error="${error.code}"`);
20
23
  }
@@ -31,25 +34,43 @@ function form(ctx) {
31
34
  return ctx.request.all();
32
35
  }
33
36
  /**
34
- * Endpoints server-to-server do Poppy (§3, §4): descoberta, metadata RFC 8414,
35
- * token, revogação, device authorization, Mediated Sign-In e a sessão de
36
- * navegador (§5). Todos isentos de CSRF — ver `authkitCsrfExceptions`.
37
+ * Endpoints do Poppy (§3, §4, §6): descoberta, protected resource metadata, metadata RFC 8414,
38
+ * token, revogação, device authorization e a entrada do navegador na Session. Os POSTs são isentos
39
+ * de CSRF — ver `authkitCsrfExceptions`.
37
40
  */
38
41
  export default class PoppyController {
39
- /** `GET /.well-known/poppy.json` — só no domínio da empresa (ignorando `www.`). */
42
+ /** `GET /.well-known/poppy.json` — só no domínio da empresa; `www.` redireciona para ele. */
40
43
  async discovery(ctx) {
41
44
  const poppy = await poppyOr404(ctx);
42
45
  if (!poppy)
43
46
  return;
44
47
  // `host()` mantém a porta (dev em `localhost:3333`): o `browser_session_endpoint` sai dele.
45
- const doc = poppy.discoveryDocument(ctx.request.host() ?? ctx.request.hostname() ?? '');
48
+ const host = ctx.request.host() ?? ctx.request.hostname() ?? '';
49
+ const redirect = poppy.discoveryRedirect(host);
50
+ if (redirect) {
51
+ ctx.response.header('Location', redirect);
52
+ return ctx.response.status(308).send('');
53
+ }
54
+ const doc = poppy.discoveryDocument(host);
46
55
  if (!doc)
47
56
  return ctx.response.notFound();
48
57
  ctx.response.header('Access-Control-Allow-Origin', '*');
49
58
  ctx.response.header('Cache-Control', 'public, max-age=300');
50
59
  return ctx.response.json(doc);
51
60
  }
52
- /** `GET /.well-known/oauth-authorization-server{prefix}` (RFC 8414 + `poppy_domains`). */
61
+ /** `GET /.well-known/oauth-protected-resource` do domínio (§3.2, RFC 9728). */
62
+ async protectedResource(ctx) {
63
+ const poppy = await poppyOr404(ctx);
64
+ if (!poppy)
65
+ return;
66
+ const doc = poppy.protectedResourceMetadata(ctx.request.host() ?? ctx.request.hostname() ?? '');
67
+ if (!doc)
68
+ return ctx.response.notFound();
69
+ ctx.response.header('Access-Control-Allow-Origin', '*');
70
+ ctx.response.header('Cache-Control', 'public, max-age=300');
71
+ return ctx.response.json(doc);
72
+ }
73
+ /** `GET /.well-known/oauth-authorization-server{prefix}` (RFC 8414 + `protected_resources`). */
53
74
  async metadata(ctx) {
54
75
  const poppy = await poppyOr404(ctx);
55
76
  if (!poppy)
@@ -64,7 +85,7 @@ export default class PoppyController {
64
85
  return;
65
86
  withNonce(ctx, poppy);
66
87
  try {
67
- const body = await poppy.token(form(ctx), ctx.request.header('dpop'));
88
+ const body = await poppy.token(form(ctx), ctx.request.header('dpop'), ctx);
68
89
  noStore(ctx);
69
90
  return ctx.response.json(body);
70
91
  }
@@ -105,89 +126,93 @@ export default class PoppyController {
105
126
  throw error;
106
127
  }
107
128
  }
108
- /** `POST auth.mediated.endpoint` (§4.7) — com o Session Token (DPoP). */
109
- async mediatedStart(ctx) {
129
+ /**
130
+ * `GET web.browser_session_endpoint` (§6, passo 1): a página com o código de uso único e o cookie
131
+ * que o prende a este navegador. Não mexe em Session nem nos outros cookies.
132
+ */
133
+ async browserSessionPage(ctx) {
110
134
  const poppy = await poppyOr404(ctx);
111
- if (!poppy?.cfg.signIn.mediated)
135
+ if (!poppy?.cfg.web?.browserSession)
112
136
  return poppy ? ctx.response.notFound() : undefined;
113
- const auth = await poppy.verifyAccess({
114
- method: ctx.request.method(),
115
- url: publicRequestUrl(ctx, poppy),
116
- headers: ctx.request.headers(),
117
- });
118
- if (!auth.ok) {
119
- const { ok: _ok, ...rest } = auth;
120
- return writePoppyAuthError(ctx, { ok: false, ...rest });
121
- }
122
- try {
123
- const body = await poppy.mediatedStart(auth.principal, auth.jkt, ctx.request.body(), ctx);
124
- noStore(ctx);
125
- return ctx.response.json(body);
126
- }
127
- catch (error) {
128
- if (error instanceof PoppyError)
129
- return oauthError(ctx, error);
130
- throw error;
131
- }
137
+ const { code, cookie, maxAge } = await poppy.startBrowserJoin();
138
+ const name = joinCookieName(poppy);
139
+ ctx.response.append('Set-Cookie', `${name}=${cookie}; Path=/; Max-Age=${maxAge}; ${cookieSecure(poppy)}HttpOnly; SameSite=Strict`);
140
+ browserHeaders(ctx);
141
+ // `code` só tem caracteres base64url; o `action` é o path do próprio endpoint.
142
+ const action = new URL(poppy.urls.browserSession).pathname;
143
+ return ctx.response
144
+ .status(200)
145
+ .send(`<!doctype html><html><head><meta charset="utf-8"><title>Connecting</title></head><body><form method="post" action="${action}"><input type="hidden" name="code" value="${code}"><button>Continue</button></form></body></html>`);
132
146
  }
133
- /** `POST {mediated}/{sign_in_id}` (§4.7). */
134
- async mediatedCode(ctx) {
147
+ /**
148
+ * `POST {browser_session_endpoint}/authorize` (§6, passo 2): o agente autoriza o código, com o
149
+ * header da Session e, se logado, o access token. 204; código inválido → 400 `invalid_request`.
150
+ */
151
+ async browserSessionAuthorize(ctx) {
135
152
  const poppy = await poppyOr404(ctx);
136
- if (!poppy?.cfg.signIn.mediated)
153
+ if (!poppy?.cfg.web?.browserSession)
137
154
  return poppy ? ctx.response.notFound() : undefined;
138
- const auth = await poppy.verifyAccess({
139
- method: ctx.request.method(),
140
- url: publicRequestUrl(ctx, poppy),
141
- headers: ctx.request.headers(),
142
- });
143
- if (!auth.ok) {
144
- const { ok: _ok, ...rest } = auth;
145
- return writePoppyAuthError(ctx, { ok: false, ...rest });
146
- }
155
+ noStore(ctx);
147
156
  try {
148
- const body = await poppy.mediatedCode(auth.principal, auth.jkt, String(ctx.request.param('id') ?? ''), ctx.request.body());
149
- noStore(ctx);
150
- if (!body) {
151
- return ctx.response
152
- .status(404)
153
- .json({ error: 'invalid_request', error_description: 'Unknown sign_in_id' });
154
- }
155
- return ctx.response.json(body);
157
+ const result = await poppy.authorizeBrowserJoin({
158
+ method: ctx.request.method(),
159
+ url: publicRequestUrl(ctx, poppy),
160
+ headers: ctx.request.headers(),
161
+ }, ctx.request.body()?.code);
162
+ if (!result.ok)
163
+ return writePoppyAuthError(ctx, result);
164
+ return ctx.response.status(204).send('');
156
165
  }
157
166
  catch (error) {
158
- if (error instanceof PoppyError)
159
- return oauthError(ctx, error);
167
+ if (error instanceof PoppyError) {
168
+ return ctx.response.status(400).json({ error: 'invalid_request' });
169
+ }
160
170
  throw error;
161
171
  }
162
172
  }
163
173
  /**
164
- * `POST web.browser_session_endpoint` (§5): asserção no corpo → cookie do app
165
- * para a Session e 303 para `return_to`; qualquer falha → 400, sem cookie.
174
+ * `POST web.browser_session_endpoint` (§6, passo 3): o navegador submete o código → cookie do app
175
+ * para a Session, limpa o cookie do passo 1 e redireciona para o site; falha → página de erro.
166
176
  */
167
177
  async browserSession(ctx) {
168
178
  const poppy = await poppyOr404(ctx);
169
179
  if (!poppy?.cfg.web?.browserSession)
170
180
  return poppy ? ctx.response.notFound() : undefined;
171
- noStore(ctx);
172
- // A asserção vai NO CORPO, nunca na URL (§5).
173
- if (ctx.request.qs().assertion !== undefined)
181
+ browserHeaders(ctx);
182
+ const name = joinCookieName(poppy);
183
+ const result = await poppy.completeBrowserJoin(ctx.request.input('code'), readCookie(ctx.request.header('cookie'), name));
184
+ if (!result)
174
185
  return badBrowserRequest(ctx);
175
- let result;
176
- try {
177
- result = await poppy.verifyBrowserAssertion(ctx.request.input('assertion'), new URL(publicRequestUrl(ctx, poppy)).origin);
178
- }
179
- catch (error) {
180
- if (error instanceof PoppyError)
181
- return badBrowserRequest(ctx);
182
- throw error;
183
- }
184
186
  await loginPoppyBrowser(ctx, result);
185
- ctx.response.header('Location', result.returnTo);
187
+ ctx.response.append('Set-Cookie', `${name}=; Path=/; Max-Age=0; ${cookieSecure(poppy)}HttpOnly; SameSite=Strict`);
188
+ ctx.response.header('Location', poppy.cfg.web.redirectTo);
186
189
  return ctx.response.status(303).send('');
187
190
  }
188
191
  }
189
- function badBrowserRequest(ctx) {
192
+ /** `__Host-` exige `Secure`; em dev com o issuer em http o cookie sai sem os dois. */
193
+ function joinCookieName(poppy) {
194
+ return new URL(poppy.urls.origin).protocol === 'https:' ? '__Host-poppy_join' : 'poppy_join';
195
+ }
196
+ function cookieSecure(poppy) {
197
+ return new URL(poppy.urls.origin).protocol === 'https:' ? 'Secure; ' : '';
198
+ }
199
+ function readCookie(header, name) {
200
+ for (const part of (header ?? '').split(';')) {
201
+ const eq = part.indexOf('=');
202
+ if (eq > 0 && part.slice(0, eq).trim() === name)
203
+ return part.slice(eq + 1).trim();
204
+ }
205
+ return undefined;
206
+ }
207
+ /** Sem cache, sem moldura e sem referência da página que abriu (§6, passo 1). */
208
+ function browserHeaders(ctx) {
209
+ noStore(ctx);
190
210
  ctx.response.header('Content-Type', 'text/html; charset=utf-8');
211
+ ctx.response.header('Content-Security-Policy', "frame-ancestors 'none'");
212
+ ctx.response.header('Cross-Origin-Opener-Policy', 'same-origin');
213
+ ctx.response.header('Referrer-Policy', 'no-referrer');
214
+ }
215
+ function badBrowserRequest(ctx) {
191
216
  return ctx.response
192
217
  .status(400)
193
218
  .send('<!doctype html><html><head><meta charset="utf-8"><title>Bad request</title></head><body><h1>Invalid browser session request</h1></body></html>');
@@ -11,10 +11,8 @@ export interface AuthkitCsrfOptions {
11
11
  */
12
12
  personalAgentsPrefix?: string | false;
13
13
  /**
14
- * Prefixo do Poppy: isenta token/revoke/device authorization, o Mediated
15
- * Sign-In e a sessão de navegador (POST cross-site autenticado pela asserção).
16
- * Default: o do config. As telas `{prefix}/oauth/authorize` e `{prefix}/device`
17
- * continuam protegidas. `false` desliga.
14
+ * Prefixo do Poppy: isenta token/revoke/device authorization e a entrada do navegador
15
+ * (o código com o cookie `SameSite=Strict` é o que protege, §6).
18
16
  */
19
17
  poppyPrefix?: string | false;
20
18
  }
@@ -44,9 +44,8 @@ export function authkitCsrfExceptions(url, options = {}) {
44
44
  (path === `${poppyPrefix}/oauth/token` ||
45
45
  path === `${poppyPrefix}/oauth/revoke` ||
46
46
  path === `${poppyPrefix}/oauth/device` ||
47
- path === `${poppyPrefix}/sign-in` ||
48
- path.startsWith(`${poppyPrefix}/sign-in/`) ||
49
- path === `${poppyPrefix}/browser-session`);
47
+ path === `${poppyPrefix}/browser-session` ||
48
+ path === `${poppyPrefix}/browser-session/authorize`);
50
49
  return (url.includes(mountPath) ||
51
50
  url.includes('/authkit/pat') ||
52
51
  (backchannel !== null && url === backchannel) ||
@@ -573,6 +573,9 @@ export function registerAuthHost(router, opts = {}) {
573
573
  // Nomes explícitos: o Router nomeia pela dupla controller.método, e as duas URLs da metadata
574
574
  // (RFC 8414 na raiz e debaixo do issuer) usam o mesmo — "poppy.metadata already exists" no boot.
575
575
  router.get('/.well-known/poppy.json', [C.poppy, 'discovery']).as('authkit.poppy.discovery');
576
+ router
577
+ .get('/.well-known/oauth-protected-resource', [C.poppy, 'protectedResource'])
578
+ .as('authkit.poppy.protected_resource');
576
579
  router
577
580
  .get(`/.well-known/oauth-authorization-server${poppyPrefix}`, [C.poppy, 'metadata'])
578
581
  .as('authkit.poppy.metadata');
@@ -584,14 +587,16 @@ export function registerAuthHost(router, opts = {}) {
584
587
  router
585
588
  .post(`${poppyPrefix}/oauth/device`, [C.poppy, 'deviceAuthorization'])
586
589
  .as('authkit.poppy.device_authorization');
587
- // Credenciais e códigos de uso único: throttle por IP (§4.7 exige rate limit).
588
- withLogin(router.post(`${poppyPrefix}/sign-in`, [C.poppy, 'mediatedStart']).as('authkit.poppy.sign_in'));
589
- withOtpLogin(router
590
- .post(`${poppyPrefix}/sign-in/:id`, [C.poppy, 'mediatedCode'])
591
- .as('authkit.poppy.sign_in_code'));
590
+ // A página grava um pedido sem autenticação: throttle por IP.
591
+ withLogin(router
592
+ .get(`${poppyPrefix}/browser-session`, [C.poppy, 'browserSessionPage'])
593
+ .as('authkit.poppy.browser_session_page'));
592
594
  router
593
595
  .post(`${poppyPrefix}/browser-session`, [C.poppy, 'browserSession'])
594
596
  .as('authkit.poppy.browser_session');
597
+ router
598
+ .post(`${poppyPrefix}/browser-session/authorize`, [C.poppy, 'browserSessionAuthorize'])
599
+ .as('authkit.poppy.browser_session_authorize');
595
600
  router
596
601
  .group(() => {
597
602
  router
@@ -334,22 +334,23 @@ const TABLES = [
334
334
  },
335
335
  },
336
336
  /*
337
- * Personal Agent Protocol (Poppy). Os Account Tokens são grants de
337
+ * Personal Agent Protocol (Poppy). Os refresh tokens são grants de
338
338
  * `auth_agent_grants` (prefixo `pgrant_`) com o hash em
339
- * `auth_agent_refresh_tokens`; aqui ficam Sessions, Session Tokens, pedidos de
339
+ * `auth_agent_refresh_tokens`; aqui ficam Sessions, access tokens, pedidos de
340
340
  * sign-in e os `jti` vistos. Mesmas regras de data das tabelas acima.
341
341
  */
342
342
  {
343
343
  name: 'auth_poppy_sessions',
344
344
  /**
345
- * Uma Session: um usuário do agente (`user_id`, opaco) num agente
346
- * (`client_id`). `account_id` é a conta a que ficou PRESA ao logar (não troca);
347
- * `grant_id` o Account Token com que está logada agora (`null` = deslogada).
345
+ * Uma Session: um usuário do agente (`user_id`, opaco) num agente (`client_id`), com o
346
+ * `agent_id` opcional. `account_id` é a conta que já usou. `grant_id` e `scope` ficaram da
347
+ * Draft 0.1, sem uso (o schema só é aditivo).
348
348
  */
349
349
  create: (t) => {
350
350
  t.string('id').primary();
351
351
  t.string('client_id', 2048).notNullable();
352
352
  t.string('user_id').notNullable();
353
+ t.string('agent_id').nullable();
353
354
  t.string('account_id').nullable().index();
354
355
  t.string('grant_id').nullable().index();
355
356
  t.text('scope').notNullable();
@@ -361,6 +362,7 @@ const TABLES = [
361
362
  columns: {
362
363
  client_id: (t) => t.string('client_id', 2048),
363
364
  user_id: (t) => t.string('user_id'),
365
+ agent_id: (t) => t.string('agent_id').nullable(),
364
366
  account_id: (t) => t.string('account_id').nullable().index(),
365
367
  grant_id: (t) => t.string('grant_id').nullable().index(),
366
368
  scope: (t) => t.text('scope'),
@@ -372,7 +374,10 @@ const TABLES = [
372
374
  },
373
375
  {
374
376
  name: 'auth_poppy_tokens',
375
- /** Session Tokens — só o hash. `jkt` = chave DPoP; `null` = Bearer (MCP). */
377
+ /**
378
+ * Access tokens — só o hash. `jkt` = chave DPoP; `null` = Bearer. `session_id` e `user_id`
379
+ * ficaram da Draft 0.1 e saem vazios: o sign-in não conhece a Session.
380
+ */
376
381
  create: (t) => {
377
382
  t.string('token_hash', 64).primary();
378
383
  t.string('session_id').notNullable().index();
@@ -402,8 +407,8 @@ const TABLES = [
402
407
  {
403
408
  name: 'auth_poppy_requests',
404
409
  /**
405
- * Pedidos de sign-in: `authorize` (Direct — vira código após o consentimento),
406
- * `device` (RFC 8628) e `mediated` (§4.7, aguardando o código de uso único).
410
+ * Pedidos: `authorize` (Direct — vira código após o consentimento), `device` (RFC 8628),
411
+ * `mediated` (aguardando o código de uso único) e `browser` (entrada de um navegador, §6).
407
412
  * Códigos só como hash. Linhas expiradas são apagadas no próximo pedido.
408
413
  */
409
414
  create: (t) => {
@@ -448,7 +453,7 @@ const TABLES = [
448
453
  },
449
454
  {
450
455
  name: 'auth_poppy_jtis',
451
- /** `jti` já aceitos (asserções, provas DPoP), até expirarem — anti-replay entre instâncias. */
456
+ /** `jti` já aceitos (client assertions, Session JWTs, provas DPoP), até expirarem — anti-replay entre instâncias. */
452
457
  create: (t) => {
453
458
  t.string('key', 100).primary();
454
459
  t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable().index();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.86.0",
3
+ "version": "0.87.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",
@@ -117,7 +117,7 @@
117
117
  },
118
118
  "devDependencies": {
119
119
  "@adonis-agora/authkit-react": "0.26.0",
120
- "@adonis-agora/durable": "0.53.0",
120
+ "@adonis-agora/durable": "0.54.1",
121
121
  "@adonis-agora/telescope": "0.23.0",
122
122
  "@adonisjs/ally": "6.3.2",
123
123
  "@adonisjs/auth": "10.1.0",
@@ -133,7 +133,7 @@
133
133
  "@poppinss/ts-exec": "1.5.1",
134
134
  "@tailwindcss/cli": "4.3.3",
135
135
  "@tanstack/intent": "0.5.6",
136
- "@tanstack/react-query": "5.104.5",
136
+ "@tanstack/react-query": "5.105.0",
137
137
  "@types/better-sqlite3": "9.6.0",
138
138
  "@types/ioredis-mock": "8.2.8",
139
139
  "@types/koa": "3.0.3",