@adonis-agora/authkit-server 0.74.0 → 0.75.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.
@@ -622,11 +622,19 @@ export interface AdminConfigInput {
622
622
  * não altera mais o valor.
623
623
  */
624
624
  impersonation?: boolean;
625
+ /**
626
+ * Permite impersonar uma conta que também tem um dos `roles` de admin. Com
627
+ * `false`, o token-exchange recusa alvo admin (`invalid_grant`): um admin não
628
+ * assume a identidade de outro. Default: **`true`** (back-compat; virar o
629
+ * default é decisão de major, mesma regra de `impersonation`).
630
+ */
631
+ impersonateAdmins?: boolean;
625
632
  }
626
633
  export interface ResolvedAdminConfig {
627
634
  enabled: boolean;
628
635
  roles: string[];
629
636
  impersonation: boolean;
637
+ impersonateAdmins: boolean;
630
638
  }
631
639
  export declare function resolveAdmin(input?: AdminConfigInput): ResolvedAdminConfig;
632
640
  /**
@@ -174,6 +174,7 @@ export function resolveAdmin(input) {
174
174
  // Default `true`: preserva o comportamento histórico (grant sempre
175
175
  // registrado). Ver o docblock de `AdminConfigInput.impersonation`.
176
176
  impersonation: input?.impersonation !== false,
177
+ impersonateAdmins: input?.impersonateAdmins !== false,
177
178
  };
178
179
  }
179
180
  /** Lê API keys de `AUTHKIT_ADMIN_API_KEY` (uma ou várias, separadas por vírgula). */
@@ -18,6 +18,13 @@ export interface VerifiedAccessToken {
18
18
  exp: number | null;
19
19
  /** Id do token (`jti`), quando conhecido. */
20
20
  jti: string | null;
21
+ /**
22
+ * Quem está AGINDO quando o token é de impersonation (RFC 8693 `act.sub`, o
23
+ * admin que trocou o próprio token pelo do `sub`). `null` num token comum.
24
+ * É o que deixa o resource server tratar a request como impersonation
25
+ * (`impersonationState`/`realAccountId`) em vez de confundi-la com o alvo.
26
+ */
27
+ actor: string | null;
21
28
  }
22
29
  /**
23
30
  * Estratégia de verificação de access token plugável no `oidcBearerGuard`.
@@ -10,6 +10,11 @@ function rejectedJwt(error) {
10
10
  !(error instanceof joseErrors.JWKSTimeout) &&
11
11
  !(error instanceof joseErrors.JWKSInvalid));
12
12
  }
13
+ /** `act.sub` (RFC 8693 §4.1) de um payload/`extra`/introspecção, ou `null`. */
14
+ function actorOf(source) {
15
+ const act = source?.act;
16
+ return typeof act?.sub === 'string' && act.sub ? act.sub : null;
17
+ }
13
18
  /** `header.payload.signature` em base64url — o formato compacto de um JWS. */
14
19
  const JWS_COMPACT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
15
20
  function looksLikeJwt(token) {
@@ -44,6 +49,7 @@ function fromJwtPayload(payload) {
44
49
  audience: toAudience(payload.aud),
45
50
  exp: typeof payload.exp === 'number' ? payload.exp : null,
46
51
  jti: typeof payload.jti === 'string' ? payload.jti : null,
52
+ actor: actorOf(payload),
47
53
  };
48
54
  }
49
55
  /**
@@ -121,6 +127,7 @@ export function inProcessAccessTokenVerifier(resolveIssuer) {
121
127
  audience: toAudience(at.aud),
122
128
  exp: typeof at.exp === 'number' ? at.exp : null,
123
129
  jti: typeof at.jti === 'string' ? at.jti : null,
130
+ actor: actorOf(at.extra),
124
131
  };
125
132
  },
126
133
  async issue(accountId, options = {}) {
@@ -212,6 +219,7 @@ export function remoteAccessTokenVerifier(options) {
212
219
  audience: toAudience(body.aud),
213
220
  exp: typeof body.exp === 'number' ? body.exp : null,
214
221
  jti: typeof body.jti === 'string' ? body.jti : null,
222
+ actor: actorOf(body),
215
223
  };
216
224
  };
217
225
  return {
@@ -7,3 +7,20 @@ export declare function clearBearerAccountId(ctx: object): void;
7
7
  * `oidcBearerGuard` autenticou (ainda) a request.
8
8
  */
9
9
  export declare function bearerAccountId(ctx: object): string | null;
10
+ /**
11
+ * Impersonation carregada pelo access token bearer desta request: o ator
12
+ * (`act.sub`, o admin) e a expiração do token. Gravada pelo `oidcBearerGuard`
13
+ * só quando o token é de impersonation; lida por `impersonationState` e
14
+ * `realAccountId` quando não há sessão.
15
+ */
16
+ export interface BearerImpersonation {
17
+ actorId: string;
18
+ /** Epoch em segundos do `exp` do token, quando conhecido. */
19
+ exp: number | null;
20
+ /** `jti` do token trocado, quando conhecido (correlaciona com a auditoria). */
21
+ jti: string | null;
22
+ }
23
+ /** Registra que o token bearer desta request é de impersonation. */
24
+ export declare function setBearerImpersonation(ctx: object, value: BearerImpersonation): void;
25
+ /** Impersonation do token bearer desta request, ou `null`. */
26
+ export declare function bearerImpersonation(ctx: object): BearerImpersonation | null;
@@ -11,6 +11,7 @@
11
11
  * barrel) depende dele.
12
12
  */
13
13
  const bearerAccounts = new WeakMap();
14
+ const bearerImpersonations = new WeakMap();
14
15
  /** Registra a conta autenticada via bearer nesta request. */
15
16
  export function setBearerAccountId(ctx, accountId) {
16
17
  bearerAccounts.set(ctx, accountId);
@@ -18,6 +19,7 @@ export function setBearerAccountId(ctx, accountId) {
18
19
  /** Esquece a conta bearer desta request (falha de autenticação). */
19
20
  export function clearBearerAccountId(ctx) {
20
21
  bearerAccounts.delete(ctx);
22
+ bearerImpersonations.delete(ctx);
21
23
  }
22
24
  /**
23
25
  * Id da conta autenticada via bearer nesta request, ou `null` quando nenhum
@@ -26,3 +28,11 @@ export function clearBearerAccountId(ctx) {
26
28
  export function bearerAccountId(ctx) {
27
29
  return bearerAccounts.get(ctx) ?? null;
28
30
  }
31
+ /** Registra que o token bearer desta request é de impersonation. */
32
+ export function setBearerImpersonation(ctx, value) {
33
+ bearerImpersonations.set(ctx, value);
34
+ }
35
+ /** Impersonation do token bearer desta request, ou `null`. */
36
+ export function bearerImpersonation(ctx) {
37
+ return bearerImpersonations.get(ctx) ?? null;
38
+ }
@@ -65,11 +65,9 @@ export function getAccountId(ctx) {
65
65
  * if (!id || !(await authz.hasRole(id, 'admin'))) throw new Error('forbidden')
66
66
  */
67
67
  export function realAccountId(ctx) {
68
- // Sem sessão não há impersonation: mesma tolerância do `getAccountId` (que usa
69
- // `ctx.session?.`), pois `impersonationState` assume uma sessão presente. Resta
70
- // a identidade bearer (se o `oidcBearerGuard` autenticou a request).
71
- if (!ctx.session)
72
- return bearerAccountId(ctx);
68
+ // `impersonationState` cobre as duas fontes: a sessão do console e o access
69
+ // token bearer trocado (`act`, ex.: app nativo) — nos dois, o humano real é o
70
+ // impersonator. Sem impersonation, a conta da sessão ou do bearer.
73
71
  return impersonationState(ctx).impersonatorId ?? getAccountId(ctx);
74
72
  }
75
73
  /**
@@ -61,6 +61,11 @@ export interface ImpersonationState {
61
61
  * do lado do IdP, NÃO impõe expiração na sessão (só `maxAge` faz isso).
62
62
  */
63
63
  exchangeExpiresIn?: number;
64
+ /**
65
+ * De onde veio a impersonation: `session` (console/web, keys de sessão) ou
66
+ * `bearer` (access token trocado com `act`, ex.: app nativo). Só quando `active`.
67
+ */
68
+ source?: 'session' | 'bearer';
64
69
  }
65
70
  /** Metadados aproveitados da resposta do token-exchange (só o que não é segredo). */
66
71
  export interface TokenExchangeResult {
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
3
+ import { bearerAccountId, bearerImpersonation } from './bearer_account.js';
3
4
  /**
4
5
  * Ergonômico de SESSÃO de browser no RP para "personificar" (impersonate) um
5
6
  * usuário e navegar como ele — roteado pelo token-exchange RFC 8693 que o IdP já
@@ -304,11 +305,16 @@ export async function startImpersonation(ctx, params) {
304
305
  * false }` puro quando nunca houve impersonation.
305
306
  */
306
307
  export function impersonationState(ctx) {
307
- const impersonatorId = ctx.session.get(IMPERSONATOR_SESSION_KEY);
308
- if (!impersonatorId)
309
- return { active: false };
308
+ const impersonatorId = ctx.session?.get(IMPERSONATOR_SESSION_KEY);
309
+ if (!impersonatorId) {
310
+ // Sessão de console logada manda (mesma regra do `getAccountId`): o bearer só
311
+ // conta quando a request não tem conta na sessão.
312
+ if (ctx.session?.get(ACCOUNT_SESSION_KEY))
313
+ return { active: false };
314
+ return bearerImpersonationState(ctx);
315
+ }
310
316
  const targetId = ctx.session.get(ACCOUNT_SESSION_KEY);
311
- const state = { active: true, targetId, impersonatorId };
317
+ const state = { active: true, targetId, impersonatorId, source: 'session' };
312
318
  const impersonationId = ctx.session.get(IMPERSONATION_ID_SESSION_KEY);
313
319
  if (impersonationId)
314
320
  state.impersonationId = impersonationId;
@@ -329,6 +335,33 @@ export function impersonationState(ctx) {
329
335
  }
330
336
  return state;
331
337
  }
338
+ /**
339
+ * Impersonation pelo ACCESS TOKEN (sem sessão): o `oidcBearerGuard` autenticou
340
+ * um token trocado que carrega `act`. O alvo é o `sub` do token; o impersonator,
341
+ * o `act.sub`; a expiração, o `exp` do token (o token trocado não tem refresh).
342
+ * Só existe depois que o guard bearer rodou na request, como o `getAccountId`.
343
+ */
344
+ function bearerImpersonationState(ctx) {
345
+ const bearer = bearerImpersonation(ctx);
346
+ const targetId = bearerAccountId(ctx);
347
+ if (!bearer || !targetId)
348
+ return { active: false };
349
+ const state = {
350
+ active: true,
351
+ targetId,
352
+ impersonatorId: bearer.actorId,
353
+ actSub: bearer.actorId,
354
+ source: 'bearer',
355
+ };
356
+ if (bearer.jti)
357
+ state.impersonationId = bearer.jti;
358
+ if (bearer.exp !== null) {
359
+ state.expiresAt = bearer.exp * 1000;
360
+ if (Date.now() > state.expiresAt)
361
+ state.active = false;
362
+ }
363
+ return state;
364
+ }
332
365
  /**
333
366
  * Encerra a impersonation: restaura `account_user_id = impersonator`, remove
334
367
  * TODAS as keys de sessão de impersonation (id, tempos, prova do exchange) e
@@ -1,6 +1,6 @@
1
1
  import { RuntimeException } from '@adonisjs/core/exceptions';
2
2
  import { inProcessAccessTokenVerifier, remoteAccessTokenVerifier, } from './access_token_verifier.js';
3
- import { clearBearerAccountId, setBearerAccountId } from './bearer_account.js';
3
+ import { clearBearerAccountId, setBearerAccountId, setBearerImpersonation, } from './bearer_account.js';
4
4
  import { cachedUnauthorizedAccessConstructor, loadUnauthorizedAccess, } from './oidc_rp_guard.js';
5
5
  /**
6
6
  * Driver do `E_UNAUTHORIZED_ACCESS` lançado pelo guard. `access_tokens` é o
@@ -144,10 +144,19 @@ export class OidcBearerGuard {
144
144
  const guardUser = await this.#userProvider.findById(token.sub);
145
145
  if (!guardUser)
146
146
  throw this.#fail('invalid_token');
147
+ // Token de impersonation: o ator (o admin) também tem que existir AGORA. Conta
148
+ // apagada/desativada depois da troca derruba o acesso na próxima request, sem
149
+ // esperar o `exp` do token.
150
+ if (token.actor && !(await this.#userProvider.findById(token.actor))) {
151
+ throw this.#fail('invalid_token');
152
+ }
147
153
  this.user = guardUser.getOriginal();
148
154
  this.accessToken = token;
149
155
  this.isAuthenticated = true;
150
156
  setBearerAccountId(this.#ctx, String(guardUser.getId()));
157
+ if (token.actor) {
158
+ setBearerImpersonation(this.#ctx, { actorId: token.actor, exp: token.exp, jti: token.jti });
159
+ }
151
160
  this.#emitter.emit('oidc_bearer:authentication_succeeded', {
152
161
  ctx: this.#ctx,
153
162
  guardName: this.#name,
@@ -5,6 +5,7 @@ import { assertClientMetadata } from '../host/client_metadata.js';
5
5
  import { createDeviceSources } from './device_sources.js';
6
6
  import { createLogoutSources } from './logout_sources.js';
7
7
  import { registrationPolicyMiddleware } from './registration_policy.js';
8
+ import { impersonationExtraClaims } from './token_exchange.js';
8
9
  /** Atualiza o holder mutável do TTL de sessão com os valores da setting. */
9
10
  export function updateSessionTtlHolder(holder, policy) {
10
11
  holder.rememberSec = Math.max(1, Math.floor(policy.rememberDays * 86400));
@@ -118,6 +119,10 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
118
119
  }
119
120
  : {};
120
121
  const provider = new oidc.Provider(config.issuer, {
122
+ // O ator (`act`, RFC 8693) dos access tokens de impersonation — ver
123
+ // `impersonationExtraClaims` em `token_exchange.ts`. Sem isto o token trocado
124
+ // não se distingue de um token do próprio alvo no resource server.
125
+ extraTokenClaims: async (_ctx, token) => impersonationExtraClaims(token),
121
126
  // Dispatcher por modelo (suportado pelo oidc-provider: `Adapter` aceita
122
127
  // função `(name) => adapter` além de classe). Session-scoped vai pro
123
128
  // `SessionAdapterClass`, o resto pro `AdapterClass` — mesma regra de
@@ -181,6 +181,7 @@ export class OidcService {
181
181
  // conta que acabou de desabilitar, recebendo tokens plenamente funcionais.
182
182
  accountStore: config.accountStore,
183
183
  audit: config.audit,
184
+ impersonateAdmins: config.admin.impersonateAdmins,
184
185
  });
185
186
  }
186
187
  // Quando o issuer tem um path (ex.: http://host/oidc), o provider precisa ser
@@ -56,5 +56,31 @@ export interface TokenExchangeDeps {
56
56
  * regra de todo o resto da lib — nunca quebra hosts com um store mínimo).
57
57
  */
58
58
  accountStore?: AccountStore;
59
+ /**
60
+ * Permite impersonar uma conta que TAMBÉM tem um dos `adminRoles`. Default
61
+ * `true` (back-compat). Com `false`, alvo admin vira `invalid_grant`: um admin
62
+ * não assume a identidade (e os tokens) de outro admin.
63
+ */
64
+ impersonateAdmins?: boolean;
59
65
  }
66
+ /**
67
+ * Propriedade (não persistida) onde a troca marca o ator ANTES do `save()`. O
68
+ * `extraTokenClaims` do provider (`build_provider.ts`) a transforma em
69
+ * `extra.act` — é o único jeito de pôr claims no `extra` de um token opaco: o
70
+ * oidc-provider SOBRESCREVE `extra` com o retorno desse hook ao salvar.
71
+ */
72
+ export declare const IMPERSONATION_ACTOR_PROP = "authkitImpersonationActor";
73
+ /** `extraTokenClaims` do provider: `{ act }` para token de impersonation, nada para o resto. */
74
+ export declare function impersonationExtraClaims(token: unknown): {
75
+ act: {
76
+ sub: string;
77
+ };
78
+ } | undefined;
79
+ /**
80
+ * O ator de um access token de impersonation: o `act` (RFC 8693 §4.1) gravado
81
+ * no `extra` do token trocado. `null` quando o token não é de impersonation.
82
+ */
83
+ export declare function impersonationActorOf(token: {
84
+ extra?: unknown;
85
+ } | null | undefined): string | null;
60
86
  export declare function registerTokenExchange(provider: any, deps: TokenExchangeDeps): void;
@@ -2,6 +2,26 @@ import { errors } from 'oidc-provider';
2
2
  import { assertAccountEnabled } from '../host/login_attempt.js';
3
3
  const TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange';
4
4
  const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
5
+ /**
6
+ * Propriedade (não persistida) onde a troca marca o ator ANTES do `save()`. O
7
+ * `extraTokenClaims` do provider (`build_provider.ts`) a transforma em
8
+ * `extra.act` — é o único jeito de pôr claims no `extra` de um token opaco: o
9
+ * oidc-provider SOBRESCREVE `extra` com o retorno desse hook ao salvar.
10
+ */
11
+ export const IMPERSONATION_ACTOR_PROP = 'authkitImpersonationActor';
12
+ /** `extraTokenClaims` do provider: `{ act }` para token de impersonation, nada para o resto. */
13
+ export function impersonationExtraClaims(token) {
14
+ const actor = token?.[IMPERSONATION_ACTOR_PROP];
15
+ return typeof actor === 'string' && actor ? { act: { sub: actor } } : undefined;
16
+ }
17
+ /**
18
+ * O ator de um access token de impersonation: o `act` (RFC 8693 §4.1) gravado
19
+ * no `extra` do token trocado. `null` quando o token não é de impersonation.
20
+ */
21
+ export function impersonationActorOf(token) {
22
+ const act = token?.extra?.act;
23
+ return typeof act?.sub === 'string' && act.sub ? act.sub : null;
24
+ }
5
25
  /**
6
26
  * Interseção entre os scopes pedidos e os scopes permitidos do client (allowlist).
7
27
  * Preserva a ordem do pedido. Nunca excede a allowlist do client.
@@ -30,6 +50,12 @@ export function registerTokenExchange(provider, deps) {
30
50
  if (!subjectAt || subjectAt.isExpired) {
31
51
  throw new errors.InvalidGrant('subject_token invalid or expired');
32
52
  }
53
+ // Sem impersonation encadeada: um token que JÁ é de impersonation não vira
54
+ // subject de outra troca (o "ator" seria a conta personificada, e a trilha
55
+ // perderia quem de fato está agindo).
56
+ if (impersonationActorOf(subjectAt)) {
57
+ throw new errors.InvalidGrant('subject_token is already an impersonation token');
58
+ }
33
59
  // O subject_token DEVE ter sido emitido para o MESMO client autenticado: senão
34
60
  // um client B poderia trocar um AT emitido para o client A (cross-client).
35
61
  if (subjectAt.clientId !== client?.clientId) {
@@ -62,6 +88,9 @@ export function registerTokenExchange(provider, deps) {
62
88
  if (!target) {
63
89
  throw new errors.InvalidGrant('requested_subject not found');
64
90
  }
91
+ if (target.id === actor.id) {
92
+ throw new errors.InvalidGrant('requested_subject must be another account');
93
+ }
65
94
  // Status do alvo (disabled/expirado): mesmo gate de `attemptPasswordLogin`.
66
95
  // Sem isso, impersonar um alvo desabilitado mintava tokens funcionais para
67
96
  // uma identidade que o admin acreditava ter revogado. `accountStore` é
@@ -107,8 +136,6 @@ export function registerTokenExchange(provider, deps) {
107
136
  // Client sem allowlist declarada: comportamento atual preservado.
108
137
  scope = params.scope || DEFAULT_SCOPE;
109
138
  }
110
- const at = new provider.AccessToken({ accountId: target.id, client, scope });
111
- const accessToken = await at.save();
112
139
  // Token exchange is not tied to a browser session, so there is no active org
113
140
  // context here — roles are resolved for the impersonated target with clientId only.
114
141
  const roles = deps.resolveTokenRoles
@@ -117,6 +144,17 @@ export function registerTokenExchange(provider, deps) {
117
144
  activeOrg: null,
118
145
  })
119
146
  : (target.globalRoles ?? []);
147
+ if (deps.impersonateAdmins === false && roles.some((r) => adminRoles.includes(r))) {
148
+ throw new errors.InvalidGrant('requested_subject is not impersonable');
149
+ }
150
+ // O ACCESS token (não só o id_token) carrega o ator: é o que o resource server
151
+ // vê a cada request (`oidcBearerGuard` → `impersonationState`/`realAccountId`).
152
+ // Sem isso, o token trocado era indistinguível de um token do próprio alvo, e
153
+ // toda regra "negado durante impersonation" deixava passar o app nativo.
154
+ // `extra` sai no JWT (formato jwt) e na introspecção (RFC 7662).
155
+ const at = new provider.AccessToken({ accountId: target.id, client, scope });
156
+ at[IMPERSONATION_ACTOR_PROP] = actor.id;
157
+ const accessToken = await at.save();
120
158
  const idToken = new provider.IdToken({
121
159
  sub: target.id,
122
160
  email: target.email,
@@ -134,7 +172,7 @@ export function registerTokenExchange(provider, deps) {
134
172
  email: target.email ?? null,
135
173
  clientId: client?.clientId ?? null,
136
174
  ip: ctx.req?.socket?.remoteAddress ?? null,
137
- metadata: { scope },
175
+ metadata: { scope, jti: at.jti ?? null },
138
176
  });
139
177
  ctx.body = {
140
178
  access_token: accessToken,
@@ -143,6 +181,9 @@ export function registerTokenExchange(provider, deps) {
143
181
  expires_in: at.expiration ?? 3600,
144
182
  id_token: idTokenJwt,
145
183
  scope,
184
+ // RFC 8693 §4.1: o ator também no corpo da resposta (o `actSub` que o
185
+ // `requestTokenExchange` do host lê — antes ficava sempre vazio).
186
+ act: { sub: actor.id },
146
187
  };
147
188
  };
148
189
  provider.registerGrantType(TOKEN_EXCHANGE, handler, [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.74.0",
3
+ "version": "0.75.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",