@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 +2 -2
- package/build/index.js +1 -1
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +3 -0
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/impersonation_session.d.ts +100 -7
- package/build/src/host/impersonation_session.js +184 -22
- package/package.json +1 -1
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<
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 →
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
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.
|
|
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",
|