@adonis-agora/authkit-server 0.61.4 → 0.62.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.
@@ -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,29 @@ 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;
35
71
  }
36
72
  /**
37
73
  * Renova o access token do admin via refresh grant (RFC 6749 §6), usando o
@@ -53,20 +89,48 @@ export declare function refreshAccessToken(ctx: HttpContext, params: Pick<StartI
53
89
  * impersonator (= `account_user_id` atual), regenera a sessão (anti-fixation) e
54
90
  * seta `account_user_id = targetId`.
55
91
  *
92
+ * Além da troca de identidade, registra a impersonation: um id único
93
+ * (`impersonationId`), o instante do start e — só com `maxAge` configurado —
94
+ * a expiração, mais os metadados não-secretos do exchange (`actSub`,
95
+ * `exchangeExpiresIn`). Devolve o estado criado (o mesmo shape de
96
+ * `impersonationState`).
97
+ *
56
98
  * - 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).
99
+ * - Recusa (lança) se já houver impersonation ativa (pare a atual antes) —
100
+ * inclusive expirada pelo prazo: expirar só apaga o `active` da leitura,
101
+ * encerrar continua explícito via `stopImpersonation`.
58
102
  * - Recusa (lança) se não houver access token do admin na sessão.
103
+ * - Recusa (lança) `maxAge` não-positivo (fail-fast de misconfiguração).
59
104
  */
60
- export declare function startImpersonation(ctx: HttpContext, params: StartImpersonationParams): Promise<void>;
105
+ export declare function startImpersonation(ctx: HttpContext, params: StartImpersonationParams): Promise<ImpersonationState>;
61
106
  /**
62
107
  * Estado da impersonation pra UI (ex.: banner). `active` quando há um impersonator
63
- * guardado na sessão.
108
+ * guardado na sessão E a impersonation não expirou (expiração preguiçosa: com
109
+ * `maxAge` configurado no start, passado o prazo a leitura devolve
110
+ * `active: false` — mas os dados seguem na sessão até o `stopImpersonation`
111
+ * explícito, que continua restaurando o admin normalmente).
112
+ *
113
+ * O shape evita keys com valor `undefined` (construção condicional): `{ active:
114
+ * false }` puro quando nunca houve impersonation.
64
115
  */
65
116
  export declare function impersonationState(ctx: HttpContext): ImpersonationState;
117
+ /** Opções do `stopImpersonation`. */
118
+ export interface StopImpersonationOptions {
119
+ /**
120
+ * Sink para auditar o encerramento (`impersonation.stopped`, com o
121
+ * `impersonationId`). Sem ele, o stop só mexe na sessão — o start segue
122
+ * auditado pelo IdP, mas o fim da impersonation não aparece na trilha.
123
+ */
124
+ audit?: AuditSink;
125
+ /** IP a registrar no evento de auditoria (o helper não lê o request). */
126
+ ip?: string | null;
127
+ }
66
128
  /**
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.
129
+ * Encerra a impersonation: restaura `account_user_id = impersonator`, remove
130
+ * TODAS as keys de sessão de impersonation (id, tempos, prova do exchange) e
131
+ * regenera a sessão (anti-fixation). Devolve o estado encerrado (com o
132
+ * `impersonationId`, mesmo que já expirado pelo prazo) ou `null` quando não
133
+ * havia nenhuma ativa (no-op — sem audit, sem regenerate).
70
134
  *
71
135
  * O `admin_access_token` (a credencial do ADMIN usada como `subject_token` do
72
136
  * token-exchange) é PRESERVADO: ele é do admin — não do alvo — e o admin segue
@@ -75,4 +139,4 @@ export declare function impersonationState(ctx: HttpContext): ImpersonationState
75
139
  * session). O token segue curto (TTL do access token do RP) e o logout do app
76
140
  * (`ctx.session.clear()` em `AuthRpController.logout`) continua limpando tudo.
77
141
  */
78
- export declare function stopImpersonation(ctx: HttpContext): Promise<void>;
142
+ 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.
@@ -59,6 +70,13 @@ export function rememberRefreshToken(ctx, refreshToken) {
59
70
  * adicioná-la inverteria a direção do grafo de pacotes (server = IdP toolkit). São
60
71
  * ~12 linhas; testável via `fetchImpl`. Lança se o IdP não responder 2xx (é o
61
72
  * gatekeeper: não-admin / token expirado ⇒ erro ⇒ a sessão não é tocada).
73
+ *
74
+ * Em sucesso devolve os metadados NÃO-secretos da resposta (`expires_in` e
75
+ * `act.sub`) — o access token trocado é DESCARTADO de propósito: é uma
76
+ * credencial bearer do alvo e nada no fluxo o consome depois do start
77
+ * (o app age como o alvo via troca de sessão, não via bearer). NUNCA guarde
78
+ * esse token na sessão. O parse do corpo é tolerante: IdP que responde 2xx
79
+ * sem JSON válido ainda conta como exchange OK, só sem metadados.
62
80
  */
63
81
  async function requestTokenExchange(params, subjectToken) {
64
82
  const body = new URLSearchParams({
@@ -82,6 +100,21 @@ async function requestTokenExchange(params, subjectToken) {
82
100
  // NUNCA logamos tokens nem o corpo (pode ecoar segredos). Só o status.
83
101
  throw new Error(`Token exchange failed: ${res.status}`);
84
102
  }
103
+ const result = {};
104
+ try {
105
+ const data = (await res.json());
106
+ if (typeof data?.expires_in === 'number' && Number.isFinite(data.expires_in)) {
107
+ result.expiresIn = data.expires_in;
108
+ }
109
+ const act = data?.act;
110
+ if (act && typeof act.sub === 'string' && act.sub.length > 0) {
111
+ result.actSub = act.sub;
112
+ }
113
+ }
114
+ catch {
115
+ // Corpo fora do JSON esperado: exchange valeu, metadados ficam ausentes.
116
+ }
117
+ return result;
85
118
  }
86
119
  /**
87
120
  * Renova o access token do admin via refresh grant (RFC 6749 §6), usando o
@@ -126,14 +159,26 @@ export async function refreshAccessToken(ctx, params) {
126
159
  * impersonator (= `account_user_id` atual), regenera a sessão (anti-fixation) e
127
160
  * seta `account_user_id = targetId`.
128
161
  *
162
+ * Além da troca de identidade, registra a impersonation: um id único
163
+ * (`impersonationId`), o instante do start e — só com `maxAge` configurado —
164
+ * a expiração, mais os metadados não-secretos do exchange (`actSub`,
165
+ * `exchangeExpiresIn`). Devolve o estado criado (o mesmo shape de
166
+ * `impersonationState`).
167
+ *
129
168
  * - 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).
169
+ * - Recusa (lança) se já houver impersonation ativa (pare a atual antes) —
170
+ * inclusive expirada pelo prazo: expirar só apaga o `active` da leitura,
171
+ * encerrar continua explícito via `stopImpersonation`.
131
172
  * - Recusa (lança) se não houver access token do admin na sessão.
173
+ * - Recusa (lança) `maxAge` não-positivo (fail-fast de misconfiguração).
132
174
  */
133
175
  export async function startImpersonation(ctx, params) {
134
176
  if (ctx.session.get(IMPERSONATOR_SESSION_KEY)) {
135
177
  throw new Error('Impersonation already active; stop the current one before starting another');
136
178
  }
179
+ if (params.maxAge !== undefined && !(params.maxAge > 0)) {
180
+ throw new Error('Invalid maxAge: must be a positive number of seconds');
181
+ }
137
182
  const adminAccessToken = ctx.session.get(ADMIN_ACCESS_TOKEN_SESSION_KEY);
138
183
  if (!adminAccessToken) {
139
184
  throw new Error('No admin access token in session; call rememberAccessToken after login');
@@ -146,8 +191,9 @@ export async function startImpersonation(ctx, params) {
146
191
  }
147
192
  // O IdP é o gatekeeper: lança se o admin não puder personificar. Chamado ANTES
148
193
  // de qualquer mutação de sessão — em caso de erro nada é trocado.
194
+ let exchange;
149
195
  try {
150
- await requestTokenExchange(params, adminAccessToken);
196
+ exchange = await requestTokenExchange(params, adminAccessToken);
151
197
  }
152
198
  catch (err) {
153
199
  // Access token expirado (4xx do exchange): renova via refresh token e tenta
@@ -160,11 +206,14 @@ export async function startImpersonation(ctx, params) {
160
206
  rememberAccessToken(ctx, refreshed.accessToken);
161
207
  if (refreshed.refreshToken)
162
208
  rememberRefreshToken(ctx, refreshed.refreshToken);
163
- await requestTokenExchange(params, refreshed.accessToken);
209
+ exchange = await requestTokenExchange(params, refreshed.accessToken);
164
210
  }
165
211
  // Anti-fixation: rotaciona o id da sessão (mantém os dados) antes de gravar a
166
212
  // nova identidade. Mesmo padrão do consumidor real no RP.
167
213
  await ctx.session.regenerate();
214
+ const impersonationId = randomUUID();
215
+ const startedAt = Date.now();
216
+ const expiresAt = params.maxAge !== undefined ? startedAt + params.maxAge * 1000 : undefined;
168
217
  // ESCALAÇÃO DE PRIVILÉGIO (fechada por vinculação): trocar a conta aqui NÃO
169
218
  // pode carregar junto o sudo que o admin confirmou sobre a PRÓPRIA conta —
170
219
  // senão ele entraria personificando já com a graça aberta sobre a conta
@@ -173,24 +222,61 @@ export async function startImpersonation(ctx, params) {
173
222
  // `isSudoActive` a recusa sozinho assim que `ACCOUNT_SESSION_KEY` muda. A
174
223
  // garantia é estrutural — vale para qualquer troca de conta futura, sem
175
224
  // depender de um `forget` lembrado em cada nova transição.
225
+ ctx.session.put(IMPERSONATION_ID_SESSION_KEY, impersonationId);
226
+ ctx.session.put(IMPERSONATION_STARTED_AT_SESSION_KEY, startedAt);
227
+ if (expiresAt !== undefined)
228
+ ctx.session.put(IMPERSONATION_EXPIRES_AT_SESSION_KEY, expiresAt);
229
+ if (exchange.actSub)
230
+ ctx.session.put(IMPERSONATION_ACT_SESSION_KEY, exchange.actSub);
231
+ if (exchange.expiresIn !== undefined) {
232
+ ctx.session.put(IMPERSONATION_EXCHANGE_EXPIRES_IN_SESSION_KEY, exchange.expiresIn);
233
+ }
176
234
  ctx.session.put(IMPERSONATOR_SESSION_KEY, impersonatorId);
177
235
  ctx.session.put(ACCOUNT_SESSION_KEY, params.targetId);
236
+ return impersonationState(ctx);
178
237
  }
179
238
  /**
180
239
  * Estado da impersonation pra UI (ex.: banner). `active` quando há um impersonator
181
- * guardado na sessão.
240
+ * guardado na sessão E a impersonation não expirou (expiração preguiçosa: com
241
+ * `maxAge` configurado no start, passado o prazo a leitura devolve
242
+ * `active: false` — mas os dados seguem na sessão até o `stopImpersonation`
243
+ * explícito, que continua restaurando o admin normalmente).
244
+ *
245
+ * O shape evita keys com valor `undefined` (construção condicional): `{ active:
246
+ * false }` puro quando nunca houve impersonation.
182
247
  */
183
248
  export function impersonationState(ctx) {
184
249
  const impersonatorId = ctx.session.get(IMPERSONATOR_SESSION_KEY);
185
250
  if (!impersonatorId)
186
251
  return { active: false };
187
252
  const targetId = ctx.session.get(ACCOUNT_SESSION_KEY);
188
- return { active: true, targetId, impersonatorId };
253
+ const state = { active: true, targetId, impersonatorId };
254
+ const impersonationId = ctx.session.get(IMPERSONATION_ID_SESSION_KEY);
255
+ if (impersonationId)
256
+ state.impersonationId = impersonationId;
257
+ const startedAt = ctx.session.get(IMPERSONATION_STARTED_AT_SESSION_KEY);
258
+ if (typeof startedAt === 'number')
259
+ state.startedAt = startedAt;
260
+ const expiresAt = ctx.session.get(IMPERSONATION_EXPIRES_AT_SESSION_KEY);
261
+ if (typeof expiresAt === 'number')
262
+ state.expiresAt = expiresAt;
263
+ const actSub = ctx.session.get(IMPERSONATION_ACT_SESSION_KEY);
264
+ if (actSub)
265
+ state.actSub = actSub;
266
+ const exchangeExpiresIn = ctx.session.get(IMPERSONATION_EXCHANGE_EXPIRES_IN_SESSION_KEY);
267
+ if (typeof exchangeExpiresIn === 'number')
268
+ state.exchangeExpiresIn = exchangeExpiresIn;
269
+ if (state.expiresAt !== undefined && Date.now() > state.expiresAt) {
270
+ state.active = false;
271
+ }
272
+ return state;
189
273
  }
190
274
  /**
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.
275
+ * Encerra a impersonation: restaura `account_user_id = impersonator`, remove
276
+ * TODAS as keys de sessão de impersonation (id, tempos, prova do exchange) e
277
+ * regenera a sessão (anti-fixation). Devolve o estado encerrado (com o
278
+ * `impersonationId`, mesmo que já expirado pelo prazo) ou `null` quando não
279
+ * havia nenhuma ativa (no-op — sem audit, sem regenerate).
194
280
  *
195
281
  * O `admin_access_token` (a credencial do ADMIN usada como `subject_token` do
196
282
  * token-exchange) é PRESERVADO: ele é do admin — não do alvo — e o admin segue
@@ -199,10 +285,10 @@ export function impersonationState(ctx) {
199
285
  * session). O token segue curto (TTL do access token do RP) e o logout do app
200
286
  * (`ctx.session.clear()` em `AuthRpController.logout`) continua limpando tudo.
201
287
  */
202
- export async function stopImpersonation(ctx) {
203
- const impersonatorId = ctx.session.get(IMPERSONATOR_SESSION_KEY);
204
- if (!impersonatorId)
205
- return;
288
+ export async function stopImpersonation(ctx, options) {
289
+ const stopped = impersonationState(ctx);
290
+ if (!stopped.impersonatorId)
291
+ return null;
206
292
  await ctx.session.regenerate();
207
293
  // Simétrico ao `startImpersonation`: o sudo obtido ENQUANTO personificava
208
294
  // ficaria valendo sobre a conta do admin ao voltar. A vinculação corta isso —
@@ -211,6 +297,24 @@ export async function stopImpersonation(ctx) {
211
297
  // Nota: se o admin tinha sudo sobre a própria conta ANTES de personificar e a
212
298
  // graça ainda não venceu, ele volta valendo. Correto e intencional: é a
213
299
  // confirmação dele, sobre a conta dele, dentro da janela dele.
214
- ctx.session.put(ACCOUNT_SESSION_KEY, impersonatorId);
300
+ ctx.session.put(ACCOUNT_SESSION_KEY, stopped.impersonatorId);
215
301
  ctx.session.forget(IMPERSONATOR_SESSION_KEY);
302
+ ctx.session.forget(IMPERSONATION_ID_SESSION_KEY);
303
+ ctx.session.forget(IMPERSONATION_STARTED_AT_SESSION_KEY);
304
+ ctx.session.forget(IMPERSONATION_EXPIRES_AT_SESSION_KEY);
305
+ ctx.session.forget(IMPERSONATION_ACT_SESSION_KEY);
306
+ ctx.session.forget(IMPERSONATION_EXCHANGE_EXPIRES_IN_SESSION_KEY);
307
+ await options?.audit?.record({
308
+ type: 'impersonation.stopped',
309
+ accountId: stopped.targetId ?? null,
310
+ actorId: stopped.impersonatorId,
311
+ ip: options?.ip ?? null,
312
+ metadata: {
313
+ impersonationId: stopped.impersonationId ?? null,
314
+ startedAt: stopped.startedAt ?? null,
315
+ stoppedAt: Date.now(),
316
+ expired: !stopped.active,
317
+ },
318
+ });
319
+ return stopped;
216
320
  }
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.62.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",