@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
@@ -0,0 +1,154 @@
1
+ import { resolveRedirectUriPolicy } from '../provider/registration_policy.js';
2
+ /** Callbacks dos clientes MCP conhecidos. Loopback (Claude Code, CLIs) já entra pela política. */
3
+ export const MCP_CLIENT_REDIRECTS = {
4
+ loopback: true,
5
+ exact: [
6
+ 'https://claude.ai/api/mcp/auth_callback',
7
+ 'https://claude.com/api/mcp/auth_callback',
8
+ 'https://chatgpt.com/connector_platform_oauth_redirect',
9
+ 'https://vscode.dev/redirect',
10
+ 'https://insiders.vscode.dev/redirect',
11
+ ],
12
+ appSchemes: ['cursor', 'vscode', 'vscode-insiders'],
13
+ anyHttps: false,
14
+ };
15
+ /** Escopos de um token de servidor MCP: identidade + refresh. */
16
+ export const MCP_RESOURCE_SCOPES = ['openid', 'profile', 'email', 'offline_access'];
17
+ export function resolveMcpOAuth(input) {
18
+ const options = typeof input === 'object' ? input : {};
19
+ const extra = options.redirectUris ?? {};
20
+ return {
21
+ enabled: input === true || typeof input === 'object',
22
+ redirectUriPolicy: resolveRedirectUriPolicy({
23
+ loopback: extra.loopback ?? MCP_CLIENT_REDIRECTS.loopback,
24
+ exact: [...new Set([...MCP_CLIENT_REDIRECTS.exact, ...(extra.exact ?? [])])],
25
+ appSchemes: [...new Set([...MCP_CLIENT_REDIRECTS.appSchemes, ...(extra.appSchemes ?? [])])],
26
+ anyHttps: extra.anyHttps ?? MCP_CLIENT_REDIRECTS.anyHttps,
27
+ }),
28
+ resources: [...(options.resources ?? [])],
29
+ };
30
+ }
31
+ /**
32
+ * Registro em runtime — um slot global, para que outra lib (o MCP do `@adonis-agora/agent`) se
33
+ * registre sem importar esta: o contrato é o símbolo, não o módulo.
34
+ */
35
+ const REGISTRY = Symbol.for('@adonis-agora/oauth:resources');
36
+ function registry() {
37
+ const slot = globalThis;
38
+ if (!Array.isArray(slot[REGISTRY]))
39
+ slot[REGISTRY] = [];
40
+ return slot[REGISTRY];
41
+ }
42
+ export function registerOAuthResource(resource) {
43
+ if (!resource.url && !resource.path) {
44
+ throw new Error('authkit: registerOAuthResource precisa de `url` ou `path`.');
45
+ }
46
+ registry().push({ ...resource });
47
+ }
48
+ export function registeredOAuthResources() {
49
+ return registry();
50
+ }
51
+ const trim = (value) => value.replace(/\/+$/, '');
52
+ function normalizePath(path) {
53
+ return `/${path.replace(/^\/+|\/+$/g, '')}`;
54
+ }
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 function findMcpResource(indicator, issuer, config) {
60
+ if (!config.enabled)
61
+ return null;
62
+ let url;
63
+ try {
64
+ url = new URL(indicator);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ const wanted = trim(url.href);
70
+ for (const declared of config.resources) {
71
+ if (trim(declared) === wanted)
72
+ return { audience: trim(declared), scopes: MCP_RESOURCE_SCOPES };
73
+ }
74
+ const issuerOrigin = new URL(issuer).origin;
75
+ for (const resource of registeredOAuthResources()) {
76
+ const scopes = resource.scopes ?? MCP_RESOURCE_SCOPES;
77
+ if (resource.url && trim(resource.url) === wanted)
78
+ return { audience: trim(resource.url), scopes };
79
+ if (resource.path &&
80
+ url.origin === issuerOrigin &&
81
+ trim(url.pathname) === normalizePath(resource.path) &&
82
+ !url.search) {
83
+ return { audience: wanted, scopes };
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Registro de um cliente MCP: quem pede `refresh_token` ganha `openid offline_access` no escopo
90
+ * registrado, senão o provider recusaria pedi-los no authorize.
91
+ */
92
+ export function mcpClientRegistration(metadata) {
93
+ const grants = Array.isArray(metadata.grant_types)
94
+ ? metadata.grant_types
95
+ : ['authorization_code'];
96
+ if (!grants.includes('refresh_token') || typeof metadata.scope !== 'string')
97
+ return metadata;
98
+ const scopes = metadata.scope.split(' ').filter(Boolean);
99
+ for (const needed of ['openid', 'offline_access']) {
100
+ if (!scopes.includes(needed))
101
+ scopes.push(needed);
102
+ }
103
+ return { ...metadata, scope: scopes.join(' ') };
104
+ }
105
+ /**
106
+ * `scope`/`prompt` do authorize de um cliente MCP para que saia um refresh token: `offline_access`
107
+ * no escopo e `consent` no prompt (sem ele o provider descarta o `offline_access`). `null` quando
108
+ * já estão lá. `prompt=none` pede "sem interação", o contrário de consentir: sai.
109
+ */
110
+ export function withOfflineAccess(params) {
111
+ const scopes = String(params.scope ?? '')
112
+ .split(' ')
113
+ .filter(Boolean);
114
+ const prompts = String(params.prompt ?? '')
115
+ .split(' ')
116
+ .filter(Boolean);
117
+ const needsScope = !scopes.includes('offline_access');
118
+ const needsPrompt = !prompts.includes('consent');
119
+ if (!needsScope && !needsPrompt)
120
+ return null;
121
+ if (!scopes.includes('openid'))
122
+ scopes.unshift('openid');
123
+ if (needsScope)
124
+ scopes.push('offline_access');
125
+ const prompt = needsPrompt ? [...prompts.filter((p) => p !== 'none'), 'consent'] : prompts;
126
+ return { scope: scopes.join(' '), prompt: prompt.join(' ') };
127
+ }
128
+ /**
129
+ * Middleware do provider (Koa) que aplica {@link withOfflineAccess} ao `GET /auth` de um client
130
+ * registrado dinamicamente que tem o grant `refresh_token`.
131
+ */
132
+ export function mcpAuthorizeMiddleware(provider) {
133
+ return async (ctx, next) => {
134
+ if (ctx.method !== 'GET' || ctx.path !== '/auth')
135
+ return next();
136
+ const query = ctx.query;
137
+ const clientId = typeof query.client_id === 'string' ? query.client_id : '';
138
+ if (!clientId || (query.response_type !== undefined && query.response_type !== 'code')) {
139
+ return next();
140
+ }
141
+ const client = await provider.Client.find(clientId).catch(() => undefined);
142
+ const metadata = client?.metadata?.() ?? {};
143
+ const grants = metadata.grant_types;
144
+ if (metadata.client_id_issued_at === undefined ||
145
+ !Array.isArray(grants) ||
146
+ !grants.includes('refresh_token')) {
147
+ return next();
148
+ }
149
+ const widened = withOfflineAccess(query);
150
+ if (widened)
151
+ ctx.query = { ...query, ...widened };
152
+ return next();
153
+ };
154
+ }
@@ -2,9 +2,11 @@ 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
4
  import { assertClientMetadata } from '../host/client_metadata.js';
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';
7
8
  import { registrationPolicyMiddleware } from './registration_policy.js';
9
+ import { impersonationExtraClaims } from './token_exchange.js';
8
10
  /** Atualiza o holder mutável do TTL de sessão com os valores da setting. */
9
11
  export function updateSessionTtlHolder(holder, policy) {
10
12
  holder.rememberSec = Math.max(1, Math.floor(policy.rememberDays * 86400));
@@ -76,7 +78,8 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
76
78
  const key = declaredResources.find((k) => k.replace(/\/+$/, '') === trimmed);
77
79
  return key ? { key, rc: at.resources[key] } : null;
78
80
  };
79
- const resourceIndicatorFeatures = at.anyJwt || declaredResources.length > 0
81
+ const mcp = config.mcp;
82
+ const resourceIndicatorFeatures = at.anyJwt || declaredResources.length > 0 || mcp.enabled
80
83
  ? {
81
84
  resourceIndicators: {
82
85
  enabled: true,
@@ -93,6 +96,15 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
93
96
  getResourceServerInfo: (_ctx, resourceIndicator, _client) => {
94
97
  const found = findResource(resourceIndicator);
95
98
  const isDefault = at.anyJwt && resourceIndicator === at.audience;
99
+ // Servidores MCP (`mcp`): os declarados e os registrados em runtime.
100
+ const mcpResource = found || isDefault ? null : findMcpResource(resourceIndicator, config.issuer, mcp);
101
+ if (mcpResource) {
102
+ return {
103
+ scope: mcpResource.scopes.join(' '),
104
+ audience: mcpResource.audience,
105
+ accessTokenFormat: 'opaque',
106
+ };
107
+ }
96
108
  if (!found && !isDefault) {
97
109
  throw new oidc.errors.InvalidTarget(`resource indicator not allowed: ${resourceIndicator}`);
98
110
  }
@@ -118,6 +130,10 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
118
130
  }
119
131
  : {};
120
132
  const provider = new oidc.Provider(config.issuer, {
133
+ // O ator (`act`, RFC 8693) dos access tokens de impersonation — ver
134
+ // `impersonationExtraClaims` em `token_exchange.ts`. Sem isto o token trocado
135
+ // não se distingue de um token do próprio alvo no resource server.
136
+ extraTokenClaims: async (_ctx, token) => impersonationExtraClaims(token),
121
137
  // Dispatcher por modelo (suportado pelo oidc-provider: `Adapter` aceita
122
138
  // função `(name) => adapter` além de classe). Session-scoped vai pro
123
139
  // `SessionAdapterClass`, o resto pro `AdapterClass` — mesma regra de
@@ -336,6 +352,9 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
336
352
  validate: dynReg.validateRegistration,
337
353
  }));
338
354
  }
355
+ // Refresh token dos clientes MCP registrados dinamicamente (ver mcp/mcp_oauth.ts).
356
+ if (mcp.enabled)
357
+ provider.use(mcpAuthorizeMiddleware(provider));
339
358
  provider.proxy = true;
340
359
  return provider;
341
360
  }
@@ -17,6 +17,14 @@ export declare class OidcService {
17
17
  get publicJwks(): {
18
18
  keys: Record<string, any>[];
19
19
  };
20
+ /**
21
+ * @internal JWKS PRIVADO em uso (mesmas chaves do `publicJwks`). Só para quem
22
+ * assina in-process com o keystore do IdP — tokens de delegação e recibos de
23
+ * personal agents. Troca junto com o provider numa rotação.
24
+ */
25
+ get signingJwks(): {
26
+ keys: Record<string, any>[];
27
+ };
20
28
  /** Pathname do issuer sem barra final (ex.: `/oidc`). Vazio quando montado na raiz. */
21
29
  readonly mountPath: string;
22
30
  readonly recorder: MetricsRecorder;
@@ -14,6 +14,7 @@ export class OidcService {
14
14
  #callback;
15
15
  #interactions;
16
16
  #publicJwks;
17
+ #signingJwks;
17
18
  #appKey;
18
19
  get provider() {
19
20
  return this.#provider;
@@ -32,6 +33,14 @@ export class OidcService {
32
33
  get publicJwks() {
33
34
  return this.#publicJwks;
34
35
  }
36
+ /**
37
+ * @internal JWKS PRIVADO em uso (mesmas chaves do `publicJwks`). Só para quem
38
+ * assina in-process com o keystore do IdP — tokens de delegação e recibos de
39
+ * personal agents. Troca junto com o provider numa rotação.
40
+ */
41
+ get signingJwks() {
42
+ return this.#signingJwks;
43
+ }
35
44
  /** Pathname do issuer sem barra final (ex.: `/oidc`). Vazio quando montado na raiz. */
36
45
  mountPath;
37
46
  recorder;
@@ -181,6 +190,7 @@ export class OidcService {
181
190
  // conta que acabou de desabilitar, recebendo tokens plenamente funcionais.
182
191
  accountStore: config.accountStore,
183
192
  audit: config.audit,
193
+ impersonateAdmins: config.admin.impersonateAdmins,
184
194
  });
185
195
  }
186
196
  // Quando o issuer tem um path (ex.: http://host/oidc), o provider precisa ser
@@ -215,6 +225,7 @@ export class OidcService {
215
225
  this.#callback = callback;
216
226
  this.#interactions = interactions;
217
227
  this.#publicJwks = toPublicJwks(jwks);
228
+ this.#signingJwks = jwks;
218
229
  }
219
230
  /**
220
231
  * Recarrega as chaves de assinatura AO VIVO: relê o keystore do cofre e reconstrói
@@ -56,5 +56,31 @@ export interface TokenExchangeDeps {
56
56
  * regra de todo o resto da lib — nunca quebra hosts com um store mínimo).
57
57
  */
58
58
  accountStore?: AccountStore;
59
+ /**
60
+ * Permite impersonar uma conta que TAMBÉM tem um dos `adminRoles`. Default
61
+ * `true` (back-compat). Com `false`, alvo admin vira `invalid_grant`: um admin
62
+ * não assume a identidade (e os tokens) de outro admin.
63
+ */
64
+ impersonateAdmins?: boolean;
59
65
  }
66
+ /**
67
+ * Propriedade (não persistida) onde a troca marca o ator ANTES do `save()`. O
68
+ * `extraTokenClaims` do provider (`build_provider.ts`) a transforma em
69
+ * `extra.act` — é o único jeito de pôr claims no `extra` de um token opaco: o
70
+ * oidc-provider SOBRESCREVE `extra` com o retorno desse hook ao salvar.
71
+ */
72
+ export declare const IMPERSONATION_ACTOR_PROP = "authkitImpersonationActor";
73
+ /** `extraTokenClaims` do provider: `{ act }` para token de impersonation, nada para o resto. */
74
+ export declare function impersonationExtraClaims(token: unknown): {
75
+ act: {
76
+ sub: string;
77
+ };
78
+ } | undefined;
79
+ /**
80
+ * O ator de um access token de impersonation: o `act` (RFC 8693 §4.1) gravado
81
+ * no `extra` do token trocado. `null` quando o token não é de impersonation.
82
+ */
83
+ export declare function impersonationActorOf(token: {
84
+ extra?: unknown;
85
+ } | null | undefined): string | null;
60
86
  export declare function registerTokenExchange(provider: any, deps: TokenExchangeDeps): void;
@@ -2,6 +2,26 @@ import { errors } from 'oidc-provider';
2
2
  import { assertAccountEnabled } from '../host/login_attempt.js';
3
3
  const TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange';
4
4
  const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
5
+ /**
6
+ * Propriedade (não persistida) onde a troca marca o ator ANTES do `save()`. O
7
+ * `extraTokenClaims` do provider (`build_provider.ts`) a transforma em
8
+ * `extra.act` — é o único jeito de pôr claims no `extra` de um token opaco: o
9
+ * oidc-provider SOBRESCREVE `extra` com o retorno desse hook ao salvar.
10
+ */
11
+ export const IMPERSONATION_ACTOR_PROP = 'authkitImpersonationActor';
12
+ /** `extraTokenClaims` do provider: `{ act }` para token de impersonation, nada para o resto. */
13
+ export function impersonationExtraClaims(token) {
14
+ const actor = token?.[IMPERSONATION_ACTOR_PROP];
15
+ return typeof actor === 'string' && actor ? { act: { sub: actor } } : undefined;
16
+ }
17
+ /**
18
+ * O ator de um access token de impersonation: o `act` (RFC 8693 §4.1) gravado
19
+ * no `extra` do token trocado. `null` quando o token não é de impersonation.
20
+ */
21
+ export function impersonationActorOf(token) {
22
+ const act = token?.extra?.act;
23
+ return typeof act?.sub === 'string' && act.sub ? act.sub : null;
24
+ }
5
25
  /**
6
26
  * Interseção entre os scopes pedidos e os scopes permitidos do client (allowlist).
7
27
  * Preserva a ordem do pedido. Nunca excede a allowlist do client.
@@ -30,6 +50,12 @@ export function registerTokenExchange(provider, deps) {
30
50
  if (!subjectAt || subjectAt.isExpired) {
31
51
  throw new errors.InvalidGrant('subject_token invalid or expired');
32
52
  }
53
+ // Sem impersonation encadeada: um token que JÁ é de impersonation não vira
54
+ // subject de outra troca (o "ator" seria a conta personificada, e a trilha
55
+ // perderia quem de fato está agindo).
56
+ if (impersonationActorOf(subjectAt)) {
57
+ throw new errors.InvalidGrant('subject_token is already an impersonation token');
58
+ }
33
59
  // O subject_token DEVE ter sido emitido para o MESMO client autenticado: senão
34
60
  // um client B poderia trocar um AT emitido para o client A (cross-client).
35
61
  if (subjectAt.clientId !== client?.clientId) {
@@ -62,6 +88,9 @@ export function registerTokenExchange(provider, deps) {
62
88
  if (!target) {
63
89
  throw new errors.InvalidGrant('requested_subject not found');
64
90
  }
91
+ if (target.id === actor.id) {
92
+ throw new errors.InvalidGrant('requested_subject must be another account');
93
+ }
65
94
  // Status do alvo (disabled/expirado): mesmo gate de `attemptPasswordLogin`.
66
95
  // Sem isso, impersonar um alvo desabilitado mintava tokens funcionais para
67
96
  // uma identidade que o admin acreditava ter revogado. `accountStore` é
@@ -107,8 +136,6 @@ export function registerTokenExchange(provider, deps) {
107
136
  // Client sem allowlist declarada: comportamento atual preservado.
108
137
  scope = params.scope || DEFAULT_SCOPE;
109
138
  }
110
- const at = new provider.AccessToken({ accountId: target.id, client, scope });
111
- const accessToken = await at.save();
112
139
  // Token exchange is not tied to a browser session, so there is no active org
113
140
  // context here — roles are resolved for the impersonated target with clientId only.
114
141
  const roles = deps.resolveTokenRoles
@@ -117,6 +144,17 @@ export function registerTokenExchange(provider, deps) {
117
144
  activeOrg: null,
118
145
  })
119
146
  : (target.globalRoles ?? []);
147
+ if (deps.impersonateAdmins === false && roles.some((r) => adminRoles.includes(r))) {
148
+ throw new errors.InvalidGrant('requested_subject is not impersonable');
149
+ }
150
+ // O ACCESS token (não só o id_token) carrega o ator: é o que o resource server
151
+ // vê a cada request (`oidcBearerGuard` → `impersonationState`/`realAccountId`).
152
+ // Sem isso, o token trocado era indistinguível de um token do próprio alvo, e
153
+ // toda regra "negado durante impersonation" deixava passar o app nativo.
154
+ // `extra` sai no JWT (formato jwt) e na introspecção (RFC 7662).
155
+ const at = new provider.AccessToken({ accountId: target.id, client, scope });
156
+ at[IMPERSONATION_ACTOR_PROP] = actor.id;
157
+ const accessToken = await at.save();
120
158
  const idToken = new provider.IdToken({
121
159
  sub: target.id,
122
160
  email: target.email,
@@ -134,7 +172,7 @@ export function registerTokenExchange(provider, deps) {
134
172
  email: target.email ?? null,
135
173
  clientId: client?.clientId ?? null,
136
174
  ip: ctx.req?.socket?.remoteAddress ?? null,
137
- metadata: { scope },
175
+ metadata: { scope, jti: at.jti ?? null },
138
176
  });
139
177
  ctx.body = {
140
178
  access_token: accessToken,
@@ -143,6 +181,9 @@ export function registerTokenExchange(provider, deps) {
143
181
  expires_in: at.expiration ?? 3600,
144
182
  id_token: idTokenJwt,
145
183
  scope,
184
+ // RFC 8693 §4.1: o ator também no corpo da resposta (o `actSub` que o
185
+ // `requestTokenExchange` do host lê — antes ficava sempre vazio).
186
+ act: { sub: actor.id },
146
187
  };
147
188
  };
148
189
  provider.registerGrantType(TOKEN_EXCHANGE, handler, [
@@ -203,6 +203,98 @@ const TABLES = [
203
203
  updated_at: (t) => t.timestamp('updated_at', { useTz: true }).nullable(),
204
204
  },
205
205
  },
206
+ /*
207
+ * As três tabelas de personal agents usam `dateTime(…, precision 3)` e não
208
+ * `timestamp`: no MySQL o TIMESTAMP sem fração arredonda o `last_polled_at`
209
+ * (um agente que respeita o `interval` levaria `slow_down`) e, com
210
+ * `explicit_defaults_for_timestamp=OFF`, o primeiro TIMESTAMP NOT NULL da
211
+ * tabela ganha `ON UPDATE CURRENT_TIMESTAMP` — marcar um refresh como usado
212
+ * reescreveria a validade dele. DATETIME não tem nenhum dos dois; no Postgres
213
+ * vira o mesmo `timestamptz`.
214
+ */
215
+ {
216
+ name: 'auth_agent_device_codes',
217
+ /**
218
+ * Pedidos de delegação de personal agents (device flow, RFC 8628). O
219
+ * `device_code` só existe como hash; o `user_code` é o que o usuário vê.
220
+ * Linhas expiradas são apagadas no próximo pedido.
221
+ */
222
+ create: (t) => {
223
+ t.string('id').primary();
224
+ t.string('device_code_hash', 64).notNullable().unique();
225
+ t.string('user_code', 16).notNullable().unique();
226
+ t.string('client_id', 2048).notNullable();
227
+ t.string('agent_sub').notNullable();
228
+ t.text('requested_scope').notNullable();
229
+ t.string('status', 16).notNullable();
230
+ t.string('account_id').nullable();
231
+ t.string('grant_id').nullable();
232
+ t.integer('interval_seconds').notNullable();
233
+ t.dateTime('last_polled_at', { useTz: true, precision: 3 }).nullable();
234
+ t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable().index();
235
+ t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
236
+ },
237
+ columns: {
238
+ device_code_hash: (t) => t.string('device_code_hash', 64),
239
+ user_code: (t) => t.string('user_code', 16),
240
+ client_id: (t) => t.string('client_id', 2048),
241
+ agent_sub: (t) => t.string('agent_sub'),
242
+ requested_scope: (t) => t.text('requested_scope'),
243
+ status: (t) => t.string('status', 16),
244
+ account_id: (t) => t.string('account_id').nullable(),
245
+ grant_id: (t) => t.string('grant_id').nullable(),
246
+ interval_seconds: (t) => t.integer('interval_seconds'),
247
+ last_polled_at: (t) => t.dateTime('last_polled_at', { useTz: true, precision: 3 }).nullable(),
248
+ expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
249
+ created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
250
+ },
251
+ },
252
+ {
253
+ name: 'auth_agent_grants',
254
+ /**
255
+ * O que um usuário autorizou um personal agent a fazer na conta dele: um
256
+ * grant por (conta, agente, usuário do agente); aprovar mais scopes soma.
257
+ * `revoked_at` preenchido = revogado (os tokens param na próxima request).
258
+ */
259
+ create: (t) => {
260
+ t.string('id').primary();
261
+ t.string('account_id').notNullable().index();
262
+ t.string('client_id', 2048).notNullable();
263
+ t.string('agent_sub').notNullable();
264
+ t.text('scope').notNullable();
265
+ t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable();
266
+ t.dateTime('revoked_at', { useTz: true, precision: 3 }).nullable();
267
+ t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
268
+ t.dateTime('updated_at', { useTz: true, precision: 3 }).notNullable();
269
+ },
270
+ columns: {
271
+ account_id: (t) => t.string('account_id'),
272
+ client_id: (t) => t.string('client_id', 2048),
273
+ agent_sub: (t) => t.string('agent_sub'),
274
+ scope: (t) => t.text('scope'),
275
+ expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
276
+ revoked_at: (t) => t.dateTime('revoked_at', { useTz: true, precision: 3 }).nullable(),
277
+ created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
278
+ updated_at: (t) => t.dateTime('updated_at', { useTz: true, precision: 3 }).nullable(),
279
+ },
280
+ },
281
+ {
282
+ name: 'auth_agent_refresh_tokens',
283
+ /** Refresh tokens dos grants de personal agents — só o hash, uso único. */
284
+ create: (t) => {
285
+ t.string('token_hash', 64).primary();
286
+ t.string('grant_id').notNullable().index();
287
+ t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable();
288
+ t.dateTime('used_at', { useTz: true, precision: 3 }).nullable();
289
+ t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
290
+ },
291
+ columns: {
292
+ grant_id: (t) => t.string('grant_id').index(),
293
+ expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
294
+ used_at: (t) => t.dateTime('used_at', { useTz: true, precision: 3 }).nullable(),
295
+ created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
296
+ },
297
+ },
206
298
  ];
207
299
  /**
208
300
  * Probe searchPath-aware: `schema.hasTable` no Postgres ignora o
@@ -9,14 +9,20 @@ export default function AuthkitConsent({
9
9
  params,
10
10
  csrfToken,
11
11
  brand,
12
+ clientName,
13
+ scopes = [],
12
14
  }: {
13
15
  uid: string;
14
16
  params: { client_id: string };
15
17
  csrfToken: string;
16
18
  brand?: AuthBrand;
19
+ /** O nome que o client registrou (ex.: "Claude Code"); sem ele, o `client_id`. */
20
+ clientName?: string;
21
+ /** Os escopos pedidos, com o rótulo de cada um. */
22
+ scopes?: { id: string; label: string }[];
17
23
  }) {
18
24
  const accent = brand?.accent ?? '#111827';
19
- const appName = brand?.appName ?? params.client_id;
25
+ const appName = clientName ?? params.client_id;
20
26
 
21
27
  return (
22
28
  <AuthShell brand={brand}>
@@ -26,6 +32,16 @@ export default function AuthkitConsent({
26
32
  <p className="mt-2 text-sm text-gray-600">
27
33
  O app <strong>{appName}</strong> quer acessar sua conta.
28
34
  </p>
35
+ {scopes.length > 0 && (
36
+ <>
37
+ <p className="mt-4 text-sm font-medium text-gray-900">Ele vai poder:</p>
38
+ <ul className="mt-2 list-inside list-disc space-y-1 text-sm text-gray-600">
39
+ {scopes.map((scope) => (
40
+ <li key={scope.id}>{scope.label}</li>
41
+ ))}
42
+ </ul>
43
+ </>
44
+ )}
29
45
 
30
46
  <button
31
47
  type="submit"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.74.0",
3
+ "version": "0.76.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",
@@ -9,14 +9,20 @@ export default function AuthkitConsent({
9
9
  params,
10
10
  csrfToken,
11
11
  brand,
12
+ clientName,
13
+ scopes = [],
12
14
  }: {
13
15
  uid: string;
14
16
  params: { client_id: string };
15
17
  csrfToken: string;
16
18
  brand?: AuthBrand;
19
+ /** O nome que o client registrou (ex.: "Claude Code"); sem ele, o `client_id`. */
20
+ clientName?: string;
21
+ /** Os escopos pedidos, com o rótulo de cada um. */
22
+ scopes?: { id: string; label: string }[];
17
23
  }) {
18
24
  const accent = brand?.accent ?? '#111827';
19
- const appName = brand?.appName ?? params.client_id;
25
+ const appName = clientName ?? params.client_id;
20
26
 
21
27
  return (
22
28
  <AuthShell brand={brand}>
@@ -26,6 +32,16 @@ export default function AuthkitConsent({
26
32
  <p className="mt-2 text-sm text-gray-600">
27
33
  O app <strong>{appName}</strong> quer acessar sua conta.
28
34
  </p>
35
+ {scopes.length > 0 && (
36
+ <>
37
+ <p className="mt-4 text-sm font-medium text-gray-900">Ele vai poder:</p>
38
+ <ul className="mt-2 list-inside list-disc space-y-1 text-sm text-gray-600">
39
+ {scopes.map((scope) => (
40
+ <li key={scope.id}>{scope.label}</li>
41
+ ))}
42
+ </ul>
43
+ </>
44
+ )}
29
45
 
30
46
  <button
31
47
  type="submit"