@adonis-agora/authkit-server 0.61.4 → 0.63.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.
package/build/index.d.ts CHANGED
@@ -69,8 +69,8 @@ export type { AuthMessages, I18nConfig } from './src/host/i18n.js';
69
69
  export { BUILTIN_MESSAGES, DEFAULT_LOCALE, DEFAULT_MESSAGES, PT_BR_MESSAGES, resolveMessages, translate, } from './src/host/i18n.js';
70
70
  export type { ImpersonationClientLike, ImpersonationPanel } from './src/host/impersonation.js';
71
71
  export { buildImpersonationPanel } from './src/host/impersonation.js';
72
- export type { ImpersonationState, StartImpersonationParams, } from './src/host/impersonation_session.js';
73
- export { impersonationState, refreshAccessToken, rememberAccessToken, rememberRefreshToken, startImpersonation, stopImpersonation, } from './src/host/impersonation_session.js';
72
+ export type { ImpersonationStartErrorCode, ImpersonationState, StartImpersonationParams, StopImpersonationOptions, TokenExchangeResult, } from './src/host/impersonation_session.js';
73
+ export { ImpersonationStartError, impersonationState, refreshAccessToken, rememberAccessToken, rememberRefreshToken, startImpersonation, stopImpersonation, } from './src/host/impersonation_session.js';
74
74
  export type { KeysStatus as ServerKeysStatus } from './src/host/key_rotation_actions.js';
75
75
  export { buildKeysStatus, rotateNow } from './src/host/key_rotation_actions.js';
76
76
  export type { OidcRpGuardEvents, OidcRpGuardOptions } from './src/host/oidc_rp_guard.js';
package/build/index.js CHANGED
@@ -54,7 +54,7 @@ export { buildImpersonationPanel } from './src/host/impersonation.js';
54
54
  // Session impersonation — RP-side glue that routes through the IdP's RFC 8693
55
55
  // token-exchange (the IdP validates the admin role + audits). See
56
56
  // src/host/impersonation_session.ts.
57
- export { impersonationState, refreshAccessToken, rememberAccessToken, rememberRefreshToken, startImpersonation, stopImpersonation, } from './src/host/impersonation_session.js';
57
+ export { ImpersonationStartError, impersonationState, refreshAccessToken, rememberAccessToken, rememberRefreshToken, startImpersonation, stopImpersonation, } from './src/host/impersonation_session.js';
58
58
  // Key rotation actions — shared between the Admin REST API controller and the SDK embedded driver.
59
59
  export { buildKeysStatus, rotateNow } from './src/host/key_rotation_actions.js';
60
60
  // @adonisjs/auth integration (opt-in) — guard pra Relying Parties OIDC.
@@ -8,7 +8,7 @@
8
8
  * varre os `audit.record({ type: ... })` do `src/` e falha quando um evento
9
9
  * emitido não está aqui.
10
10
  */
11
- export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'mfa.enabled', 'mfa.disabled', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'roles_catalog.updated'];
11
+ export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'impersonation.stopped', 'mfa.enabled', 'mfa.disabled', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'roles_catalog.updated'];
12
12
  /** Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP. */
13
13
  export type AuditEventType = (typeof AUDIT_EVENT_TYPES)[number];
14
14
  /**
@@ -24,6 +24,9 @@ export const AUDIT_EVENT_TYPES = [
24
24
  // acontecer. Manter os dois separados é o que impede a trilha de auditoria de
25
25
  // afirmar uma impersonação que só foi consultada.
26
26
  'impersonation.panel_viewed',
27
+ // Uma impersonation foi encerrada no RP (`stopImpersonation`, com o
28
+ // `impersonationId` no metadata). Fecha o par aberto pelo `impersonation`.
29
+ 'impersonation.stopped',
27
30
  'mfa.enabled',
28
31
  'mfa.disabled',
29
32
  'account.locked',
@@ -48,7 +48,7 @@ export declare function grantDto(grant: AdminGrant): {
48
48
  };
49
49
  export declare function auditDto(event: StoredAuditEvent): {
50
50
  id: string;
51
- type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
51
+ type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "impersonation.stopped" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
52
52
  accountId: string | null;
53
53
  email: string | null;
54
54
  clientId: string | null;
@@ -1,4 +1,5 @@
1
1
  import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { AuditSink } from '../audit/audit_sink.js';
2
3
  /**
3
4
  * Guarda o access token do admin na sessão (chame no callback OIDC do RP, logo
4
5
  * após o login). Necessário porque o token-exchange exige o access token do admin
@@ -25,6 +26,18 @@ export interface StartImpersonationParams {
25
26
  tokenEndpoint?: string;
26
27
  scope?: string;
27
28
  fetchImpl?: typeof fetch;
29
+ /**
30
+ * Vida máxima da impersonation em SEGUNDOS, contada do start. Quando
31
+ * definido, `impersonationState().active` passa a `false` após o prazo
32
+ * (expiração preguiçosa — nenhum timer roda; a leitura decide) e o stop
33
+ * continua restaurando o admin normalmente.
34
+ *
35
+ * OPCIONAL e ausente por default: sem ele a impersonation dura até o stop
36
+ * explícito / logout / fim da sessão (comportamento histórico — um default
37
+ * com prazo faria a impersonation "parar sozinha" para quem não configurou
38
+ * nada). Deve ser > 0.
39
+ */
40
+ maxAge?: number;
28
41
  }
29
42
  export interface ImpersonationState {
30
43
  active: boolean;
@@ -32,6 +45,52 @@ export interface ImpersonationState {
32
45
  targetId?: string;
33
46
  /** O admin real (impersonator), quando `active`. */
34
47
  impersonatorId?: string;
48
+ /** Id único desta impersonation (gerado no start). */
49
+ impersonationId?: string;
50
+ /** Epoch ms do start. */
51
+ startedAt?: number;
52
+ /** Epoch ms da expiração — só quando `maxAge` foi configurado. */
53
+ expiresAt?: number;
54
+ /**
55
+ * `act.sub` devolvido pelo token-exchange (o ator PROVADO pelo IdP, que
56
+ * deve coincidir com `impersonatorId`).
57
+ */
58
+ actSub?: string;
59
+ /**
60
+ * `expires_in` (segundos) do token trocado — INFORMATIVO: é a vida do token
61
+ * do lado do IdP, NÃO impõe expiração na sessão (só `maxAge` faz isso).
62
+ */
63
+ exchangeExpiresIn?: number;
64
+ }
65
+ /** Metadados aproveitados da resposta do token-exchange (só o que não é segredo). */
66
+ export interface TokenExchangeResult {
67
+ /** `expires_in` (s) do token trocado, quando o IdP informa. */
68
+ expiresIn?: number;
69
+ /** `act.sub` (ator provado pelo IdP), quando o IdP informa. */
70
+ actSub?: string;
71
+ }
72
+ /**
73
+ * Códigos de falha do `startImpersonation` — o controller mapeia cada um numa
74
+ * mensagem amigável (sem vazar segredo), em vez de um genérico único que
75
+ * obriga adivinhar entre "token expirado", "sem refresh" e "IdP recusou".
76
+ */
77
+ export type ImpersonationStartErrorCode = 'already_active' | 'invalid_max_age' | 'no_admin_access_token' | 'no_account_session' | 'no_refresh_token' | 'refresh_rejected' | 'exchange_rejected';
78
+ /**
79
+ * Erro do `startImpersonation` com `code` machine-readable. `status` é o HTTP
80
+ * do token/refresh endpoint; `idpError` é o campo `error` do corpo
81
+ * (`invalid_grant`, `invalid_request`, …) — um código de allowlist, NUNCA
82
+ * tokens nem `error_description` (pode ecoar segredos). `cause` encadeia o
83
+ * erro original (ex.: o 400 do exchange que motivou a tentativa de refresh).
84
+ */
85
+ export declare class ImpersonationStartError extends Error {
86
+ readonly code: ImpersonationStartErrorCode;
87
+ readonly status?: number;
88
+ readonly idpError?: string;
89
+ constructor(code: ImpersonationStartErrorCode, message: string, options?: {
90
+ status?: number;
91
+ idpError?: string;
92
+ cause?: unknown;
93
+ });
35
94
  }
36
95
  /**
37
96
  * Renova o access token do admin via refresh grant (RFC 6749 §6), usando o
@@ -53,20 +112,54 @@ export declare function refreshAccessToken(ctx: HttpContext, params: Pick<StartI
53
112
  * impersonator (= `account_user_id` atual), regenera a sessão (anti-fixation) e
54
113
  * seta `account_user_id = targetId`.
55
114
  *
115
+ * Além da troca de identidade, registra a impersonation: um id único
116
+ * (`impersonationId`), o instante do start e — só com `maxAge` configurado —
117
+ * a expiração, mais os metadados não-secretos do exchange (`actSub`,
118
+ * `exchangeExpiresIn`). Devolve o estado criado (o mesmo shape de
119
+ * `impersonationState`).
120
+ *
56
121
  * - Se o exchange falhar, LANÇA e NÃO troca NADA na sessão.
57
- * - Recusa (lança) se já houver impersonation ativa (pare a atual antes).
122
+ * - Recusa (lança) se já houver impersonation ativa (pare a atual antes) —
123
+ * inclusive expirada pelo prazo: expirar só apaga o `active` da leitura,
124
+ * encerrar continua explícito via `stopImpersonation`.
58
125
  * - Recusa (lança) se não houver access token do admin na sessão.
126
+ * - Recusa (lança) `maxAge` não-positivo (fail-fast de misconfiguração).
127
+ *
128
+ * Toda falha é um `ImpersonationStartError` com `code` (`already_active`,
129
+ * `invalid_max_age`, `no_admin_access_token`, `no_account_session`,
130
+ * `no_refresh_token`, `refresh_rejected`, `exchange_rejected`) — o controller
131
+ * traduz cada um numa mensagem amigável. `status`/`idpError` carregam o HTTP
132
+ * e o `error` do IdP (códigos seguros, sem segredos) para o log server-side.
59
133
  */
60
- export declare function startImpersonation(ctx: HttpContext, params: StartImpersonationParams): Promise<void>;
134
+ export declare function startImpersonation(ctx: HttpContext, params: StartImpersonationParams): Promise<ImpersonationState>;
61
135
  /**
62
136
  * Estado da impersonation pra UI (ex.: banner). `active` quando há um impersonator
63
- * guardado na sessão.
137
+ * guardado na sessão E a impersonation não expirou (expiração preguiçosa: com
138
+ * `maxAge` configurado no start, passado o prazo a leitura devolve
139
+ * `active: false` — mas os dados seguem na sessão até o `stopImpersonation`
140
+ * explícito, que continua restaurando o admin normalmente).
141
+ *
142
+ * O shape evita keys com valor `undefined` (construção condicional): `{ active:
143
+ * false }` puro quando nunca houve impersonation.
64
144
  */
65
145
  export declare function impersonationState(ctx: HttpContext): ImpersonationState;
146
+ /** Opções do `stopImpersonation`. */
147
+ export interface StopImpersonationOptions {
148
+ /**
149
+ * Sink para auditar o encerramento (`impersonation.stopped`, com o
150
+ * `impersonationId`). Sem ele, o stop só mexe na sessão — o start segue
151
+ * auditado pelo IdP, mas o fim da impersonation não aparece na trilha.
152
+ */
153
+ audit?: AuditSink;
154
+ /** IP a registrar no evento de auditoria (o helper não lê o request). */
155
+ ip?: string | null;
156
+ }
66
157
  /**
67
- * Encerra a impersonation: restaura `account_user_id = impersonator`, remove a
68
- * key de impersonation e regenera a sessão (anti-fixation). No-op quando não há
69
- * impersonation ativa.
158
+ * Encerra a impersonation: restaura `account_user_id = impersonator`, remove
159
+ * TODAS as keys de sessão de impersonation (id, tempos, prova do exchange) e
160
+ * regenera a sessão (anti-fixation). Devolve o estado encerrado (com o
161
+ * `impersonationId`, mesmo que já expirado pelo prazo) ou `null` quando não
162
+ * havia nenhuma ativa (no-op — sem audit, sem regenerate).
70
163
  *
71
164
  * O `admin_access_token` (a credencial do ADMIN usada como `subject_token` do
72
165
  * token-exchange) é PRESERVADO: ele é do admin — não do alvo — e o admin segue
@@ -75,4 +168,4 @@ export declare function impersonationState(ctx: HttpContext): ImpersonationState
75
168
  * session). O token segue curto (TTL do access token do RP) e o logout do app
76
169
  * (`ctx.session.clear()` em `AuthRpController.logout`) continua limpando tudo.
77
170
  */
78
- export declare function stopImpersonation(ctx: HttpContext): Promise<void>;
171
+ export declare function stopImpersonation(ctx: HttpContext, options?: StopImpersonationOptions): Promise<ImpersonationState | null>;
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
2
3
  /**
3
4
  * Ergonômico de SESSÃO de browser no RP para "personificar" (impersonate) um
@@ -22,6 +23,16 @@ const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
22
23
  * implementação, leia via `impersonationState`.
23
24
  */
24
25
  const IMPERSONATOR_SESSION_KEY = 'impersonator_user_id';
26
+ /**
27
+ * Keys de sessão do registro da impersonation (id + tempos + prova do
28
+ * exchange). Internas pelo mesmo motivo da key do impersonator: o contrato
29
+ * público é `impersonationState` / o retorno de `startImpersonation`.
30
+ */
31
+ const IMPERSONATION_ID_SESSION_KEY = 'impersonation_id';
32
+ const IMPERSONATION_STARTED_AT_SESSION_KEY = 'impersonation_started_at';
33
+ const IMPERSONATION_EXPIRES_AT_SESSION_KEY = 'impersonation_expires_at';
34
+ const IMPERSONATION_ACT_SESSION_KEY = 'impersonation_act_sub';
35
+ const IMPERSONATION_EXCHANGE_EXPIRES_IN_SESSION_KEY = 'impersonation_exchange_expires_in';
25
36
  /**
26
37
  * Key de sessão que guarda o access token do admin, necessário como
27
38
  * `subject_token` do token-exchange. Interna: NÃO exporte o literal.
@@ -53,12 +64,55 @@ export function rememberAccessToken(ctx, accessToken) {
53
64
  export function rememberRefreshToken(ctx, refreshToken) {
54
65
  ctx.session.put(ADMIN_REFRESH_TOKEN_SESSION_KEY, refreshToken);
55
66
  }
67
+ /**
68
+ * Erro do `startImpersonation` com `code` machine-readable. `status` é o HTTP
69
+ * do token/refresh endpoint; `idpError` é o campo `error` do corpo
70
+ * (`invalid_grant`, `invalid_request`, …) — um código de allowlist, NUNCA
71
+ * tokens nem `error_description` (pode ecoar segredos). `cause` encadeia o
72
+ * erro original (ex.: o 400 do exchange que motivou a tentativa de refresh).
73
+ */
74
+ export class ImpersonationStartError extends Error {
75
+ code;
76
+ status;
77
+ idpError;
78
+ constructor(code, message, options) {
79
+ super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
80
+ this.name = 'ImpersonationStartError';
81
+ this.code = code;
82
+ if (options?.status !== undefined)
83
+ this.status = options.status;
84
+ if (options?.idpError !== undefined)
85
+ this.idpError = options.idpError;
86
+ }
87
+ }
88
+ /**
89
+ * Extrai o campo `error` do corpo de uma resposta de erro do token endpoint
90
+ * (`invalid_grant`, …). Tolerante: corpo ausente/fora de JSON/sem `error`
91
+ * string ⇒ `undefined`. Só o código — nunca `error_description` (pode ecoar
92
+ * segredos) nem o corpo cru.
93
+ */
94
+ async function readIdpErrorCode(res) {
95
+ try {
96
+ const data = (await res.json());
97
+ return typeof data?.error === 'string' && data.error.length > 0 ? data.error : undefined;
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
56
103
  /**
57
104
  * POST inline do RFC 8693 token-exchange. Inline (em vez de depender de
58
105
  * `@adonis-agora/authkit-client`) porque o client NÃO é dependência do server e
59
106
  * adicioná-la inverteria a direção do grafo de pacotes (server = IdP toolkit). São
60
107
  * ~12 linhas; testável via `fetchImpl`. Lança se o IdP não responder 2xx (é o
61
108
  * gatekeeper: não-admin / token expirado ⇒ erro ⇒ a sessão não é tocada).
109
+ *
110
+ * Em sucesso devolve os metadados NÃO-secretos da resposta (`expires_in` e
111
+ * `act.sub`) — o access token trocado é DESCARTADO de propósito: é uma
112
+ * credencial bearer do alvo e nada no fluxo o consome depois do start
113
+ * (o app age como o alvo via troca de sessão, não via bearer). NUNCA guarde
114
+ * esse token na sessão. O parse do corpo é tolerante: IdP que responde 2xx
115
+ * sem JSON válido ainda conta como exchange OK, só sem metadados.
62
116
  */
63
117
  async function requestTokenExchange(params, subjectToken) {
64
118
  const body = new URLSearchParams({
@@ -79,9 +133,26 @@ async function requestTokenExchange(params, subjectToken) {
79
133
  body: body.toString(),
80
134
  });
81
135
  if (!res.ok) {
82
- // NUNCA logamos tokens nem o corpo (pode ecoar segredos). Só o status.
83
- throw new Error(`Token exchange failed: ${res.status}`);
136
+ // NUNCA logamos tokens nem o corpo (pode ecoar segredos). Só o status +
137
+ // o código `error` do IdP (allowlist como `invalid_grant`).
138
+ const idpError = await readIdpErrorCode(res);
139
+ throw new ImpersonationStartError('exchange_rejected', `Token exchange failed: ${res.status}${idpError ? ` (${idpError})` : ''}`, { status: res.status, idpError });
140
+ }
141
+ const result = {};
142
+ try {
143
+ const data = (await res.json());
144
+ if (typeof data?.expires_in === 'number' && Number.isFinite(data.expires_in)) {
145
+ result.expiresIn = data.expires_in;
146
+ }
147
+ const act = data?.act;
148
+ if (act && typeof act.sub === 'string' && act.sub.length > 0) {
149
+ result.actSub = act.sub;
150
+ }
151
+ }
152
+ catch {
153
+ // Corpo fora do JSON esperado: exchange valeu, metadados ficam ausentes.
84
154
  }
155
+ return result;
85
156
  }
86
157
  /**
87
158
  * Renova o access token do admin via refresh grant (RFC 6749 §6), usando o
@@ -126,45 +197,81 @@ export async function refreshAccessToken(ctx, params) {
126
197
  * impersonator (= `account_user_id` atual), regenera a sessão (anti-fixation) e
127
198
  * seta `account_user_id = targetId`.
128
199
  *
200
+ * Além da troca de identidade, registra a impersonation: um id único
201
+ * (`impersonationId`), o instante do start e — só com `maxAge` configurado —
202
+ * a expiração, mais os metadados não-secretos do exchange (`actSub`,
203
+ * `exchangeExpiresIn`). Devolve o estado criado (o mesmo shape de
204
+ * `impersonationState`).
205
+ *
129
206
  * - Se o exchange falhar, LANÇA e NÃO troca NADA na sessão.
130
- * - Recusa (lança) se já houver impersonation ativa (pare a atual antes).
207
+ * - Recusa (lança) se já houver impersonation ativa (pare a atual antes) —
208
+ * inclusive expirada pelo prazo: expirar só apaga o `active` da leitura,
209
+ * encerrar continua explícito via `stopImpersonation`.
131
210
  * - Recusa (lança) se não houver access token do admin na sessão.
211
+ * - Recusa (lança) `maxAge` não-positivo (fail-fast de misconfiguração).
212
+ *
213
+ * Toda falha é um `ImpersonationStartError` com `code` (`already_active`,
214
+ * `invalid_max_age`, `no_admin_access_token`, `no_account_session`,
215
+ * `no_refresh_token`, `refresh_rejected`, `exchange_rejected`) — o controller
216
+ * traduz cada um numa mensagem amigável. `status`/`idpError` carregam o HTTP
217
+ * e o `error` do IdP (códigos seguros, sem segredos) para o log server-side.
132
218
  */
133
219
  export async function startImpersonation(ctx, params) {
134
220
  if (ctx.session.get(IMPERSONATOR_SESSION_KEY)) {
135
- throw new Error('Impersonation already active; stop the current one before starting another');
221
+ throw new ImpersonationStartError('already_active', 'Impersonation already active; stop the current one before starting another');
222
+ }
223
+ if (params.maxAge !== undefined && !(params.maxAge > 0)) {
224
+ throw new ImpersonationStartError('invalid_max_age', 'Invalid maxAge: must be a positive number of seconds');
136
225
  }
137
226
  const adminAccessToken = ctx.session.get(ADMIN_ACCESS_TOKEN_SESSION_KEY);
138
227
  if (!adminAccessToken) {
139
- throw new Error('No admin access token in session; call rememberAccessToken after login');
228
+ throw new ImpersonationStartError('no_admin_access_token', 'No admin access token in session; call rememberAccessToken after login');
140
229
  }
141
230
  const impersonatorId = ctx.session.get(ACCOUNT_SESSION_KEY);
142
231
  if (!impersonatorId) {
143
232
  // Não há admin logado para impersonar como — sem identidade para restaurar
144
233
  // depois. Recusa antes de qualquer chamada/mutação.
145
- throw new Error('No account session; log in as the admin before impersonating');
234
+ throw new ImpersonationStartError('no_account_session', 'No account session; log in as the admin before impersonating');
146
235
  }
147
236
  // O IdP é o gatekeeper: lança se o admin não puder personificar. Chamado ANTES
148
237
  // de qualquer mutação de sessão — em caso de erro nada é trocado.
238
+ let exchange;
149
239
  try {
150
- await requestTokenExchange(params, adminAccessToken);
240
+ exchange = await requestTokenExchange(params, adminAccessToken);
151
241
  }
152
242
  catch (err) {
153
243
  // Access token expirado (4xx do exchange): renova via refresh token e tenta
154
- // de novo. Sem refresh token / refresh falho → propaga o erro original.
155
- if (!(err instanceof Error) || !/exchange failed: 4\d\d/.test(err.message))
244
+ // de novo. Sem refresh token / refresh falho → erro TIPADO (o controller
245
+ // distingue "entre de novo" de "IdP recusou"), com o 4xx original em `cause`.
246
+ const status = err instanceof ImpersonationStartError ? err.status : undefined;
247
+ const isExpiredToken = err instanceof Error && status !== undefined && status >= 400 && status < 500;
248
+ if (!isExpiredToken)
156
249
  throw err;
250
+ const hasRefreshToken = Boolean(ctx.session.get(ADMIN_REFRESH_TOKEN_SESSION_KEY));
251
+ if (!hasRefreshToken) {
252
+ throw new ImpersonationStartError('no_refresh_token', `Admin access token expired (Token exchange failed: ${status}${err instanceof ImpersonationStartError && err.idpError ? ` (${err.idpError})` : ''}) and no refresh token in session; log in again`, {
253
+ status,
254
+ ...(err instanceof ImpersonationStartError && err.idpError
255
+ ? { idpError: err.idpError }
256
+ : {}),
257
+ cause: err,
258
+ });
259
+ }
157
260
  const refreshed = await refreshAccessToken(ctx, params);
158
- if (!refreshed)
159
- throw err;
261
+ if (!refreshed) {
262
+ throw new ImpersonationStartError('refresh_rejected', 'Admin access token expired and refresh token was rejected; log in again', { status, cause: err });
263
+ }
160
264
  rememberAccessToken(ctx, refreshed.accessToken);
161
265
  if (refreshed.refreshToken)
162
266
  rememberRefreshToken(ctx, refreshed.refreshToken);
163
- await requestTokenExchange(params, refreshed.accessToken);
267
+ exchange = await requestTokenExchange(params, refreshed.accessToken);
164
268
  }
165
269
  // Anti-fixation: rotaciona o id da sessão (mantém os dados) antes de gravar a
166
270
  // nova identidade. Mesmo padrão do consumidor real no RP.
167
271
  await ctx.session.regenerate();
272
+ const impersonationId = randomUUID();
273
+ const startedAt = Date.now();
274
+ const expiresAt = params.maxAge !== undefined ? startedAt + params.maxAge * 1000 : undefined;
168
275
  // ESCALAÇÃO DE PRIVILÉGIO (fechada por vinculação): trocar a conta aqui NÃO
169
276
  // pode carregar junto o sudo que o admin confirmou sobre a PRÓPRIA conta —
170
277
  // senão ele entraria personificando já com a graça aberta sobre a conta
@@ -173,24 +280,61 @@ export async function startImpersonation(ctx, params) {
173
280
  // `isSudoActive` a recusa sozinho assim que `ACCOUNT_SESSION_KEY` muda. A
174
281
  // garantia é estrutural — vale para qualquer troca de conta futura, sem
175
282
  // depender de um `forget` lembrado em cada nova transição.
283
+ ctx.session.put(IMPERSONATION_ID_SESSION_KEY, impersonationId);
284
+ ctx.session.put(IMPERSONATION_STARTED_AT_SESSION_KEY, startedAt);
285
+ if (expiresAt !== undefined)
286
+ ctx.session.put(IMPERSONATION_EXPIRES_AT_SESSION_KEY, expiresAt);
287
+ if (exchange.actSub)
288
+ ctx.session.put(IMPERSONATION_ACT_SESSION_KEY, exchange.actSub);
289
+ if (exchange.expiresIn !== undefined) {
290
+ ctx.session.put(IMPERSONATION_EXCHANGE_EXPIRES_IN_SESSION_KEY, exchange.expiresIn);
291
+ }
176
292
  ctx.session.put(IMPERSONATOR_SESSION_KEY, impersonatorId);
177
293
  ctx.session.put(ACCOUNT_SESSION_KEY, params.targetId);
294
+ return impersonationState(ctx);
178
295
  }
179
296
  /**
180
297
  * Estado da impersonation pra UI (ex.: banner). `active` quando há um impersonator
181
- * guardado na sessão.
298
+ * guardado na sessão E a impersonation não expirou (expiração preguiçosa: com
299
+ * `maxAge` configurado no start, passado o prazo a leitura devolve
300
+ * `active: false` — mas os dados seguem na sessão até o `stopImpersonation`
301
+ * explícito, que continua restaurando o admin normalmente).
302
+ *
303
+ * O shape evita keys com valor `undefined` (construção condicional): `{ active:
304
+ * false }` puro quando nunca houve impersonation.
182
305
  */
183
306
  export function impersonationState(ctx) {
184
307
  const impersonatorId = ctx.session.get(IMPERSONATOR_SESSION_KEY);
185
308
  if (!impersonatorId)
186
309
  return { active: false };
187
310
  const targetId = ctx.session.get(ACCOUNT_SESSION_KEY);
188
- return { active: true, targetId, impersonatorId };
311
+ const state = { active: true, targetId, impersonatorId };
312
+ const impersonationId = ctx.session.get(IMPERSONATION_ID_SESSION_KEY);
313
+ if (impersonationId)
314
+ state.impersonationId = impersonationId;
315
+ const startedAt = ctx.session.get(IMPERSONATION_STARTED_AT_SESSION_KEY);
316
+ if (typeof startedAt === 'number')
317
+ state.startedAt = startedAt;
318
+ const expiresAt = ctx.session.get(IMPERSONATION_EXPIRES_AT_SESSION_KEY);
319
+ if (typeof expiresAt === 'number')
320
+ state.expiresAt = expiresAt;
321
+ const actSub = ctx.session.get(IMPERSONATION_ACT_SESSION_KEY);
322
+ if (actSub)
323
+ state.actSub = actSub;
324
+ const exchangeExpiresIn = ctx.session.get(IMPERSONATION_EXCHANGE_EXPIRES_IN_SESSION_KEY);
325
+ if (typeof exchangeExpiresIn === 'number')
326
+ state.exchangeExpiresIn = exchangeExpiresIn;
327
+ if (state.expiresAt !== undefined && Date.now() > state.expiresAt) {
328
+ state.active = false;
329
+ }
330
+ return state;
189
331
  }
190
332
  /**
191
- * Encerra a impersonation: restaura `account_user_id = impersonator`, remove a
192
- * key de impersonation e regenera a sessão (anti-fixation). No-op quando não há
193
- * impersonation ativa.
333
+ * Encerra a impersonation: restaura `account_user_id = impersonator`, remove
334
+ * TODAS as keys de sessão de impersonation (id, tempos, prova do exchange) e
335
+ * regenera a sessão (anti-fixation). Devolve o estado encerrado (com o
336
+ * `impersonationId`, mesmo que já expirado pelo prazo) ou `null` quando não
337
+ * havia nenhuma ativa (no-op — sem audit, sem regenerate).
194
338
  *
195
339
  * O `admin_access_token` (a credencial do ADMIN usada como `subject_token` do
196
340
  * token-exchange) é PRESERVADO: ele é do admin — não do alvo — e o admin segue
@@ -199,10 +343,10 @@ export function impersonationState(ctx) {
199
343
  * session). O token segue curto (TTL do access token do RP) e o logout do app
200
344
  * (`ctx.session.clear()` em `AuthRpController.logout`) continua limpando tudo.
201
345
  */
202
- export async function stopImpersonation(ctx) {
203
- const impersonatorId = ctx.session.get(IMPERSONATOR_SESSION_KEY);
204
- if (!impersonatorId)
205
- return;
346
+ export async function stopImpersonation(ctx, options) {
347
+ const stopped = impersonationState(ctx);
348
+ if (!stopped.impersonatorId)
349
+ return null;
206
350
  await ctx.session.regenerate();
207
351
  // Simétrico ao `startImpersonation`: o sudo obtido ENQUANTO personificava
208
352
  // ficaria valendo sobre a conta do admin ao voltar. A vinculação corta isso —
@@ -211,6 +355,24 @@ export async function stopImpersonation(ctx) {
211
355
  // Nota: se o admin tinha sudo sobre a própria conta ANTES de personificar e a
212
356
  // graça ainda não venceu, ele volta valendo. Correto e intencional: é a
213
357
  // confirmação dele, sobre a conta dele, dentro da janela dele.
214
- ctx.session.put(ACCOUNT_SESSION_KEY, impersonatorId);
358
+ ctx.session.put(ACCOUNT_SESSION_KEY, stopped.impersonatorId);
215
359
  ctx.session.forget(IMPERSONATOR_SESSION_KEY);
360
+ ctx.session.forget(IMPERSONATION_ID_SESSION_KEY);
361
+ ctx.session.forget(IMPERSONATION_STARTED_AT_SESSION_KEY);
362
+ ctx.session.forget(IMPERSONATION_EXPIRES_AT_SESSION_KEY);
363
+ ctx.session.forget(IMPERSONATION_ACT_SESSION_KEY);
364
+ ctx.session.forget(IMPERSONATION_EXCHANGE_EXPIRES_IN_SESSION_KEY);
365
+ await options?.audit?.record({
366
+ type: 'impersonation.stopped',
367
+ accountId: stopped.targetId ?? null,
368
+ actorId: stopped.impersonatorId,
369
+ ip: options?.ip ?? null,
370
+ metadata: {
371
+ impersonationId: stopped.impersonationId ?? null,
372
+ startedAt: stopped.startedAt ?? null,
373
+ stoppedAt: Date.now(),
374
+ expired: !stopped.active,
375
+ },
376
+ });
377
+ return stopped;
216
378
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.61.4",
3
+ "version": "0.63.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",