@adonis-agora/authkit-server 0.76.0 → 0.78.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 (88) hide show
  1. package/README.md +149 -0
  2. package/build/host/views/agents/consent.edge +13 -2
  3. package/build/host/views/agents/done.edge +3 -0
  4. package/build/host/views/mfa-challenge.edge +42 -3
  5. package/build/index.d.ts +19 -1
  6. package/build/index.js +13 -1
  7. package/build/providers/authkit_server_provider.js +15 -1
  8. package/build/src/accounts/account_store.d.ts +2 -0
  9. package/build/src/accounts/lucid_account_store.js +2 -0
  10. package/build/src/adapters/adapter_contract.d.ts +2 -0
  11. package/build/src/adapters/database_adapter.d.ts +1 -0
  12. package/build/src/adapters/database_adapter.js +26 -0
  13. package/build/src/adapters/redis_adapter.d.ts +1 -0
  14. package/build/src/adapters/redis_adapter.js +30 -0
  15. package/build/src/agents/config.d.ts +20 -3
  16. package/build/src/agents/config.js +19 -4
  17. package/build/src/agents/delegation_service.js +18 -8
  18. package/build/src/agents/middleware.js +2 -1
  19. package/build/src/agents/poppy/client_registry.d.ts +45 -0
  20. package/build/src/agents/poppy/client_registry.js +226 -0
  21. package/build/src/agents/poppy/config.d.ts +362 -0
  22. package/build/src/agents/poppy/config.js +201 -0
  23. package/build/src/agents/poppy/dpop.d.ts +61 -0
  24. package/build/src/agents/poppy/dpop.js +158 -0
  25. package/build/src/agents/poppy/errors.d.ts +20 -0
  26. package/build/src/agents/poppy/errors.js +22 -0
  27. package/build/src/agents/poppy/replay.d.ts +25 -0
  28. package/build/src/agents/poppy/replay.js +106 -0
  29. package/build/src/agents/poppy/resource.d.ts +95 -0
  30. package/build/src/agents/poppy/resource.js +125 -0
  31. package/build/src/agents/poppy/safe_fetch.d.ts +48 -0
  32. package/build/src/agents/poppy/safe_fetch.js +256 -0
  33. package/build/src/agents/poppy/service.d.ts +280 -0
  34. package/build/src/agents/poppy/service.js +1452 -0
  35. package/build/src/agents/poppy/store.d.ts +109 -0
  36. package/build/src/agents/poppy/store.js +260 -0
  37. package/build/src/agents/poppy/web_session.d.ts +32 -0
  38. package/build/src/agents/poppy/web_session.js +104 -0
  39. package/build/src/agents/protocol.d.ts +8 -4
  40. package/build/src/agents/runtime.d.ts +17 -1
  41. package/build/src/agents/runtime.js +138 -13
  42. package/build/src/define_config.d.ts +24 -0
  43. package/build/src/define_config.js +14 -0
  44. package/build/src/host/account_api/account_api_controller.d.ts +12 -1
  45. package/build/src/host/account_api/account_api_controller.js +10 -5
  46. package/build/src/host/admin_sessions_service.js +8 -5
  47. package/build/src/host/auth_host_config.d.ts +2 -1
  48. package/build/src/host/controllers/account_apps_controller.js +13 -3
  49. package/build/src/host/controllers/account_security_controller.js +8 -4
  50. package/build/src/host/controllers/interaction_controller.d.ts +13 -1
  51. package/build/src/host/controllers/interaction_controller.js +352 -24
  52. package/build/src/host/controllers/poppy_consent_controller.d.ts +19 -0
  53. package/build/src/host/controllers/poppy_consent_controller.js +173 -0
  54. package/build/src/host/controllers/poppy_controller.d.ts +25 -0
  55. package/build/src/host/controllers/poppy_controller.js +194 -0
  56. package/build/src/host/csrf.d.ts +7 -0
  57. package/build/src/host/csrf.js +16 -1
  58. package/build/src/host/custom_login.d.ts +36 -0
  59. package/build/src/host/custom_login.js +76 -0
  60. package/build/src/host/custom_login_completion.d.ts +4 -0
  61. package/build/src/host/custom_login_completion.js +33 -0
  62. package/build/src/host/custom_mfa.d.ts +77 -0
  63. package/build/src/host/custom_mfa.js +268 -0
  64. package/build/src/host/i18n.d.ts +8 -0
  65. package/build/src/host/i18n.js +14 -6
  66. package/build/src/host/idp_session_bridge.d.ts +5 -3
  67. package/build/src/host/idp_session_bridge.js +5 -3
  68. package/build/src/host/oidc_rp_guard.d.ts +5 -1
  69. package/build/src/host/oidc_rp_guard.js +15 -2
  70. package/build/src/host/persistent_rp_session.d.ts +8 -0
  71. package/build/src/host/persistent_rp_session.js +74 -0
  72. package/build/src/host/poppy_session_key.d.ts +9 -0
  73. package/build/src/host/poppy_session_key.js +9 -0
  74. package/build/src/host/register_auth_host.js +72 -0
  75. package/build/src/host/whatsapp_code_sender.d.ts +20 -0
  76. package/build/src/host/whatsapp_code_sender.js +9 -0
  77. package/build/src/host/whatsapp_senders/http.d.ts +5 -0
  78. package/build/src/host/whatsapp_senders/http.js +33 -0
  79. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
  80. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
  81. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
  82. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
  83. package/build/src/provider/oidc_service.d.ts +5 -0
  84. package/build/src/provider/oidc_service.js +15 -4
  85. package/build/src/schema/ensure.js +124 -0
  86. package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
  87. package/package.json +2 -1
  88. package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
@@ -8,6 +8,7 @@ import { adminApiGuard } from './admin_api/admin_api_guard.js';
8
8
  import { normalizeAdminApiPrefix, normalizeAdminPrefix, setAdminApiPrefix, setAdminPrefix, } from './admin_prefix.js';
9
9
  import { getAuthHostConfig, markAuthHostAutoMounted, wasAuthHostAutoMounted, } from './auth_host_config.js';
10
10
  import { ensureConsoleSession } from './idp_session_bridge.js';
11
+ import { POPPY_WEB_SESSION_KEY } from './poppy_session_key.js';
11
12
  import { createAuthThrottles } from './rate_limit.js';
12
13
  import { redirectExact } from './redirect_exact.js';
13
14
  import { requestPathWithQuery } from './request_url.js';
@@ -113,7 +114,16 @@ function buildLoginRedirect(ctx, extra) {
113
114
  * classe NÃO era aplicada em runtime num grupo, deixando /account/tokens e
114
115
  * /account/mfa acessíveis sem sessão.
115
116
  */
117
+ /** A sessão é do navegador de um personal agent Poppy (§5)? */
118
+ function isPoppyBrowser(ctx) {
119
+ const marker = ctx.session?.get?.(POPPY_WEB_SESSION_KEY);
120
+ return typeof marker === 'object' && marker !== null && typeof marker.sessionId === 'string';
121
+ }
116
122
  const accountGuard = async (ctx, next) => {
123
+ // O navegador de um personal agent Poppy (§5) não entra no console de conta nem
124
+ // nas telas de consentimento: a sessão é do agente, não do usuário.
125
+ if (isPoppyBrowser(ctx))
126
+ return ctx.response.status(403).send('');
117
127
  // Sessão do console — ou, com `accountSession.acceptIdpSession`, a do IdP (SSO).
118
128
  if (!(await ensureConsoleSession(ctx))) {
119
129
  return redirectExact(ctx.response, buildLoginRedirect(ctx));
@@ -145,6 +155,8 @@ export const adminGuard = async (ctx, next) => {
145
155
  if (!cfg.admin.enabled) {
146
156
  return ctx.response.notFound();
147
157
  }
158
+ if (isPoppyBrowser(ctx))
159
+ return ctx.response.status(403).send('');
148
160
  // Com `accountSession.acceptIdpSession`, a sessão do IdP também abre o console.
149
161
  await ensureConsoleSession(ctx);
150
162
  const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
@@ -187,6 +199,8 @@ const C = {
187
199
  accountConfirm: () => import('./controllers/account_confirm_controller.js'),
188
200
  agentOAuth: () => import('./controllers/agent_oauth_controller.js'),
189
201
  agentConsent: () => import('./controllers/agent_consent_controller.js'),
202
+ poppy: () => import('./controllers/poppy_controller.js'),
203
+ poppyConsent: () => import('./controllers/poppy_consent_controller.js'),
190
204
  webauthnAsset: () => import('./controllers/webauthn_asset_controller.js'),
191
205
  logoutAsset: () => import('./controllers/logout_asset_controller.js'),
192
206
  passkeyAutofillAsset: () => import('./controllers/passkey_autofill_asset_controller.js'),
@@ -441,6 +455,18 @@ export function registerAuthHost(router, opts = {}) {
441
455
  router.post('/auth/interaction/:uid/identifier', [C.interaction, 'identifier']);
442
456
  withLogin(router.post('/auth/interaction/:uid/login', [C.interaction, 'login']));
443
457
  withLogin(router.post('/auth/interaction/:uid/mfa', [C.interaction, 'mfaVerify']));
458
+ const customMfaBeginRoute = router.post('/auth/interaction/:uid/mfa/custom/:method/begin', [
459
+ C.interaction,
460
+ 'customMfaBegin',
461
+ ]);
462
+ withLogin(customMfaBeginRoute);
463
+ customMfaBeginRoute.as('authkit.mfa.custom.begin');
464
+ const customMfaVerifyRoute = router.post('/auth/interaction/:uid/mfa/custom/:method/verify', [
465
+ C.interaction,
466
+ 'customMfaVerify',
467
+ ]);
468
+ withLogin(customMfaVerifyRoute);
469
+ customMfaVerifyRoute.as('authkit.mfa.custom.verify');
444
470
  // Troca de senha obrigatória quando a senha expirou (password expiration gate).
445
471
  withLogin(router.post('/auth/interaction/:uid/password-expired', [
446
472
  C.interaction,
@@ -496,6 +522,52 @@ export function registerAuthHost(router, opts = {}) {
496
522
  })
497
523
  .use([accountGuard]);
498
524
  }
525
+ // Personal Agent Protocol (Poppy): descoberta, authorization server (token,
526
+ // revogação, device authorization), Mediated Sign-In e sessão de navegador —
527
+ // server-to-server ou POST cross-site do navegador do agente (sem CSRF, ver
528
+ // `authkitCsrfExceptions`). As telas do usuário (authorize/device) exigem a
529
+ // sessão de conta e mantêm o CSRF.
530
+ const poppyPrefix = hostCfg?.personalAgents?.poppyPrefix;
531
+ if (poppyPrefix) {
532
+ // Nomes explícitos: o Router nomeia pela dupla controller.método, e as duas URLs da metadata
533
+ // (RFC 8414 na raiz e debaixo do issuer) usam o mesmo — "poppy.metadata already exists" no boot.
534
+ router.get('/.well-known/poppy.json', [C.poppy, 'discovery']).as('authkit.poppy.discovery');
535
+ router
536
+ .get(`/.well-known/oauth-authorization-server${poppyPrefix}`, [C.poppy, 'metadata'])
537
+ .as('authkit.poppy.metadata');
538
+ router
539
+ .get(`${poppyPrefix}/.well-known/oauth-authorization-server`, [C.poppy, 'metadata'])
540
+ .as('authkit.poppy.metadata_under_issuer');
541
+ router.post(`${poppyPrefix}/oauth/token`, [C.poppy, 'token']).as('authkit.poppy.token');
542
+ router.post(`${poppyPrefix}/oauth/revoke`, [C.poppy, 'revoke']).as('authkit.poppy.revoke');
543
+ router
544
+ .post(`${poppyPrefix}/oauth/device`, [C.poppy, 'deviceAuthorization'])
545
+ .as('authkit.poppy.device_authorization');
546
+ // Credenciais e códigos de uso único: throttle por IP (§4.7 exige rate limit).
547
+ withLogin(router.post(`${poppyPrefix}/sign-in`, [C.poppy, 'mediatedStart']).as('authkit.poppy.sign_in'));
548
+ withOtpLogin(router
549
+ .post(`${poppyPrefix}/sign-in/:id`, [C.poppy, 'mediatedCode'])
550
+ .as('authkit.poppy.sign_in_code'));
551
+ router
552
+ .post(`${poppyPrefix}/browser-session`, [C.poppy, 'browserSession'])
553
+ .as('authkit.poppy.browser_session');
554
+ router
555
+ .group(() => {
556
+ router
557
+ .get(`${poppyPrefix}/oauth/authorize`, [C.poppyConsent, 'authorize'])
558
+ .as('authkit.poppy.authorize');
559
+ router
560
+ .post(`${poppyPrefix}/oauth/authorize`, [C.poppyConsent, 'decideAuthorization'])
561
+ .as('authkit.poppy.authorize_decide');
562
+ withOtpLogin(router
563
+ .get(`${poppyPrefix}/device`, [C.poppyConsent, 'device'])
564
+ .as('authkit.poppy.device'));
565
+ withOtpLogin(router
566
+ .post(`${poppyPrefix}/device`, [C.poppyConsent, 'decideDevice'])
567
+ .as('authkit.poppy.device_decide'));
568
+ })
569
+ .use([accountGuard]);
570
+ }
499
571
  // Paths do console de conta (configuráveis/localizáveis via `accountRoutes`).
500
572
  // As TELAS vêm de `accountPath(key)` (prefixo + segmento configurável); os
501
573
  // action-subpaths concatenados (`/password`, `/enroll`, ...) são FIXOS —
@@ -0,0 +1,20 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /** Provider-neutral delivery input. Only the host generates and verifies the OTP. */
3
+ export interface WhatsappCodeInput {
4
+ phone: string;
5
+ code: string;
6
+ locale: string;
7
+ expiresInSeconds: number;
8
+ /** Optional host-localized copy for transports that send free-form messages. */
9
+ text?: string;
10
+ }
11
+ /** Implement this interface to support any WhatsApp provider. Reject on failed delivery. */
12
+ export interface WhatsappCodeSender {
13
+ sendCode(input: WhatsappCodeInput): Promise<void>;
14
+ }
15
+ /** A configured instance, or an injectable class resolved by the request container. */
16
+ export type WhatsappCodeSenderConstructor = new (...args: never[]) => WhatsappCodeSender;
17
+ export type WhatsappCodeSenderBinding = WhatsappCodeSender | WhatsappCodeSenderConstructor;
18
+ export declare function resolveWhatsappCodeSender(resolver: Pick<HttpContext['containerResolver'], 'make'>, binding: WhatsappCodeSenderBinding): Promise<WhatsappCodeSender>;
19
+ export { MetaWhatsappCodeSender, type MetaWhatsappCodeSenderOptions, } from './whatsapp_senders/meta_whatsapp_code_sender.js';
20
+ export { WhatsmiauCodeSender, type WhatsmiauCodeSenderOptions, } from './whatsapp_senders/whatsmiau_code_sender.js';
@@ -0,0 +1,9 @@
1
+ export async function resolveWhatsappCodeSender(resolver, binding) {
2
+ const sender = typeof binding === 'function' ? await resolver.make(binding) : binding;
3
+ if (!sender || typeof sender.sendCode !== 'function') {
4
+ throw new Error('Invalid WhatsApp code sender: expected sendCode(input)');
5
+ }
6
+ return sender;
7
+ }
8
+ export { MetaWhatsappCodeSender, } from './whatsapp_senders/meta_whatsapp_code_sender.js';
9
+ export { WhatsmiauCodeSender, } from './whatsapp_senders/whatsmiau_code_sender.js';
@@ -0,0 +1,5 @@
1
+ /** Never include configuration values in validation errors. */
2
+ export declare function requireString(value: string, field: string): void;
3
+ export declare function recipientPhone(phone: string): string;
4
+ /** Response bodies and fetch causes can contain credentials and OTPs: discard both. */
5
+ export declare function postJson(provider: string, url: URL, headers: Record<string, string>, body: unknown): Promise<void>;
@@ -0,0 +1,33 @@
1
+ /** Never include configuration values in validation errors. */
2
+ export function requireString(value, field) {
3
+ if (typeof value !== 'string' || !value.trim() || /[\r\n]/.test(value)) {
4
+ throw new Error(`Invalid WhatsApp sender ${field}`);
5
+ }
6
+ }
7
+ export function recipientPhone(phone) {
8
+ if (typeof phone !== 'string' || !/^\+?[1-9]\d{6,14}$/.test(phone)) {
9
+ throw new Error('Invalid WhatsApp recipient phone');
10
+ }
11
+ return phone.replace(/^\+/, '');
12
+ }
13
+ /** Response bodies and fetch causes can contain credentials and OTPs: discard both. */
14
+ export async function postJson(provider, url, headers, body) {
15
+ let response;
16
+ try {
17
+ response = await fetch(url, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json', ...headers },
20
+ body: JSON.stringify(body),
21
+ signal: AbortSignal.timeout(15_000),
22
+ redirect: 'error',
23
+ });
24
+ }
25
+ catch {
26
+ throw new Error(`WhatsApp code delivery failed (${provider})`);
27
+ }
28
+ // Do not parse or log the body, even when a provider echoes the submitted code.
29
+ void response.body?.cancel().catch(() => { });
30
+ if (!response.ok) {
31
+ throw new Error(`WhatsApp code delivery failed (${provider}, HTTP ${response.status})`);
32
+ }
33
+ }
@@ -0,0 +1,20 @@
1
+ import type { WhatsappCodeInput, WhatsappCodeSender } from '../whatsapp_code_sender.js';
2
+ export interface MetaWhatsappCodeSenderOptions {
3
+ accessToken: string;
4
+ phoneNumberId: string;
5
+ /** Explicit supported Graph API version, for example v23.0. */
6
+ apiVersion: string;
7
+ /** An approved authentication template with an OTP URL button. */
8
+ templateName: string;
9
+ /** Overrides the host locale when the template uses a different language. */
10
+ languageCode?: string;
11
+ }
12
+ /** Meta Cloud API authentication template transport (copy-code / OTP URL button). */
13
+ export declare class MetaWhatsappCodeSender implements WhatsappCodeSender {
14
+ private readonly accessToken;
15
+ private readonly endpoint;
16
+ private readonly templateName;
17
+ private readonly languageCode?;
18
+ constructor(options: MetaWhatsappCodeSenderOptions);
19
+ sendCode(input: WhatsappCodeInput): Promise<void>;
20
+ }
@@ -0,0 +1,51 @@
1
+ import { postJson, recipientPhone, requireString } from './http.js';
2
+ const LANGUAGE_CODES = { 'pt-BR': 'pt_BR', en: 'en_US', es: 'es' };
3
+ /** Meta Cloud API authentication template transport (copy-code / OTP URL button). */
4
+ export class MetaWhatsappCodeSender {
5
+ accessToken;
6
+ endpoint;
7
+ templateName;
8
+ languageCode;
9
+ constructor(options) {
10
+ requireString(options.accessToken, 'accessToken');
11
+ if (typeof options.apiVersion !== 'string' || !/^v\d+\.\d+$/.test(options.apiVersion)) {
12
+ throw new Error('Invalid WhatsApp sender apiVersion');
13
+ }
14
+ if (typeof options.phoneNumberId !== 'string' || !/^\d+$/.test(options.phoneNumberId)) {
15
+ throw new Error('Invalid WhatsApp sender phoneNumberId');
16
+ }
17
+ if (typeof options.templateName !== 'string' || !/^[a-z0-9_]+$/.test(options.templateName)) {
18
+ throw new Error('Invalid WhatsApp sender templateName');
19
+ }
20
+ if (options.languageCode !== undefined) {
21
+ requireString(options.languageCode, 'languageCode');
22
+ }
23
+ this.endpoint = new URL(`https://graph.facebook.com/${options.apiVersion}/${options.phoneNumberId}/messages`);
24
+ this.accessToken = options.accessToken;
25
+ this.templateName = options.templateName;
26
+ this.languageCode = options.languageCode;
27
+ }
28
+ async sendCode(input) {
29
+ await postJson('Meta', this.endpoint, { Authorization: `Bearer ${this.accessToken}` }, {
30
+ messaging_product: 'whatsapp',
31
+ recipient_type: 'individual',
32
+ to: recipientPhone(input.phone),
33
+ type: 'template',
34
+ template: {
35
+ name: this.templateName,
36
+ language: {
37
+ code: this.languageCode ?? LANGUAGE_CODES[input.locale] ?? input.locale.replace(/-/g, '_'),
38
+ },
39
+ components: [
40
+ { type: 'body', parameters: [{ type: 'text', text: input.code }] },
41
+ {
42
+ type: 'button',
43
+ sub_type: 'url',
44
+ index: '0',
45
+ parameters: [{ type: 'text', text: input.code }],
46
+ },
47
+ ],
48
+ },
49
+ });
50
+ }
51
+ }
@@ -0,0 +1,14 @@
1
+ import type { WhatsappCodeInput, WhatsappCodeSender } from '../whatsapp_code_sender.js';
2
+ export interface WhatsmiauCodeSenderOptions {
3
+ apiKey: string;
4
+ instanceName: string;
5
+ /** Includes the API version path. Defaults to Whatsmiau Cloud's /v2 endpoint. */
6
+ baseUrl?: string;
7
+ }
8
+ /** Whatsmiau Cloud v2 / Evolution-compatible free-form text transport. */
9
+ export declare class WhatsmiauCodeSender implements WhatsappCodeSender {
10
+ private readonly apiKey;
11
+ private readonly endpoint;
12
+ constructor(options: WhatsmiauCodeSenderOptions);
13
+ sendCode(input: WhatsappCodeInput): Promise<void>;
14
+ }
@@ -0,0 +1,36 @@
1
+ import { postJson, recipientPhone, requireString } from './http.js';
2
+ /** Whatsmiau Cloud v2 / Evolution-compatible free-form text transport. */
3
+ export class WhatsmiauCodeSender {
4
+ apiKey;
5
+ endpoint;
6
+ constructor(options) {
7
+ requireString(options.apiKey, 'apiKey');
8
+ if (typeof options.instanceName !== 'string' ||
9
+ !/^[A-Za-z0-9_-]+$/.test(options.instanceName)) {
10
+ throw new Error('Invalid WhatsApp sender instanceName');
11
+ }
12
+ let baseUrl;
13
+ try {
14
+ baseUrl = new URL(options.baseUrl ?? 'https://api.whatsmiau.dev/v2');
15
+ }
16
+ catch {
17
+ throw new Error('Invalid WhatsApp sender baseUrl');
18
+ }
19
+ if (!['http:', 'https:'].includes(baseUrl.protocol) ||
20
+ baseUrl.username ||
21
+ baseUrl.password ||
22
+ baseUrl.search ||
23
+ baseUrl.hash) {
24
+ throw new Error('Invalid WhatsApp sender baseUrl');
25
+ }
26
+ baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/, '')}/message/sendText/${options.instanceName}`;
27
+ this.endpoint = baseUrl;
28
+ this.apiKey = options.apiKey;
29
+ }
30
+ async sendCode(input) {
31
+ await postJson('Whatsmiau', this.endpoint, { apikey: this.apiKey }, {
32
+ number: recipientPhone(input.phone),
33
+ text: input.text ?? input.code,
34
+ });
35
+ }
36
+ }
@@ -25,6 +25,11 @@ export declare class OidcService {
25
25
  get signingJwks(): {
26
26
  keys: Record<string, any>[];
27
27
  };
28
+ /**
29
+ * @internal Segredo derivado da app key para um uso (`label`) — HMAC dos nonces
30
+ * DPoP do Poppy, por exemplo. Igual em todas as instâncias, sem expor a chave.
31
+ */
32
+ deriveSecret(label: string): Buffer;
28
33
  /** Pathname do issuer sem barra final (ex.: `/oidc`). Vazio quando montado na raiz. */
29
34
  readonly mountPath: string;
30
35
  readonly recorder: MetricsRecorder;
@@ -1,4 +1,4 @@
1
- import { timingSafeEqual } from 'node:crypto';
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
2
  import { NoopRecorder } from '@adonis-agora/authkit-core';
3
3
  import Koa from 'koa';
4
4
  import mount from 'koa-mount';
@@ -41,6 +41,13 @@ export class OidcService {
41
41
  get signingJwks() {
42
42
  return this.#signingJwks;
43
43
  }
44
+ /**
45
+ * @internal Segredo derivado da app key para um uso (`label`) — HMAC dos nonces
46
+ * DPoP do Poppy, por exemplo. Igual em todas as instâncias, sem expor a chave.
47
+ */
48
+ deriveSecret(label) {
49
+ return createHmac('sha256', this.#appKey).update(`authkit:${label}`).digest();
50
+ }
44
51
  /** Pathname do issuer sem barra final (ex.: `/oidc`). Vazio quando montado na raiz. */
45
52
  mountPath;
46
53
  recorder;
@@ -137,8 +144,12 @@ export class OidcService {
137
144
  claims: async (_use, _scope) => {
138
145
  const base = {
139
146
  sub: user.id,
140
- email: user.email,
141
- email_verified: true,
147
+ ...(user.email
148
+ ? {
149
+ email: user.email,
150
+ email_verified: (await config.accountStore.isEmailVerified?.(user.id)) ?? false,
151
+ }
152
+ : {}),
142
153
  name: user.name,
143
154
  picture: user.avatarUrl,
144
155
  };
@@ -321,7 +332,7 @@ export class OidcService {
321
332
  /** Verifica client_id + client_secret contra os clients da config (p/ endpoints custom como introspecção de PAT). */
322
333
  verifyClientCredentials(clientId, clientSecret) {
323
334
  const client = this.#clients.find((c) => c.clientId === clientId);
324
- if (!client || !client.clientSecret)
335
+ if (!client?.clientSecret)
325
336
  return false;
326
337
  const a = Buffer.from(client.clientSecret);
327
338
  const b = Buffer.from(clientSecret);
@@ -295,6 +295,130 @@ const TABLES = [
295
295
  created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
296
296
  },
297
297
  },
298
+ /*
299
+ * Personal Agent Protocol (Poppy). Os Account Tokens são grants de
300
+ * `auth_agent_grants` (prefixo `pgrant_`) com o hash em
301
+ * `auth_agent_refresh_tokens`; aqui ficam Sessions, Session Tokens, pedidos de
302
+ * sign-in e os `jti` vistos. Mesmas regras de data das tabelas acima.
303
+ */
304
+ {
305
+ name: 'auth_poppy_sessions',
306
+ /**
307
+ * Uma Session: um usuário do agente (`user_id`, opaco) num agente
308
+ * (`client_id`). `account_id` é a conta a que ficou PRESA ao logar (não troca);
309
+ * `grant_id` o Account Token com que está logada agora (`null` = deslogada).
310
+ */
311
+ create: (t) => {
312
+ t.string('id').primary();
313
+ t.string('client_id', 2048).notNullable();
314
+ t.string('user_id').notNullable();
315
+ t.string('account_id').nullable().index();
316
+ t.string('grant_id').nullable().index();
317
+ t.text('scope').notNullable();
318
+ t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable();
319
+ t.dateTime('ended_at', { useTz: true, precision: 3 }).nullable();
320
+ t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
321
+ t.dateTime('updated_at', { useTz: true, precision: 3 }).notNullable();
322
+ },
323
+ columns: {
324
+ client_id: (t) => t.string('client_id', 2048),
325
+ user_id: (t) => t.string('user_id'),
326
+ account_id: (t) => t.string('account_id').nullable().index(),
327
+ grant_id: (t) => t.string('grant_id').nullable().index(),
328
+ scope: (t) => t.text('scope'),
329
+ expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
330
+ ended_at: (t) => t.dateTime('ended_at', { useTz: true, precision: 3 }).nullable(),
331
+ created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
332
+ updated_at: (t) => t.dateTime('updated_at', { useTz: true, precision: 3 }).nullable(),
333
+ },
334
+ },
335
+ {
336
+ name: 'auth_poppy_tokens',
337
+ /** Session Tokens — só o hash. `jkt` = chave DPoP; `null` = Bearer (MCP). */
338
+ create: (t) => {
339
+ t.string('token_hash', 64).primary();
340
+ t.string('session_id').notNullable().index();
341
+ t.string('client_id', 2048).notNullable();
342
+ t.string('user_id').notNullable();
343
+ t.string('account_id').nullable();
344
+ t.string('grant_id').nullable().index();
345
+ t.text('scope').notNullable();
346
+ t.string('resource', 2048).nullable();
347
+ t.string('jkt', 64).nullable();
348
+ t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable().index();
349
+ t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
350
+ },
351
+ columns: {
352
+ session_id: (t) => t.string('session_id').index(),
353
+ client_id: (t) => t.string('client_id', 2048),
354
+ user_id: (t) => t.string('user_id'),
355
+ account_id: (t) => t.string('account_id').nullable(),
356
+ grant_id: (t) => t.string('grant_id').nullable().index(),
357
+ scope: (t) => t.text('scope'),
358
+ resource: (t) => t.string('resource', 2048).nullable(),
359
+ jkt: (t) => t.string('jkt', 64).nullable(),
360
+ expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
361
+ created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
362
+ },
363
+ },
364
+ {
365
+ name: 'auth_poppy_requests',
366
+ /**
367
+ * Pedidos de sign-in: `authorize` (Direct — vira código após o consentimento),
368
+ * `device` (RFC 8628) e `mediated` (§4.7, aguardando o código de uso único).
369
+ * Códigos só como hash. Linhas expiradas são apagadas no próximo pedido.
370
+ */
371
+ create: (t) => {
372
+ t.string('id').primary();
373
+ t.string('kind', 16).notNullable();
374
+ t.string('code_hash', 64).nullable().unique();
375
+ t.string('user_code', 16).nullable().unique();
376
+ t.string('client_id', 2048).notNullable();
377
+ t.string('session_id').nullable();
378
+ t.string('account_id').nullable();
379
+ t.text('requested_scope').notNullable();
380
+ t.text('granted_scope').nullable();
381
+ t.string('redirect_uri', 2048).nullable();
382
+ t.string('code_challenge', 128).nullable();
383
+ t.string('status', 16).notNullable();
384
+ t.integer('attempts').notNullable().defaultTo(0);
385
+ t.integer('interval_seconds').nullable();
386
+ t.dateTime('last_polled_at', { useTz: true, precision: 3 }).nullable();
387
+ t.text('data').nullable();
388
+ t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable().index();
389
+ t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
390
+ },
391
+ columns: {
392
+ kind: (t) => t.string('kind', 16),
393
+ code_hash: (t) => t.string('code_hash', 64).nullable(),
394
+ user_code: (t) => t.string('user_code', 16).nullable(),
395
+ client_id: (t) => t.string('client_id', 2048),
396
+ session_id: (t) => t.string('session_id').nullable(),
397
+ account_id: (t) => t.string('account_id').nullable(),
398
+ requested_scope: (t) => t.text('requested_scope'),
399
+ granted_scope: (t) => t.text('granted_scope').nullable(),
400
+ redirect_uri: (t) => t.string('redirect_uri', 2048).nullable(),
401
+ code_challenge: (t) => t.string('code_challenge', 128).nullable(),
402
+ status: (t) => t.string('status', 16),
403
+ attempts: (t) => t.integer('attempts').defaultTo(0),
404
+ interval_seconds: (t) => t.integer('interval_seconds').nullable(),
405
+ last_polled_at: (t) => t.dateTime('last_polled_at', { useTz: true, precision: 3 }).nullable(),
406
+ data: (t) => t.text('data').nullable(),
407
+ expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
408
+ created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
409
+ },
410
+ },
411
+ {
412
+ name: 'auth_poppy_jtis',
413
+ /** `jti` já aceitos (asserções, provas DPoP), até expirarem — anti-replay entre instâncias. */
414
+ create: (t) => {
415
+ t.string('key', 100).primary();
416
+ t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable().index();
417
+ },
418
+ columns: {
419
+ expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
420
+ },
421
+ },
298
422
  ];
299
423
  /**
300
424
  * Probe searchPath-aware: `schema.hasTable` no Postgres ignora o