@adonis-agora/authkit-server 0.74.0 → 0.76.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 (79) hide show
  1. package/README.md +3 -0
  2. package/build/host/views/account/apps.edge +30 -0
  3. package/build/host/views/agents/consent.edge +60 -0
  4. package/build/host/views/agents/done.edge +24 -0
  5. package/build/host/views/consent.edge +9 -1
  6. package/build/host/views/partials/styles.edge +1 -1
  7. package/build/index.d.ts +9 -0
  8. package/build/index.js +4 -0
  9. package/build/providers/authkit_server_provider.js +75 -50
  10. package/build/src/agents/agent_identity.d.ts +33 -0
  11. package/build/src/agents/agent_identity.js +156 -0
  12. package/build/src/agents/config.d.ts +99 -0
  13. package/build/src/agents/config.js +101 -0
  14. package/build/src/agents/delegation_service.d.ts +154 -0
  15. package/build/src/agents/delegation_service.js +394 -0
  16. package/build/src/agents/delegation_store.d.ts +93 -0
  17. package/build/src/agents/delegation_store.js +222 -0
  18. package/build/src/agents/middleware.d.ts +73 -0
  19. package/build/src/agents/middleware.js +113 -0
  20. package/build/src/agents/protocol.d.ts +62 -0
  21. package/build/src/agents/protocol.js +51 -0
  22. package/build/src/agents/runtime.d.ts +36 -0
  23. package/build/src/agents/runtime.js +65 -0
  24. package/build/src/agents/signer.d.ts +34 -0
  25. package/build/src/agents/signer.js +70 -0
  26. package/build/src/audit/audit_sink.d.ts +1 -1
  27. package/build/src/audit/audit_sink.js +5 -0
  28. package/build/src/controllers/authorization_server_metadata_controller.d.ts +10 -0
  29. package/build/src/controllers/authorization_server_metadata_controller.js +20 -0
  30. package/build/src/define_config.d.ts +34 -3
  31. package/build/src/define_config.js +35 -1
  32. package/build/src/host/access_token_verifier.d.ts +7 -0
  33. package/build/src/host/access_token_verifier.js +8 -0
  34. package/build/src/host/account_api/account_api_controller.d.ts +12 -0
  35. package/build/src/host/account_api/account_api_controller.js +40 -0
  36. package/build/src/host/admin_api/dto.d.ts +1 -1
  37. package/build/src/host/admin_sessions_service.d.ts +2 -0
  38. package/build/src/host/admin_sessions_service.js +18 -0
  39. package/build/src/host/auth_host_config.d.ts +4 -0
  40. package/build/src/host/bearer_account.d.ts +17 -0
  41. package/build/src/host/bearer_account.js +10 -0
  42. package/build/src/host/client_names.d.ts +11 -0
  43. package/build/src/host/client_names.js +12 -0
  44. package/build/src/host/console_session.js +3 -5
  45. package/build/src/host/controllers/account_apps_controller.d.ts +2 -0
  46. package/build/src/host/controllers/account_apps_controller.js +39 -3
  47. package/build/src/host/controllers/account_orgs_controller.js +2 -1
  48. package/build/src/host/controllers/account_session_controller.js +2 -1
  49. package/build/src/host/controllers/agent_consent_controller.d.ts +17 -0
  50. package/build/src/host/controllers/agent_consent_controller.js +138 -0
  51. package/build/src/host/controllers/agent_oauth_controller.d.ts +23 -0
  52. package/build/src/host/controllers/agent_oauth_controller.js +110 -0
  53. package/build/src/host/controllers/interaction_controller.js +28 -0
  54. package/build/src/host/csrf.d.ts +8 -18
  55. package/build/src/host/csrf.js +28 -2
  56. package/build/src/host/i18n.d.ts +54 -0
  57. package/build/src/host/i18n.js +54 -0
  58. package/build/src/host/impersonation_session.d.ts +5 -0
  59. package/build/src/host/impersonation_session.js +37 -4
  60. package/build/src/host/oidc_bearer_guard.js +10 -1
  61. package/build/src/host/redirect_exact.d.ts +12 -0
  62. package/build/src/host/redirect_exact.js +17 -0
  63. package/build/src/host/register_auth_host.js +48 -7
  64. package/build/src/host/request_url.d.ts +10 -0
  65. package/build/src/host/request_url.js +17 -0
  66. package/build/src/host/sudo/methods/magic_link.js +2 -1
  67. package/build/src/host/sudo/runtime.js +3 -2
  68. package/build/src/host/sudo_mode.js +4 -4
  69. package/build/src/mcp/mcp_oauth.d.ts +85 -0
  70. package/build/src/mcp/mcp_oauth.js +154 -0
  71. package/build/src/provider/build_provider.js +20 -1
  72. package/build/src/provider/oidc_service.d.ts +8 -0
  73. package/build/src/provider/oidc_service.js +11 -0
  74. package/build/src/provider/token_exchange.d.ts +26 -0
  75. package/build/src/provider/token_exchange.js +44 -3
  76. package/build/src/schema/ensure.js +92 -0
  77. package/build/stubs/ui/react/pages/consent.tsx +17 -1
  78. package/package.json +1 -1
  79. package/stubs/ui/react/pages/consent.tsx +17 -1
@@ -97,6 +97,11 @@ export const DEFAULT_MESSAGES = {
97
97
  // raw na view). O nome vem do branding (config-trusted).
98
98
  'consent.body': 'The app <strong>{app}</strong> wants to access your account.',
99
99
  'consent.submit': 'Authorize',
100
+ 'consent.scopes_title': 'It will be able to:',
101
+ 'consent.scope.profile': 'See your name and photo',
102
+ 'consent.scope.email': 'See your e-mail address',
103
+ 'consent.scope.offline_access': 'Stay connected without asking you to sign in again',
104
+ 'consent.scope.roles': 'See your roles and organization',
100
105
  // Console de conta — login (account/login).
101
106
  'account.login.page_title': 'My account',
102
107
  'account.login.title': 'My account',
@@ -183,6 +188,28 @@ export const DEFAULT_MESSAGES = {
183
188
  'account.apps.revoke': 'Revoke access',
184
189
  'account.apps.revoke_confirm': 'Revoke this app’s access? It will need to be authorized again and its tokens will stop working.',
185
190
  'account.apps.revoked': 'Access revoked.',
191
+ 'account.apps.agents_title': 'Personal agents',
192
+ 'account.apps.agents_intro': 'AI assistants you allowed to act on your account, and what each one can do.',
193
+ 'account.apps.agents_empty': 'No personal agent can act on your account.',
194
+ 'agents.consent.page_title': 'Authorize assistant',
195
+ 'agents.consent.title': '{agent} wants to act on your account',
196
+ 'agents.consent.body': 'The request came from {origin}. Choose what it can do — you can revoke access at any time.',
197
+ 'agents.consent.signed_in_as': 'Signed in as {email}',
198
+ 'agents.consent.scopes_label': 'Allow it to:',
199
+ 'agents.consent.code': 'Request code: {code}. Make sure it matches the code your assistant shows.',
200
+ 'agents.consent.allow': 'Allow',
201
+ 'agents.consent.deny': 'Deny',
202
+ 'agents.consent.enter_code_title': 'Connect an assistant',
203
+ 'agents.consent.enter_code_body': 'Enter the code your assistant showed you.',
204
+ 'agents.consent.continue': 'Continue',
205
+ 'agents.consent.invalid_code': 'This code is invalid or expired. Ask your assistant for a new one.',
206
+ 'agents.consent.impersonating': 'You cannot authorize an assistant while impersonating another account.',
207
+ 'agents.done.approved_title': '{agent} can now:',
208
+ 'agents.done.denied_title': 'Access denied',
209
+ 'agents.done.return': 'You can close this page and return to your assistant.',
210
+ 'agents.done.revoke_hint': 'To revoke this access later, go to the apps page of your account.',
211
+ 'agents.done.expired_title': 'Request expired',
212
+ 'agents.done.expired_body': 'This request is no longer valid. Ask your assistant to start again.',
186
213
  'account.apps.not_supported': 'The configured OIDC adapter does not support enumeration — listing apps is unavailable.',
187
214
  // Console de conta — organizations (account/orgs).
188
215
  'account.orgs.page_title': 'My Organizations',
@@ -908,6 +935,11 @@ export const PT_BR_MESSAGES = {
908
935
  'consent.title': 'Autorizar acesso',
909
936
  'consent.body': 'O app <strong>{app}</strong> quer acessar sua conta.',
910
937
  'consent.submit': 'Autorizar',
938
+ 'consent.scopes_title': 'Ele vai poder:',
939
+ 'consent.scope.profile': 'Ver seu nome e foto',
940
+ 'consent.scope.email': 'Ver seu e-mail',
941
+ 'consent.scope.offline_access': 'Continuar conectado sem pedir que você entre de novo',
942
+ 'consent.scope.roles': 'Ver seus papéis e sua organização',
911
943
  // Console de conta — login (account/login).
912
944
  'account.login.page_title': 'Minha conta',
913
945
  'account.login.title': 'Minha conta',
@@ -994,6 +1026,28 @@ export const PT_BR_MESSAGES = {
994
1026
  'account.apps.revoke': 'Revogar acesso',
995
1027
  'account.apps.revoke_confirm': 'Revogar o acesso deste app? Ele precisará ser autorizado novamente e seus tokens deixarão de funcionar.',
996
1028
  'account.apps.revoked': 'Acesso revogado.',
1029
+ 'account.apps.agents_title': 'Assistentes pessoais',
1030
+ 'account.apps.agents_intro': 'Assistentes de IA que você autorizou a agir na sua conta, e o que cada um pode fazer.',
1031
+ 'account.apps.agents_empty': 'Nenhum assistente pessoal pode agir na sua conta.',
1032
+ 'agents.consent.page_title': 'Autorizar assistente',
1033
+ 'agents.consent.title': '{agent} quer agir na sua conta',
1034
+ 'agents.consent.body': 'O pedido veio de {origin}. Escolha o que ele pode fazer — você pode revogar o acesso quando quiser.',
1035
+ 'agents.consent.signed_in_as': 'Conectado como {email}',
1036
+ 'agents.consent.scopes_label': 'Permitir que ele:',
1037
+ 'agents.consent.code': 'Código do pedido: {code}. Confira se é o mesmo que o seu assistente mostra.',
1038
+ 'agents.consent.allow': 'Permitir',
1039
+ 'agents.consent.deny': 'Negar',
1040
+ 'agents.consent.enter_code_title': 'Conectar um assistente',
1041
+ 'agents.consent.enter_code_body': 'Digite o código que o seu assistente mostrou.',
1042
+ 'agents.consent.continue': 'Continuar',
1043
+ 'agents.consent.invalid_code': 'Este código é inválido ou expirou. Peça um novo ao seu assistente.',
1044
+ 'agents.consent.impersonating': 'Não é possível autorizar um assistente enquanto você personifica outra conta.',
1045
+ 'agents.done.approved_title': '{agent} agora pode:',
1046
+ 'agents.done.denied_title': 'Acesso negado',
1047
+ 'agents.done.return': 'Você pode fechar esta página e voltar ao seu assistente.',
1048
+ 'agents.done.revoke_hint': 'Para revogar este acesso depois, vá à página de apps da sua conta.',
1049
+ 'agents.done.expired_title': 'Pedido expirado',
1050
+ 'agents.done.expired_body': 'Este pedido não vale mais. Peça ao seu assistente para começar de novo.',
997
1051
  'account.apps.not_supported': 'O adapter OIDC configurado não suporta enumeração — a listagem de apps fica indisponível.',
998
1052
  // Console de conta — organizations (account/orgs).
999
1053
  'account.orgs.page_title': 'Minhas Organizações',
@@ -61,6 +61,11 @@ export interface ImpersonationState {
61
61
  * do lado do IdP, NÃO impõe expiração na sessão (só `maxAge` faz isso).
62
62
  */
63
63
  exchangeExpiresIn?: number;
64
+ /**
65
+ * De onde veio a impersonation: `session` (console/web, keys de sessão) ou
66
+ * `bearer` (access token trocado com `act`, ex.: app nativo). Só quando `active`.
67
+ */
68
+ source?: 'session' | 'bearer';
64
69
  }
65
70
  /** Metadados aproveitados da resposta do token-exchange (só o que não é segredo). */
66
71
  export interface TokenExchangeResult {
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
3
+ import { bearerAccountId, bearerImpersonation } from './bearer_account.js';
3
4
  /**
4
5
  * Ergonômico de SESSÃO de browser no RP para "personificar" (impersonate) um
5
6
  * usuário e navegar como ele — roteado pelo token-exchange RFC 8693 que o IdP já
@@ -304,11 +305,16 @@ export async function startImpersonation(ctx, params) {
304
305
  * false }` puro quando nunca houve impersonation.
305
306
  */
306
307
  export function impersonationState(ctx) {
307
- const impersonatorId = ctx.session.get(IMPERSONATOR_SESSION_KEY);
308
- if (!impersonatorId)
309
- return { active: false };
308
+ const impersonatorId = ctx.session?.get(IMPERSONATOR_SESSION_KEY);
309
+ if (!impersonatorId) {
310
+ // Sessão de console logada manda (mesma regra do `getAccountId`): o bearer só
311
+ // conta quando a request não tem conta na sessão.
312
+ if (ctx.session?.get(ACCOUNT_SESSION_KEY))
313
+ return { active: false };
314
+ return bearerImpersonationState(ctx);
315
+ }
310
316
  const targetId = ctx.session.get(ACCOUNT_SESSION_KEY);
311
- const state = { active: true, targetId, impersonatorId };
317
+ const state = { active: true, targetId, impersonatorId, source: 'session' };
312
318
  const impersonationId = ctx.session.get(IMPERSONATION_ID_SESSION_KEY);
313
319
  if (impersonationId)
314
320
  state.impersonationId = impersonationId;
@@ -329,6 +335,33 @@ export function impersonationState(ctx) {
329
335
  }
330
336
  return state;
331
337
  }
338
+ /**
339
+ * Impersonation pelo ACCESS TOKEN (sem sessão): o `oidcBearerGuard` autenticou
340
+ * um token trocado que carrega `act`. O alvo é o `sub` do token; o impersonator,
341
+ * o `act.sub`; a expiração, o `exp` do token (o token trocado não tem refresh).
342
+ * Só existe depois que o guard bearer rodou na request, como o `getAccountId`.
343
+ */
344
+ function bearerImpersonationState(ctx) {
345
+ const bearer = bearerImpersonation(ctx);
346
+ const targetId = bearerAccountId(ctx);
347
+ if (!bearer || !targetId)
348
+ return { active: false };
349
+ const state = {
350
+ active: true,
351
+ targetId,
352
+ impersonatorId: bearer.actorId,
353
+ actSub: bearer.actorId,
354
+ source: 'bearer',
355
+ };
356
+ if (bearer.jti)
357
+ state.impersonationId = bearer.jti;
358
+ if (bearer.exp !== null) {
359
+ state.expiresAt = bearer.exp * 1000;
360
+ if (Date.now() > state.expiresAt)
361
+ state.active = false;
362
+ }
363
+ return state;
364
+ }
332
365
  /**
333
366
  * Encerra a impersonation: restaura `account_user_id = impersonator`, remove
334
367
  * TODAS as keys de sessão de impersonation (id, tempos, prova do exchange) e
@@ -1,6 +1,6 @@
1
1
  import { RuntimeException } from '@adonisjs/core/exceptions';
2
2
  import { inProcessAccessTokenVerifier, remoteAccessTokenVerifier, } from './access_token_verifier.js';
3
- import { clearBearerAccountId, setBearerAccountId } from './bearer_account.js';
3
+ import { clearBearerAccountId, setBearerAccountId, setBearerImpersonation, } from './bearer_account.js';
4
4
  import { cachedUnauthorizedAccessConstructor, loadUnauthorizedAccess, } from './oidc_rp_guard.js';
5
5
  /**
6
6
  * Driver do `E_UNAUTHORIZED_ACCESS` lançado pelo guard. `access_tokens` é o
@@ -144,10 +144,19 @@ export class OidcBearerGuard {
144
144
  const guardUser = await this.#userProvider.findById(token.sub);
145
145
  if (!guardUser)
146
146
  throw this.#fail('invalid_token');
147
+ // Token de impersonation: o ator (o admin) também tem que existir AGORA. Conta
148
+ // apagada/desativada depois da troca derruba o acesso na próxima request, sem
149
+ // esperar o `exp` do token.
150
+ if (token.actor && !(await this.#userProvider.findById(token.actor))) {
151
+ throw this.#fail('invalid_token');
152
+ }
147
153
  this.user = guardUser.getOriginal();
148
154
  this.accessToken = token;
149
155
  this.isAuthenticated = true;
150
156
  setBearerAccountId(this.#ctx, String(guardUser.getId()));
157
+ if (token.actor) {
158
+ setBearerImpersonation(this.#ctx, { actorId: token.actor, exp: token.exp, jti: token.jti });
159
+ }
151
160
  this.#emitter.emit('oidc_bearer:authentication_succeeded', {
152
161
  ctx: this.#ctx,
153
162
  guardName: this.#name,
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Redireciona para `url` EXATAMENTE como a lib a montou.
3
+ *
4
+ * O starter do AdonisJS liga `redirect.forwardQueryString: true` no `config/app.ts`: todo
5
+ * `response.redirect(url)` passa a colar a query da request ATUAL no fim — e numa URL que já tem a
6
+ * dela, sai um destino quebrado (`/login?return_to=%2Fx%3Fa%3D1?a=1`). O argumento
7
+ * `forwardQueryString = false` do `redirect()` não desliga o que veio do config; só o
8
+ * `clearQs()` do builder desliga. Toda URL que a lib monta já carrega a query que precisa.
9
+ *
10
+ * Fora de uma Response do AdonisJS (dublês de teste), cai no `redirect(url)` de sempre.
11
+ */
12
+ export declare function redirectExact(response: any, url: string): unknown;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Redireciona para `url` EXATAMENTE como a lib a montou.
3
+ *
4
+ * O starter do AdonisJS liga `redirect.forwardQueryString: true` no `config/app.ts`: todo
5
+ * `response.redirect(url)` passa a colar a query da request ATUAL no fim — e numa URL que já tem a
6
+ * dela, sai um destino quebrado (`/login?return_to=%2Fx%3Fa%3D1?a=1`). O argumento
7
+ * `forwardQueryString = false` do `redirect()` não desliga o que veio do config; só o
8
+ * `clearQs()` do builder desliga. Toda URL que a lib monta já carrega a query que precisa.
9
+ *
10
+ * Fora de uma Response do AdonisJS (dublês de teste), cai no `redirect(url)` de sempre.
11
+ */
12
+ export function redirectExact(response, url) {
13
+ if (typeof response?.getStatus === 'function') {
14
+ return response.redirect().clearQs().toPath(url);
15
+ }
16
+ return response.redirect(url);
17
+ }
@@ -9,6 +9,8 @@ import { normalizeAdminApiPrefix, normalizeAdminPrefix, setAdminApiPrefix, setAd
9
9
  import { getAuthHostConfig, markAuthHostAutoMounted, wasAuthHostAutoMounted, } from './auth_host_config.js';
10
10
  import { ensureConsoleSession } from './idp_session_bridge.js';
11
11
  import { createAuthThrottles } from './rate_limit.js';
12
+ import { redirectExact } from './redirect_exact.js';
13
+ import { requestPathWithQuery } from './request_url.js';
12
14
  import { resolveRuntimeSettings } from './runtime_settings.js';
13
15
  import { resolveEffectiveSessionPolicy } from './runtime_toggles.js';
14
16
  import { magicLink as sudoMagicLink } from './sudo/methods/magic_link.js';
@@ -90,9 +92,7 @@ function buildLoginRedirect(ctx, extra) {
90
92
  // host que desmontou a tela de login (`account: { login: false }`) aponta para
91
93
  // a própria rota de login dele (ex.: `/login`). Ver `account_login_url.ts`.
92
94
  const loginUrl = getAccountLoginUrl();
93
- const url = ctx.request?.url?.() ?? '';
94
- const qs = ctx.request?.parsedUrl?.search ?? '';
95
- const dest = qs ? `${url}${qs}` : url;
95
+ const dest = requestPathWithQuery(ctx.request);
96
96
  // Só inclui return_to quando há um caminho real (não vazio, não é o próprio login).
97
97
  if (dest && dest !== '/' && !dest.startsWith(loginUrl)) {
98
98
  const encoded = encodeURIComponent(dest);
@@ -116,12 +116,12 @@ function buildLoginRedirect(ctx, extra) {
116
116
  const accountGuard = async (ctx, next) => {
117
117
  // Sessão do console — ou, com `accountSession.acceptIdpSession`, a do IdP (SSO).
118
118
  if (!(await ensureConsoleSession(ctx))) {
119
- return ctx.response.redirect(buildLoginRedirect(ctx));
119
+ return redirectExact(ctx.response, buildLoginRedirect(ctx));
120
120
  }
121
121
  // Idle timeout: encerra e redireciona com query param de motivo.
122
122
  const idleExpired = await checkAndRefreshIdle(ctx);
123
123
  if (idleExpired) {
124
- return ctx.response.redirect(buildLoginRedirect(ctx, 'reason=idle'));
124
+ return redirectExact(ctx.response, buildLoginRedirect(ctx, 'reason=idle'));
125
125
  }
126
126
  return next();
127
127
  };
@@ -150,12 +150,12 @@ export const adminGuard = async (ctx, next) => {
150
150
  const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
151
151
  if (!accountId) {
152
152
  // `/account/login` é sempre o login da conta — NÃO muda com o prefixo admin.
153
- return ctx.response.redirect(buildLoginRedirect(ctx));
153
+ return redirectExact(ctx.response, buildLoginRedirect(ctx));
154
154
  }
155
155
  // Idle timeout: também protege o console admin.
156
156
  const idleExpired = await checkAndRefreshIdle(ctx);
157
157
  if (idleExpired) {
158
- return ctx.response.redirect(buildLoginRedirect(ctx, 'reason=idle'));
158
+ return redirectExact(ctx.response, buildLoginRedirect(ctx, 'reason=idle'));
159
159
  }
160
160
  const allowed = cfg.admin.roles;
161
161
  const account = await cfg.accountStore.findById(accountId);
@@ -173,6 +173,7 @@ export const adminGuard = async (ctx, next) => {
173
173
  };
174
174
  const C = {
175
175
  oidc: () => import('../controllers/oidc_callback_controller.js'),
176
+ authorizationServerMetadata: () => import('../controllers/authorization_server_metadata_controller.js'),
176
177
  interaction: () => import('./controllers/interaction_controller.js'),
177
178
  registration: () => import('./controllers/registration_controller.js'),
178
179
  social: () => import('./controllers/social_controller.js'),
@@ -184,6 +185,8 @@ const C = {
184
185
  accountMfa: () => import('./controllers/account_mfa_controller.js'),
185
186
  accountOrgs: () => import('./controllers/account_orgs_controller.js'),
186
187
  accountConfirm: () => import('./controllers/account_confirm_controller.js'),
188
+ agentOAuth: () => import('./controllers/agent_oauth_controller.js'),
189
+ agentConsent: () => import('./controllers/agent_consent_controller.js'),
187
190
  webauthnAsset: () => import('./controllers/webauthn_asset_controller.js'),
188
191
  logoutAsset: () => import('./controllers/logout_asset_controller.js'),
189
192
  passkeyAutofillAsset: () => import('./controllers/passkey_autofill_asset_controller.js'),
@@ -421,6 +424,15 @@ export function registerAuthHost(router, opts = {}) {
421
424
  .get('/authkit/assets/webauthn_confirm.js', [C.webauthnConfirmAsset])
422
425
  .as('authkit.assets.webauthnConfirm');
423
426
  router.get('/authkit/assets/submit_lock.js', [C.submitLockAsset]).as('authkit.assets.submitLock');
427
+ // Metadata do servidor de autorização no caminho da RFC 8414 §3.1 (issuer com path: os
428
+ // clientes MCP procuram `/.well-known/oauth-authorization-server/oidc` antes do OIDC Discovery).
429
+ // O issuer termina no mountPath, então o mount É o path do issuer.
430
+ const issuerPath = mount.replace(/\/+$/, '');
431
+ if (issuerPath !== '') {
432
+ router
433
+ .get(`/.well-known/oauth-authorization-server${issuerPath}`, [C.authorizationServerMetadata])
434
+ .as('authkit.oauth_authorization_server');
435
+ }
424
436
  // Provider OIDC (wildcard + root) — o que registerOidcRoutes fazia.
425
437
  router.any(`${mount}/*`, [C.oidc]).as('authkit.oidc.wildcard');
426
438
  router.any(mount, [C.oidc]).as('authkit.oidc.root');
@@ -463,6 +475,27 @@ export function registerAuthHost(router, opts = {}) {
463
475
  }
464
476
  // PAT introspection (server-to-server).
465
477
  withIntrospection(router.post('/authkit/pat/introspect', [C.patIntrospection, 'handle']));
478
+ // Personal agents (PACT §5): authorization server de delegação. Os endpoints
479
+ // OAuth são server-to-server (o agente se autentica pelo JWT dele, sem
480
+ // sessão); a tela de consentimento exige a sessão de conta — sem ela o
481
+ // `accountGuard` manda para o login com `return_to`, e o usuário volta com o
482
+ // `user_code`. Montado só quando `personalAgents` está no config.
483
+ const agentsPrefix = hostCfg?.personalAgents?.prefix;
484
+ if (agentsPrefix) {
485
+ const oauth = `${agentsPrefix}/oauth`;
486
+ router.get(`${oauth}/.well-known/oauth-authorization-server`, [C.agentOAuth, 'metadata']);
487
+ router.get(`${oauth}/jwks.json`, [C.agentOAuth, 'jwks']);
488
+ router.post(`${oauth}/device_authorization`, [C.agentOAuth, 'deviceAuthorization']);
489
+ router.post(`${oauth}/token`, [C.agentOAuth, 'token']);
490
+ // Throttle de código (bucket do OTP, por IP): o `user_code` tem 8 letras e
491
+ // a RFC 8628 §5.1 conta com rate limit para não ser adivinhável.
492
+ router
493
+ .group(() => {
494
+ withOtpLogin(router.get(`${agentsPrefix}/consent`, [C.agentConsent, 'show']));
495
+ withOtpLogin(router.post(`${agentsPrefix}/consent`, [C.agentConsent, 'decide']));
496
+ })
497
+ .use([accountGuard]);
498
+ }
466
499
  // Paths do console de conta (configuráveis/localizáveis via `accountRoutes`).
467
500
  // As TELAS vêm de `accountPath(key)` (prefixo + segmento configurável); os
468
501
  // action-subpaths concatenados (`/password`, `/enroll`, ...) são FIXOS —
@@ -531,6 +564,9 @@ export function registerAuthHost(router, opts = {}) {
531
564
  if (mountApps) {
532
565
  router.get(appsPath, [C.accountApps, 'index']);
533
566
  router.post(`${appsPath}/:clientId/revoke`, [C.accountApps, 'revoke']);
567
+ if (agentsPrefix) {
568
+ router.post(`${appsPath}/agents/:grantId/revoke`, [C.accountApps, 'revokeAgent']);
569
+ }
534
570
  }
535
571
  // MFA — TOTP + passkeys (tela `mfa`).
536
572
  if (mountMfa) {
@@ -642,6 +678,11 @@ export function registerAuthHost(router, opts = {}) {
642
678
  // Apps (grants).
643
679
  router.get(`${apiBase}/apps`, [C.accountApi, 'listApps']);
644
680
  router.delete(`${apiBase}/apps/:clientId`, [C.accountApi, 'revokeApp']);
681
+ // Personal agents com delegação (PACT).
682
+ if (agentsPrefix) {
683
+ router.get(`${apiBase}/agents`, [C.accountApi, 'listAgents']);
684
+ router.delete(`${apiBase}/agents/:id`, [C.accountApi, 'revokeAgent']);
685
+ }
645
686
  // MFA + passkeys.
646
687
  router.get(`${apiBase}/mfa`, [C.accountApi, 'mfaStatus']);
647
688
  // Login methods preference (self-service, por usuário).
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Path + query string da request atual, para montar um `return_to`.
3
+ *
4
+ * `request.url(true)` é a API do AdonisJS que inclui a query. O código antigo lia
5
+ * `request.parsedUrl.search`, que o AdonisJS 7 não tem mais (o `parsedUrl` virou
6
+ * `{ pathname, query }`) — todo redirect para o login perdia a query em silêncio,
7
+ * e o usuário voltava para a página sem os parâmetros (um `user_code`, uma
8
+ * paginação). O `search` fica como fallback para hosts/dublês que ainda o expõem.
9
+ */
10
+ export declare function requestPathWithQuery(request: any): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Path + query string da request atual, para montar um `return_to`.
3
+ *
4
+ * `request.url(true)` é a API do AdonisJS que inclui a query. O código antigo lia
5
+ * `request.parsedUrl.search`, que o AdonisJS 7 não tem mais (o `parsedUrl` virou
6
+ * `{ pathname, query }`) — todo redirect para o login perdia a query em silêncio,
7
+ * e o usuário voltava para a página sem os parâmetros (um `user_code`, uma
8
+ * paginação). O `search` fica como fallback para hosts/dublês que ainda o expõem.
9
+ */
10
+ export function requestPathWithQuery(request) {
11
+ const withQuery = request?.url?.(true) ?? '';
12
+ if (withQuery.includes('?'))
13
+ return withQuery;
14
+ const parsed = request?.parsedUrl;
15
+ const legacy = parsed?.search ?? (parsed?.query ? `?${parsed.query}` : '');
16
+ return `${withQuery}${legacy}`;
17
+ }
@@ -2,6 +2,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { accountPath } from '../../account_paths.js';
3
3
  import { sendSudoLinkEmail } from '../../default_mailer.js';
4
4
  import { translate } from '../../i18n.js';
5
+ import { redirectExact } from '../../redirect_exact.js';
5
6
  import { isSudoMethodEnabled } from '../runtime.js';
6
7
  /** Token de sudo pendente, guardado na sessão que o pediu. */
7
8
  export const SUDO_LINK_SESSION_KEY = 'authkit_sudo_link';
@@ -170,7 +171,7 @@ export function magicLink() {
170
171
  // `translate(...)` em `confirmError`, e a tela leria dois formatos
171
172
  // diferentes se este aqui mandasse a chave.
172
173
  ctx.session.flash('confirmNotice', translate(c.cfg.messages, 'account.confirm.magic_link_sent'));
173
- return ctx.response.redirect(`${accountPath('confirm')}${qs}`);
174
+ return redirectExact(ctx.response, `${accountPath('confirm')}${qs}`);
174
175
  });
175
176
  router.get(`${accountPath('confirm')}/magic-link/:token`, async (ctx) => {
176
177
  const c = await h.contextFrom(ctx);
@@ -3,6 +3,7 @@ import { accountPath } from '../account_paths.js';
3
3
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
4
4
  import { validateReturnTo } from '../controllers/account_session_controller.js';
5
5
  import { translate } from '../i18n.js';
6
+ import { redirectExact } from '../redirect_exact.js';
6
7
  import { markSudo } from '../sudo_mode.js';
7
8
  /** Último método usado com sucesso — só ordena a tela, não restringe nada. */
8
9
  export const LAST_METHOD_SESSION_KEY = 'authkit_sudo_last_method';
@@ -362,7 +363,7 @@ export async function completeSudo(c, methodId) {
362
363
  ip: c.ctx.request.ip?.() ?? null,
363
364
  metadata: { method: methodId },
364
365
  });
365
- return c.ctx.response.redirect(c.returnTo ?? accountHome(c.cfg));
366
+ return redirectExact(c.ctx.response, c.returnTo ?? accountHome(c.cfg));
366
367
  }
367
368
  /**
368
369
  * Falha de confirmação: flash + volta pro /account/confirm preservando o
@@ -372,7 +373,7 @@ export async function completeSudo(c, methodId) {
372
373
  export async function fail(c, messageKey) {
373
374
  c.ctx.session.flash('confirmError', translate(c.cfg.messages, messageKey));
374
375
  const qs = c.returnTo ? `?return_to=${encodeURIComponent(c.returnTo)}` : '';
375
- return c.ctx.response.redirect(`${accountPath('confirm')}${qs}`);
376
+ return redirectExact(c.ctx.response, `${accountPath('confirm')}${qs}`);
376
377
  }
377
378
  /**
378
379
  * Filtra os métodos disponíveis para esta conta e promove o último usado.
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import { accountPath } from './account_paths.js';
17
17
  import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
18
+ import { redirectExact } from './redirect_exact.js';
19
+ import { requestPathWithQuery } from './request_url.js';
18
20
  import { SETTING_KEYS } from './runtime_toggles.js';
19
21
  export const SUDO_MODE_DEFAULTS = {
20
22
  enabled: true,
@@ -235,11 +237,9 @@ export async function requireSudo(ctx, settings) {
235
237
  if (await isSudoSatisfied(ctx, settings))
236
238
  return true;
237
239
  // Fora da graça: redireciona para confirmação.
238
- const rawUrl = ctx.request.url?.() ?? '';
239
- const qs = ctx.request.parsedUrl?.search ?? '';
240
- const dest = qs ? `${rawUrl}${qs}` : rawUrl;
240
+ const dest = requestPathWithQuery(ctx.request);
241
241
  const returnTo = dest && dest !== '/' && !dest.startsWith(accountPath('confirm'))
242
242
  ? `?return_to=${encodeURIComponent(dest)}`
243
243
  : '';
244
- return ctx.response.redirect(`${accountPath('confirm')}${returnTo}`);
244
+ return redirectExact(ctx.response, `${accountPath('confirm')}${returnTo}`);
245
245
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Login OAuth de clientes MCP (Claude Code, Claude, ChatGPT, VS Code, Cursor) — a autorização da
3
+ * spec do MCP: OAuth 2.1 com PKCE, registro dinâmico (RFC 7591), metadata do servidor de
4
+ * autorização (RFC 8414) e o token amarrado ao servidor MCP pelo `resource` (RFC 8707).
5
+ *
6
+ * `mcp: true` na config liga tudo de uma vez:
7
+ * - o registro dinâmico ABERTO, restrito aos redirects dos clientes MCP conhecidos
8
+ * ({@link MCP_CLIENT_REDIRECTS}) — loopback para os de linha de comando;
9
+ * - o refresh token desses clientes: o registro pede `offline_access` e o authorize ganha
10
+ * `prompt=consent` (OIDC Core §11), então a pessoa consente uma vez e o cliente não precisa
11
+ * logar de novo a cada hora;
12
+ * - os `resource` dos servidores MCP: os de `mcp.resources` e os REGISTRADOS em runtime
13
+ * ({@link registerOAuthResource}) — é por aí que o MCP do `@adonis-agora/agent` se anuncia
14
+ * sem o app listar a URL dele aqui.
15
+ *
16
+ * Os clientes registrados pelo `/reg` se distinguem pelo `client_id_issued_at`, que só o registro
17
+ * dinâmico grava: clients estáticos ou criados pelo console/CLI não mudam de comportamento.
18
+ */
19
+ import type { RedirectUriPolicy, ResolvedRedirectUriPolicy } from '../provider/registration_policy.js';
20
+ /** Callbacks dos clientes MCP conhecidos. Loopback (Claude Code, CLIs) já entra pela política. */
21
+ export declare const MCP_CLIENT_REDIRECTS: Required<RedirectUriPolicy>;
22
+ /** Escopos de um token de servidor MCP: identidade + refresh. */
23
+ export declare const MCP_RESOURCE_SCOPES: string[];
24
+ export interface McpOAuthConfigInput {
25
+ /**
26
+ * Redirects aceitos ALÉM dos clientes MCP conhecidos — ex.: o callback de um cliente próprio.
27
+ * Somados a {@link MCP_CLIENT_REDIRECTS}.
28
+ */
29
+ redirectUris?: RedirectUriPolicy;
30
+ /**
31
+ * URLs dos servidores MCP para os quais este IdP emite tokens, além dos que se registram em
32
+ * runtime ({@link registerOAuthResource}). Ex.: `['https://app.example.com/mcp']`.
33
+ */
34
+ resources?: string[];
35
+ }
36
+ export interface ResolvedMcpOAuthConfig {
37
+ enabled: boolean;
38
+ redirectUriPolicy: ResolvedRedirectUriPolicy;
39
+ resources: string[];
40
+ }
41
+ export declare function resolveMcpOAuth(input?: boolean | McpOAuthConfigInput): ResolvedMcpOAuthConfig;
42
+ /**
43
+ * Um servidor protegido (RFC 9728) que aceita tokens deste IdP. `url` é o `resource` exato; sem
44
+ * ela, `path` casa com qualquer `resource` na origem do issuer (quem registra no boot nem sempre
45
+ * sabe a URL pública).
46
+ */
47
+ export interface OAuthResourceRegistration {
48
+ url?: string;
49
+ path?: string;
50
+ /** Escopos do token para este resource. Default: {@link MCP_RESOURCE_SCOPES}. */
51
+ scopes?: string[];
52
+ }
53
+ export declare function registerOAuthResource(resource: OAuthResourceRegistration): void;
54
+ export declare function registeredOAuthResources(): readonly OAuthResourceRegistration[];
55
+ /**
56
+ * O resource MCP que `indicator` nomeia, ou `null`. Casa com `mcp.resources`, com uma URL
57
+ * registrada, ou com um `path` registrado na origem do issuer. Barra final tolerada.
58
+ */
59
+ export declare function findMcpResource(indicator: string, issuer: string, config: ResolvedMcpOAuthConfig): {
60
+ audience: string;
61
+ scopes: string[];
62
+ } | null;
63
+ /**
64
+ * Registro de um cliente MCP: quem pede `refresh_token` ganha `openid offline_access` no escopo
65
+ * registrado, senão o provider recusaria pedi-los no authorize.
66
+ */
67
+ export declare function mcpClientRegistration(metadata: Record<string, unknown>): Record<string, unknown>;
68
+ /**
69
+ * `scope`/`prompt` do authorize de um cliente MCP para que saia um refresh token: `offline_access`
70
+ * no escopo e `consent` no prompt (sem ele o provider descarta o `offline_access`). `null` quando
71
+ * já estão lá. `prompt=none` pede "sem interação", o contrário de consentir: sai.
72
+ */
73
+ export declare function withOfflineAccess(params: Record<string, unknown>): {
74
+ scope: string;
75
+ prompt: string;
76
+ } | null;
77
+ /**
78
+ * Middleware do provider (Koa) que aplica {@link withOfflineAccess} ao `GET /auth` de um client
79
+ * registrado dinamicamente que tem o grant `refresh_token`.
80
+ */
81
+ export declare function mcpAuthorizeMiddleware(provider: {
82
+ Client: {
83
+ find(id: string): Promise<any>;
84
+ };
85
+ }): (ctx: any, next: () => Promise<void>) => Promise<void>;