@adonis-agora/authkit-server 1.0.0-rc.0 → 1.0.0-rc.2

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 (91) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +4 -5
  3. package/build/index.d.ts +7 -11
  4. package/build/index.js +5 -9
  5. package/build/providers/authkit_server_provider.js +14 -8
  6. package/build/src/agents/config.d.ts +9 -13
  7. package/build/src/agents/config.js +17 -11
  8. package/build/src/agents/delegation_service.js +3 -3
  9. package/build/src/agents/delegation_store.d.ts +3 -2
  10. package/build/src/agents/delegation_store.js +6 -5
  11. package/build/src/agents/extension.d.ts +261 -0
  12. package/build/src/agents/extension.js +40 -0
  13. package/build/src/agents/middleware.js +1 -1
  14. package/build/src/agents/protocol.d.ts +4 -4
  15. package/build/src/agents/registry_store.d.ts +3 -2
  16. package/build/src/agents/registry_store.js +1 -1
  17. package/build/src/agents/runtime.d.ts +26 -7
  18. package/build/src/agents/runtime.js +110 -75
  19. package/build/src/audit/audit_sink.js +1 -1
  20. package/build/src/console_authorize.d.ts +56 -0
  21. package/build/src/console_authorize.js +85 -0
  22. package/build/src/define_config.d.ts +20 -14
  23. package/build/src/define_config.js +11 -8
  24. package/build/src/doctor/checks.d.ts +7 -1
  25. package/build/src/doctor/checks.js +47 -1
  26. package/build/src/host/account_api/account_api_controller.js +8 -6
  27. package/build/src/host/account_session_sign_in.d.ts +12 -0
  28. package/build/src/host/account_session_sign_in.js +19 -0
  29. package/build/src/host/admin_clients_service.js +10 -1
  30. package/build/src/host/admin_console/console_personal_agents_controller.d.ts +1 -6
  31. package/build/src/host/admin_console/console_personal_agents_controller.js +50 -30
  32. package/build/src/host/admin_console_warning.d.ts +8 -0
  33. package/build/src/host/admin_console_warning.js +18 -0
  34. package/build/src/host/admin_sessions_service.js +8 -5
  35. package/build/src/host/auth_host_config.d.ts +11 -2
  36. package/build/src/host/console_denied_page.d.ts +6 -0
  37. package/build/src/host/console_denied_page.js +18 -0
  38. package/build/src/host/console_sso.d.ts +24 -0
  39. package/build/src/host/console_sso.js +70 -0
  40. package/build/src/host/controllers/account_apps_controller.js +15 -11
  41. package/build/src/host/controllers/account_session_controller.js +10 -1
  42. package/build/src/host/controllers/console_sso_controller.d.ts +12 -0
  43. package/build/src/host/controllers/console_sso_controller.js +61 -0
  44. package/build/src/host/controllers/interaction_controller.js +3 -1
  45. package/build/src/host/csrf.d.ts +3 -3
  46. package/build/src/host/csrf.js +3 -13
  47. package/build/src/host/default_mailer.d.ts +11 -6
  48. package/build/src/host/default_mailer.js +16 -12
  49. package/build/src/host/i18n.d.ts +3 -14
  50. package/build/src/host/i18n.js +15 -13
  51. package/build/src/host/register_auth_host.d.ts +2 -0
  52. package/build/src/host/register_auth_host.js +110 -64
  53. package/build/src/host/ui-dist/assets/index-BqyJYRVX.js +67 -0
  54. package/build/src/host/ui-dist/index.html +1 -1
  55. package/build/src/provider/build_provider.js +6 -0
  56. package/build/src/provider/oidc_service.d.ts +1 -1
  57. package/build/src/provider/oidc_service.js +1 -1
  58. package/build/src/schema/ensure.d.ts +14 -0
  59. package/build/src/schema/ensure.js +43 -150
  60. package/package.json +5 -5
  61. package/build/src/agents/poppy/client_registry.d.ts +0 -50
  62. package/build/src/agents/poppy/client_registry.js +0 -260
  63. package/build/src/agents/poppy/config.d.ts +0 -396
  64. package/build/src/agents/poppy/config.js +0 -228
  65. package/build/src/agents/poppy/dpop.d.ts +0 -61
  66. package/build/src/agents/poppy/dpop.js +0 -158
  67. package/build/src/agents/poppy/email_code_sign_in.d.ts +0 -27
  68. package/build/src/agents/poppy/email_code_sign_in.js +0 -140
  69. package/build/src/agents/poppy/errors.d.ts +0 -25
  70. package/build/src/agents/poppy/errors.js +0 -25
  71. package/build/src/agents/poppy/replay.d.ts +0 -25
  72. package/build/src/agents/poppy/replay.js +0 -106
  73. package/build/src/agents/poppy/resource.d.ts +0 -100
  74. package/build/src/agents/poppy/resource.js +0 -129
  75. package/build/src/agents/poppy/safe_fetch.d.ts +0 -48
  76. package/build/src/agents/poppy/safe_fetch.js +0 -262
  77. package/build/src/agents/poppy/service.d.ts +0 -293
  78. package/build/src/agents/poppy/service.js +0 -1458
  79. package/build/src/agents/poppy/session_jwt.d.ts +0 -30
  80. package/build/src/agents/poppy/session_jwt.js +0 -133
  81. package/build/src/agents/poppy/store.d.ts +0 -106
  82. package/build/src/agents/poppy/store.js +0 -261
  83. package/build/src/agents/poppy/web_session.d.ts +0 -39
  84. package/build/src/agents/poppy/web_session.js +0 -122
  85. package/build/src/host/controllers/poppy_consent_controller.d.ts +0 -19
  86. package/build/src/host/controllers/poppy_consent_controller.js +0 -199
  87. package/build/src/host/controllers/poppy_controller.d.ts +0 -33
  88. package/build/src/host/controllers/poppy_controller.js +0 -219
  89. package/build/src/host/poppy_session_key.d.ts +0 -9
  90. package/build/src/host/poppy_session_key.js +0 -9
  91. package/build/src/host/ui-dist/assets/index-D0EIeyKK.js +0 -67
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
10
  <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
11
- <script type="module" crossorigin src="/__AUTHKIT_BASE__/assets/index-D0EIeyKK.js"></script>
11
+ <script type="module" crossorigin src="/__AUTHKIT_BASE__/assets/index-BqyJYRVX.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/__AUTHKIT_BASE__/assets/index-DTSmD4RU.css">
13
13
  </head>
14
14
  <body>
@@ -1,6 +1,7 @@
1
1
  import * as oidc from 'oidc-provider';
2
2
  import { pickModelAdapterClass } from '../adapters/factory.js';
3
3
  import { normalizeActiveOrg, readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
4
+ import { consoleSsoClientMetadata } from '../host/console_sso.js';
4
5
  import { findMcpResource, mcpAuthorizeMiddleware } from '../mcp/mcp_oauth.js';
5
6
  import { createDeviceSources } from './device_sources.js';
6
7
  import { createLogoutSources } from './logout_sources.js';
@@ -129,6 +130,11 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
129
130
  }
130
131
  : {};
131
132
  const provider = new oidc.Provider(config.issuer, {
133
+ // Client interno do login do console pelo IdP (ver `host/console_sso.ts`).
134
+ // Estático: não passa pelo adapter, então não aparece no console de clients.
135
+ ...(config.accountSession?.acceptIdpSession
136
+ ? { clients: [consoleSsoClientMetadata(config.issuer)] }
137
+ : {}),
132
138
  // O ator (`act`, RFC 8693) dos access tokens de impersonation — ver
133
139
  // `impersonationExtraClaims` em `token_exchange.ts`. Sem isto o token trocado
134
140
  // não se distingue de um token do próprio alvo no resource server.
@@ -28,7 +28,7 @@ export declare class OidcService {
28
28
  };
29
29
  /**
30
30
  * @internal Segredo derivado da app key para um uso (`label`) — HMAC dos nonces
31
- * DPoP do Poppy, por exemplo. Igual em todas as instâncias, sem expor a chave.
31
+ * DPoP de uma extension de personal agents, por exemplo. Igual em todas as instâncias, sem expor a chave.
32
32
  */
33
33
  deriveSecret(label: string): Buffer;
34
34
  /** Pathname do issuer sem barra final (ex.: `/oidc`). Vazio quando montado na raiz. */
@@ -44,7 +44,7 @@ export class OidcService {
44
44
  }
45
45
  /**
46
46
  * @internal Segredo derivado da app key para um uso (`label`) — HMAC dos nonces
47
- * DPoP do Poppy, por exemplo. Igual em todas as instâncias, sem expor a chave.
47
+ * DPoP de uma extension de personal agents, por exemplo. Igual em todas as instâncias, sem expor a chave.
48
48
  */
49
49
  deriveSecret(label) {
50
50
  return createHmac('sha256', this.#appKey).update(`authkit:${label}`).digest();
@@ -23,9 +23,15 @@
23
23
  * }
24
24
  * ```
25
25
  */
26
+ import type { AuthkitTable } from '../agents/extension.js';
26
27
  export interface EnsureSchemaOptions {
27
28
  /** Conexão Lucid a usar. Default: conexão primária. */
28
29
  connection?: string;
30
+ /**
31
+ * Tabelas de `personalAgents.extensions` (`tables` de cada uma), garantidas junto. O provider
32
+ * passa as do config no boot.
33
+ */
34
+ tables?: readonly AuthkitTable[];
29
35
  /**
30
36
  * Tabela da conta, onde a coluna opcional `login_methods` pertence. Vem do
31
37
  * metadado `accountStore.accountTable` (o provider repassa no boot). Undefined
@@ -51,6 +57,14 @@ export interface EnsureSchemaReport {
51
57
  ensured: boolean;
52
58
  };
53
59
  }
60
+ /**
61
+ * Garante tabelas lib-owned no formato {@link AuthkitTable} — o que `ensureAuthkitSchema` faz com as
62
+ * do authkit: cria as que faltam, adiciona colunas e índices novos, nunca dropa nem altera. Para
63
+ * uma extension expor o próprio `ensure*Schema()` para migrations do host. Estável no 1.x.
64
+ */
65
+ export declare function ensureAuthkitTables(db: any, tables: readonly AuthkitTable[], options?: {
66
+ connection?: string;
67
+ }): Promise<Pick<EnsureSchemaReport, 'created' | 'altered' | 'indexed'>>;
54
68
  /**
55
69
  * Garante que as tabelas do authkit existem e têm todas as colunas que esta
56
70
  * versão da lib espera. Aditivo e idempotente — seguro de rodar em todo boot
@@ -309,7 +309,7 @@ const TABLES = [
309
309
  name: 'auth_agent_registry',
310
310
  /**
311
311
  * Agentes liberados (`allowed`) ou bloqueados (`blocked`) pelo console admin,
312
- * somados ao `defineConfig` — PACT pelo `iss`, Poppy pelo `client_id`.
312
+ * somados ao `defineConfig` — PACT pelo `iss`; uma extension pelo identificador dela.
313
313
  */
314
314
  create: (t) => {
315
315
  t.string('id').primary();
@@ -333,135 +333,6 @@ const TABLES = [
333
333
  updated_at: (t) => t.dateTime('updated_at', { useTz: true, precision: 3 }).nullable(),
334
334
  },
335
335
  },
336
- /*
337
- * Personal Agent Protocol (Poppy). Os refresh tokens são grants de
338
- * `auth_agent_grants` (prefixo `pgrant_`) com o hash em
339
- * `auth_agent_refresh_tokens`; aqui ficam Sessions, access tokens, pedidos de
340
- * sign-in e os `jti` vistos. Mesmas regras de data das tabelas acima.
341
- */
342
- {
343
- name: 'auth_poppy_sessions',
344
- /**
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
- */
349
- create: (t) => {
350
- t.string('id').primary();
351
- t.string('client_id', 2048).notNullable();
352
- t.string('user_id').notNullable();
353
- t.string('agent_id').nullable();
354
- t.string('account_id').nullable().index();
355
- t.string('grant_id').nullable().index();
356
- t.text('scope').notNullable();
357
- t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable();
358
- t.dateTime('ended_at', { useTz: true, precision: 3 }).nullable();
359
- t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
360
- t.dateTime('updated_at', { useTz: true, precision: 3 }).notNullable();
361
- },
362
- columns: {
363
- client_id: (t) => t.string('client_id', 2048),
364
- user_id: (t) => t.string('user_id'),
365
- agent_id: (t) => t.string('agent_id').nullable(),
366
- account_id: (t) => t.string('account_id').nullable().index(),
367
- grant_id: (t) => t.string('grant_id').nullable().index(),
368
- scope: (t) => t.text('scope'),
369
- expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
370
- ended_at: (t) => t.dateTime('ended_at', { useTz: true, precision: 3 }).nullable(),
371
- created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
372
- updated_at: (t) => t.dateTime('updated_at', { useTz: true, precision: 3 }).nullable(),
373
- },
374
- },
375
- {
376
- name: 'auth_poppy_tokens',
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
- */
381
- create: (t) => {
382
- t.string('token_hash', 64).primary();
383
- t.string('session_id').notNullable().index();
384
- t.string('client_id', 2048).notNullable();
385
- t.string('user_id').notNullable();
386
- t.string('account_id').nullable();
387
- t.string('grant_id').nullable().index();
388
- t.text('scope').notNullable();
389
- t.string('resource', 2048).nullable();
390
- t.string('jkt', 64).nullable();
391
- t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable().index();
392
- t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
393
- },
394
- columns: {
395
- session_id: (t) => t.string('session_id').index(),
396
- client_id: (t) => t.string('client_id', 2048),
397
- user_id: (t) => t.string('user_id'),
398
- account_id: (t) => t.string('account_id').nullable(),
399
- grant_id: (t) => t.string('grant_id').nullable().index(),
400
- scope: (t) => t.text('scope'),
401
- resource: (t) => t.string('resource', 2048).nullable(),
402
- jkt: (t) => t.string('jkt', 64).nullable(),
403
- expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
404
- created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
405
- },
406
- },
407
- {
408
- name: 'auth_poppy_requests',
409
- /**
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).
412
- * Códigos só como hash. Linhas expiradas são apagadas no próximo pedido.
413
- */
414
- create: (t) => {
415
- t.string('id').primary();
416
- t.string('kind', 16).notNullable();
417
- t.string('code_hash', 64).nullable().unique();
418
- t.string('user_code', 16).nullable().unique();
419
- t.string('client_id', 2048).notNullable();
420
- t.string('session_id').nullable();
421
- t.string('account_id').nullable();
422
- t.text('requested_scope').notNullable();
423
- t.text('granted_scope').nullable();
424
- t.string('redirect_uri', 2048).nullable();
425
- t.string('code_challenge', 128).nullable();
426
- t.string('status', 16).notNullable();
427
- t.integer('attempts').notNullable().defaultTo(0);
428
- t.integer('interval_seconds').nullable();
429
- t.dateTime('last_polled_at', { useTz: true, precision: 3 }).nullable();
430
- t.text('data').nullable();
431
- t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable().index();
432
- t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
433
- },
434
- columns: {
435
- kind: (t) => t.string('kind', 16),
436
- code_hash: (t) => t.string('code_hash', 64).nullable(),
437
- user_code: (t) => t.string('user_code', 16).nullable(),
438
- client_id: (t) => t.string('client_id', 2048),
439
- session_id: (t) => t.string('session_id').nullable(),
440
- account_id: (t) => t.string('account_id').nullable(),
441
- requested_scope: (t) => t.text('requested_scope'),
442
- granted_scope: (t) => t.text('granted_scope').nullable(),
443
- redirect_uri: (t) => t.string('redirect_uri', 2048).nullable(),
444
- code_challenge: (t) => t.string('code_challenge', 128).nullable(),
445
- status: (t) => t.string('status', 16),
446
- attempts: (t) => t.integer('attempts').defaultTo(0),
447
- interval_seconds: (t) => t.integer('interval_seconds').nullable(),
448
- last_polled_at: (t) => t.dateTime('last_polled_at', { useTz: true, precision: 3 }).nullable(),
449
- data: (t) => t.text('data').nullable(),
450
- expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
451
- created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
452
- },
453
- },
454
- {
455
- name: 'auth_poppy_jtis',
456
- /** `jti` já aceitos (client assertions, Session JWTs, provas DPoP), até expirarem — anti-replay entre instâncias. */
457
- create: (t) => {
458
- t.string('key', 100).primary();
459
- t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable().index();
460
- },
461
- columns: {
462
- expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
463
- },
464
- },
465
336
  ];
466
337
  /** Linhas de um `rawQuery`, no formato de cada driver (pg, mysql2, sqlite). */
467
338
  function catalogRows(result) {
@@ -586,26 +457,9 @@ async function ensureIndex(conn, table, index) {
586
457
  throw error;
587
458
  }
588
459
  }
589
- /**
590
- * Garante que as tabelas do authkit existem e têm todas as colunas que esta
591
- * versão da lib espera. Aditivo e idempotente — seguro de rodar em todo boot
592
- * e dentro de migrations.
593
- *
594
- * `db` é o manager (`Database`, no boot) ou o client da própria migration
595
- * (`this.db`, um `QueryClient`/`TransactionClient`, usado como está).
596
- */
597
- export async function ensureAuthkitSchema(db, options = {}) {
598
- const conn = typeof db.connection !== 'function'
599
- ? db
600
- : options.connection
601
- ? db.connection(options.connection)
602
- : db.connection();
603
- const report = {
604
- created: [],
605
- altered: {},
606
- loginMethods: { table: options.accountTable ?? 'users', ensured: false },
607
- };
608
- for (const def of TABLES) {
460
+ /** Cria/completa as tabelas (o miolo de {@link ensureAuthkitSchema}). */
461
+ async function ensureTables(conn, tables, report) {
462
+ for (const def of tables) {
609
463
  if (!(await tableExists(conn, def.name))) {
610
464
  try {
611
465
  await conn.schema.createTable(def.name, def.create);
@@ -639,6 +493,45 @@ export async function ensureAuthkitSchema(db, options = {}) {
639
493
  report.indexed[def.name].push(index.name);
640
494
  }
641
495
  }
496
+ }
497
+ /**
498
+ * Garante tabelas lib-owned no formato {@link AuthkitTable} — o que `ensureAuthkitSchema` faz com as
499
+ * do authkit: cria as que faltam, adiciona colunas e índices novos, nunca dropa nem altera. Para
500
+ * uma extension expor o próprio `ensure*Schema()` para migrations do host. Estável no 1.x.
501
+ */
502
+ export async function ensureAuthkitTables(db, tables, options = {}) {
503
+ const conn = typeof db.connection !== 'function'
504
+ ? db
505
+ : options.connection
506
+ ? db.connection(options.connection)
507
+ : db.connection();
508
+ const report = {
509
+ created: [],
510
+ altered: {},
511
+ };
512
+ await ensureTables(conn, tables, report);
513
+ return report;
514
+ }
515
+ /**
516
+ * Garante que as tabelas do authkit existem e têm todas as colunas que esta
517
+ * versão da lib espera. Aditivo e idempotente — seguro de rodar em todo boot
518
+ * e dentro de migrations.
519
+ *
520
+ * `db` é o manager (`Database`, no boot) ou o client da própria migration
521
+ * (`this.db`, um `QueryClient`/`TransactionClient`, usado como está).
522
+ */
523
+ export async function ensureAuthkitSchema(db, options = {}) {
524
+ const conn = typeof db.connection !== 'function'
525
+ ? db
526
+ : options.connection
527
+ ? db.connection(options.connection)
528
+ : db.connection();
529
+ const report = {
530
+ created: [],
531
+ altered: {},
532
+ loginMethods: { table: options.accountTable ?? 'users', ensured: false },
533
+ };
534
+ await ensureTables(conn, [...TABLES, ...(options.tables ?? [])], report);
642
535
  // Coluna `login_methods` na tabela da CONTA — a tabela é HOST-owned (não está
643
536
  // em TABLES porque o nome/shape são decisão do app), mas a LIB consome a
644
537
  // coluna (LoginMethodsPreferenceCapability). Alguns deploys de host só migram
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.2",
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",
@@ -110,14 +110,14 @@
110
110
  "@simplewebauthn/browser": "14.0.0",
111
111
  "@simplewebauthn/server": "14.1.0",
112
112
  "jose": "6.2.15",
113
- "koa": "3.2.1",
113
+ "koa": "3.3.0",
114
114
  "koa-mount": "4.2.0",
115
115
  "oidc-provider": "9.13.3",
116
- "otplib": "13.5.0",
116
+ "otplib": "13.6.0",
117
117
  "qrcode": "1.5.4"
118
118
  },
119
119
  "devDependencies": {
120
- "@adonis-agora/authkit-react": "1.0.0-rc.0",
120
+ "@adonis-agora/authkit-react": "1.0.0-rc.1",
121
121
  "@adonis-agora/durable": "0.59.0",
122
122
  "@adonis-agora/telescope": "0.27.1",
123
123
  "@adonisjs/ally": "6.3.2",
@@ -140,7 +140,7 @@
140
140
  "@types/koa": "3.0.3",
141
141
  "@types/koa-mount": "4.0.5",
142
142
  "@types/luxon": "3.7.6",
143
- "@types/node": "26.6.5",
143
+ "@types/node": "26.6.6",
144
144
  "@types/qrcode": "1.5.6",
145
145
  "@types/react": "19.3.0",
146
146
  "@types/react-dom": "19.3.0",
@@ -1,50 +0,0 @@
1
- /**
2
- * Identidade do personal agent no Poppy (§4.1): o `client_id` é uma URL HTTPS
3
- * que devolve o documento de metadata do agente (OAuth Client ID Metadata
4
- * Document). Buscamos (à prova de SSRF), validamos, guardamos em cache e
5
- * importamos as chaves públicas do `jwks_uri`.
6
- */
7
- import { type JWTVerifyGetKey } from 'jose';
8
- import type { ResolvedPoppyConfig } from './config.js';
9
- import { type JsonFetcher } from './safe_fetch.js';
10
- export interface PoppyClient {
11
- clientId: string;
12
- /** `client_name`, ou o host do `client_id`. */
13
- name: string;
14
- logoUri: string | null;
15
- jwksUri: string;
16
- redirectUris: string[];
17
- extensions: Record<string, unknown>;
18
- /** Chaves públicas do `jwks_uri`. */
19
- keys: JWTVerifyGetKey;
20
- }
21
- /**
22
- * `redirect_uri` igual a uma entrada de `redirect_uris`; numa loopback redirect URI a porta não
23
- * conta (§4.2, RFC 8252 §7.3).
24
- */
25
- export declare function redirectUriAllowed(registered: string[], redirectUri: string): boolean;
26
- /** Valida um documento de metadata e devolve os campos usados. Lança `invalid_client`. */
27
- export declare function validateClientMetadata(fetchedFrom: string, doc: unknown, options?: {
28
- allowHttp?: boolean;
29
- }): Omit<PoppyClient, 'keys'>;
30
- export interface PoppyClientRegistryOptions {
31
- /** Busca de JSON. Default: {@link safeFetchJson}. */
32
- fetchJson?: JsonFetcher;
33
- now?: () => number;
34
- }
35
- /**
36
- * Resolve e valida agentes pelo `client_id`, com cache (respeita `max-age`,
37
- * entre 1 min e 24 h; falhas ficam 1 min em cache para que o mesmo `client_id`
38
- * não vire uma busca de saída por request).
39
- */
40
- export declare class PoppyClientRegistry {
41
- #private;
42
- constructor(cfg: ResolvedPoppyConfig, options?: PoppyClientRegistryOptions);
43
- /**
44
- * O agente, ou `invalid_client`: `client_id` não é HTTPS, bloqueado, não
45
- * registrado (quando o app exige registro), metadata inválida/inalcançável.
46
- */
47
- resolve(clientId: unknown): Promise<PoppyClient>;
48
- /** Nome de exibição sem falhar (console de conta): metadata em cache/buscada, ou o host. */
49
- displayName(clientId: string): Promise<string>;
50
- }
@@ -1,260 +0,0 @@
1
- /**
2
- * Identidade do personal agent no Poppy (§4.1): o `client_id` é uma URL HTTPS
3
- * que devolve o documento de metadata do agente (OAuth Client ID Metadata
4
- * Document). Buscamos (à prova de SSRF), validamos, guardamos em cache e
5
- * importamos as chaves públicas do `jwks_uri`.
6
- */
7
- import { createLocalJWKSet } from 'jose';
8
- import { PoppyError } from './errors.js';
9
- import { insecureDevFetchJson, SafeFetchError, safeFetchJson, } from './safe_fetch.js';
10
- const MIN_TTL_MS = 60 * 1000;
11
- const MAX_TTL_MS = 24 * 3600 * 1000;
12
- const DEFAULT_TTL_MS = 3600 * 1000;
13
- const FAILURE_TTL_MS = 60 * 1000;
14
- const MAX_CACHED = 1000;
15
- /** Releitura do JWKS quando o `kid` não está no conjunto em cache — no máximo a cada 30 s. */
16
- const JWKS_REFRESH_COOLDOWN_MS = 30 * 1000;
17
- function hostOf(url) {
18
- try {
19
- return new URL(url).host.toLowerCase();
20
- }
21
- catch {
22
- return null;
23
- }
24
- }
25
- function isHttpsUrl(value, allowHttp = false) {
26
- if (typeof value !== 'string')
27
- return false;
28
- try {
29
- const url = new URL(value);
30
- const schemeOk = url.protocol === 'https:' || (allowHttp && url.protocol === 'http:');
31
- return schemeOk && !url.username && !url.password && !url.hash;
32
- }
33
- catch {
34
- return false;
35
- }
36
- }
37
- /** Loopback redirect URI (RFC 8252 §7.3): `http`, IP de loopback literal, sem fragmento. */
38
- function isLoopbackRedirect(value) {
39
- if (typeof value !== 'string')
40
- return false;
41
- try {
42
- const url = new URL(value);
43
- return (url.protocol === 'http:' &&
44
- (url.hostname === '127.0.0.1' || url.hostname === '[::1]') &&
45
- !url.username &&
46
- !url.password &&
47
- !url.hash);
48
- }
49
- catch {
50
- return false;
51
- }
52
- }
53
- /**
54
- * `redirect_uri` igual a uma entrada de `redirect_uris`; numa loopback redirect URI a porta não
55
- * conta (§4.2, RFC 8252 §7.3).
56
- */
57
- export function redirectUriAllowed(registered, redirectUri) {
58
- if (registered.includes(redirectUri))
59
- return true;
60
- if (!isLoopbackRedirect(redirectUri))
61
- return false;
62
- const withoutPort = (value) => {
63
- const url = new URL(value);
64
- url.port = '';
65
- return url.toString();
66
- };
67
- return registered.some((r) => isLoopbackRedirect(r) && withoutPort(r) === withoutPort(redirectUri));
68
- }
69
- function ttlFrom(maxAge) {
70
- if (maxAge === null)
71
- return DEFAULT_TTL_MS;
72
- return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, maxAge * 1000));
73
- }
74
- function setBounded(map, key, value) {
75
- map.delete(key);
76
- map.set(key, value);
77
- if (map.size > MAX_CACHED)
78
- map.delete(map.keys().next().value);
79
- }
80
- /** Valida um documento de metadata e devolve os campos usados. Lança `invalid_client`. */
81
- export function validateClientMetadata(fetchedFrom, doc, options = {}) {
82
- const allowHttp = options.allowHttp === true;
83
- const bad = (why) => new PoppyError('invalid_client', `client metadata: ${why}`);
84
- if (!doc || typeof doc !== 'object' || Array.isArray(doc))
85
- throw bad('not a JSON object');
86
- const d = doc;
87
- if (d.client_id !== fetchedFrom)
88
- throw bad('client_id must equal the URL it was fetched from');
89
- const host = hostOf(fetchedFrom);
90
- if (!isHttpsUrl(d.jwks_uri, allowHttp) || hostOf(d.jwks_uri) !== host) {
91
- throw bad('jwks_uri must be an HTTPS URL on the same domain as client_id');
92
- }
93
- const redirects = d.redirect_uris ?? [];
94
- if (!Array.isArray(redirects))
95
- throw bad('redirect_uris must be an array');
96
- for (const uri of redirects) {
97
- if (isLoopbackRedirect(uri))
98
- continue;
99
- if (!isHttpsUrl(uri, allowHttp) || hostOf(uri) !== host) {
100
- throw bad('every redirect_uri must be an HTTPS URL on the same domain as client_id, or a loopback URI');
101
- }
102
- }
103
- if (d.token_endpoint_auth_method !== 'private_key_jwt') {
104
- throw bad('token_endpoint_auth_method must be private_key_jwt');
105
- }
106
- if (d.jwks !== undefined)
107
- throw bad('keys are published at jwks_uri only');
108
- const name = typeof d.client_name === 'string' && d.client_name.trim() ? d.client_name : host;
109
- const extensions = d.extensions && typeof d.extensions === 'object' && !Array.isArray(d.extensions)
110
- ? d.extensions
111
- : {};
112
- return {
113
- clientId: fetchedFrom,
114
- name: name.slice(0, 200),
115
- logoUri: isHttpsUrl(d.logo_uri, allowHttp) ? d.logo_uri : null,
116
- jwksUri: d.jwks_uri,
117
- redirectUris: redirects,
118
- extensions,
119
- };
120
- }
121
- /** Só chaves públicas (§4.1) e pelo menos uma. */
122
- function validateJwks(doc) {
123
- const keys = doc?.keys;
124
- if (!Array.isArray(keys) || keys.length === 0) {
125
- throw new PoppyError('invalid_client', 'jwks_uri must serve a non-empty JWK set');
126
- }
127
- for (const key of keys) {
128
- if (!key || typeof key !== 'object')
129
- throw new PoppyError('invalid_client', 'invalid JWK');
130
- const k = key;
131
- if ('d' in k || 'p' in k || 'q' in k || k.kty === 'oct') {
132
- throw new PoppyError('invalid_client', 'jwks_uri must contain only public keys');
133
- }
134
- }
135
- return { keys: keys };
136
- }
137
- /**
138
- * Resolve e valida agentes pelo `client_id`, com cache (respeita `max-age`,
139
- * entre 1 min e 24 h; falhas ficam 1 min em cache para que o mesmo `client_id`
140
- * não vire uma busca de saída por request).
141
- */
142
- export class PoppyClientRegistry {
143
- #cfg;
144
- #fetch;
145
- #now;
146
- #clients = new Map();
147
- #jwks = new Map();
148
- constructor(cfg, options = {}) {
149
- this.#cfg = cfg;
150
- this.#fetch =
151
- options.fetchJson ??
152
- (cfg.allowInsecureClientIds
153
- ? (url) => insecureDevFetchJson(url)
154
- : (url) => safeFetchJson(url));
155
- this.#now = options.now ?? Date.now;
156
- }
157
- /**
158
- * O agente, ou `invalid_client`: `client_id` não é HTTPS, bloqueado, não
159
- * registrado (quando o app exige registro), metadata inválida/inalcançável.
160
- */
161
- async resolve(clientId) {
162
- if (!isHttpsUrl(clientId, this.#cfg.allowInsecureClientIds)) {
163
- throw new PoppyError('invalid_client', 'client_id must be an HTTPS URL');
164
- }
165
- if (await this.#cfg.clients.isBlocked(clientId)) {
166
- throw new PoppyError('invalid_client', 'client_id is blocked');
167
- }
168
- if (this.#cfg.clients.requireRegistration &&
169
- !(await this.#cfg.clients.isRegistered(clientId))) {
170
- throw new PoppyError('invalid_client', 'client_id is not registered');
171
- }
172
- const meta = await this.#metadata(clientId);
173
- return { ...meta, keys: this.#keySet(meta.jwksUri) };
174
- }
175
- /** Nome de exibição sem falhar (console de conta): metadata em cache/buscada, ou o host. */
176
- async displayName(clientId) {
177
- try {
178
- return (await this.#metadata(clientId)).name;
179
- }
180
- catch {
181
- return hostOf(clientId) ?? clientId;
182
- }
183
- }
184
- #metadata(clientId) {
185
- const now = this.#now();
186
- const cached = this.#clients.get(clientId);
187
- if (cached && cached.expiresAt > now)
188
- return cached.value;
189
- const entry = {
190
- value: Promise.resolve(null),
191
- expiresAt: now + FAILURE_TTL_MS,
192
- };
193
- entry.value = (async () => {
194
- let fetched;
195
- try {
196
- fetched = await this.#fetch(clientId);
197
- }
198
- catch (error) {
199
- const why = error instanceof SafeFetchError ? error.message : 'unreachable';
200
- throw new PoppyError('invalid_client', `client metadata: ${why}`);
201
- }
202
- const meta = validateClientMetadata(clientId, fetched.body, {
203
- allowHttp: this.#cfg.allowInsecureClientIds,
204
- });
205
- entry.expiresAt = this.#now() + ttlFrom(fetched.maxAge);
206
- return meta;
207
- })();
208
- entry.value.catch(() => {
209
- entry.expiresAt = this.#now() + FAILURE_TTL_MS;
210
- });
211
- setBounded(this.#clients, clientId, entry);
212
- return entry.value;
213
- }
214
- #fetchJwks(jwksUri, force) {
215
- const now = this.#now();
216
- const cached = this.#jwks.get(jwksUri);
217
- if (cached && cached.expiresAt > now && !force)
218
- return cached.value;
219
- if (cached && force && now - cached.fetchedAt < JWKS_REFRESH_COOLDOWN_MS)
220
- return cached.value;
221
- const entry = {
222
- value: Promise.resolve({ keys: [] }),
223
- expiresAt: now + FAILURE_TTL_MS,
224
- fetchedAt: now,
225
- };
226
- entry.value = (async () => {
227
- let fetched;
228
- try {
229
- fetched = await this.#fetch(jwksUri);
230
- }
231
- catch (error) {
232
- const why = error instanceof SafeFetchError ? error.message : 'unreachable';
233
- throw new PoppyError('invalid_client', `jwks_uri: ${why}`);
234
- }
235
- const jwks = validateJwks(fetched.body);
236
- entry.expiresAt = this.#now() + ttlFrom(fetched.maxAge);
237
- return jwks;
238
- })();
239
- entry.value.catch(() => {
240
- entry.expiresAt = this.#now() + FAILURE_TTL_MS;
241
- });
242
- setBounded(this.#jwks, jwksUri, entry);
243
- return entry.value;
244
- }
245
- /** `JWTVerifyGetKey` que relê o JWKS uma vez quando o `kid` é desconhecido (rotação). */
246
- #keySet(jwksUri) {
247
- return async (header, token) => {
248
- const jwks = await this.#fetchJwks(jwksUri, false);
249
- try {
250
- return await createLocalJWKSet(jwks)(header, token);
251
- }
252
- catch (error) {
253
- const refreshed = await this.#fetchJwks(jwksUri, true);
254
- if (refreshed === jwks)
255
- throw error;
256
- return createLocalJWKSet(refreshed)(header, token);
257
- }
258
- };
259
- }
260
- }