@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<
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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<
|
|
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
|
-
|
|
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
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
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.
|
|
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",
|