@adonis-agora/authkit-server 0.71.0 → 0.73.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.
@@ -17,8 +17,11 @@
17
17
  @end
18
18
 
19
19
  {{-- Step-up sem MFA enrolado: bloqueia o login e instrui a configurar o MFA;
20
- não renderiza o campo de código (não há 2º fator a desafiar). --}}
21
- @if(!noEnrollment)
20
+ não renderiza o campo de código (não há 2º fator a desafiar).
21
+ `totpAvailable === false` = a conta só tem passkey, sem app autenticador
22
+ confirmado: pedir um código de 6 dígitos seria pedir o impossível. Ausente
23
+ (stores que não reportam o estado) mantém o campo visível. --}}
24
+ @if(!noEnrollment && totpAvailable !== false)
22
25
  <div class="mt-6">
23
26
  <label for="code" class="mb-1 block text-sm font-medium text-gray-700">{{ t('mfa_challenge.code_label') }}</label>
24
27
  <input id="code" name="code" inputmode="numeric" autocomplete="one-time-code"
@@ -56,7 +59,9 @@
56
59
  <p id="passkey-error" class="mt-3 hidden text-sm text-red-600">{{ t('mfa_challenge.passkey_error') }}</p>
57
60
  @end
58
61
 
59
- @if(!noEnrollment)
62
+ {{-- Códigos de recuperação só existem junto com o TOTP (são gerados no
63
+ enrollment); sem ele, a seção não tem o que receber. --}}
64
+ @if(!noEnrollment && totpAvailable !== false)
60
65
  <details class="mt-6 text-sm text-gray-600">
61
66
  <summary class="cursor-pointer hover:underline">{{ t('mfa_challenge.recovery_summary') }}</summary>
62
67
  <form method="POST" action="/auth/interaction/{{ uid }}/mfa" class="mt-3">
@@ -250,10 +250,18 @@ export interface MfaCapability {
250
250
  * mecanismo de "trusted devices": um cookie de confiança emitido ANTES desse
251
251
  * instante é considerado inválido (re-enrolar MFA revoga a confiança). Pode ser
252
252
  * `null`/ausente quando o MFA não está ativo ou o store não rastreia o instante.
253
+ *
254
+ * `totp` diz se há um app autenticador CONFIRMADO (segredo + códigos de
255
+ * recuperação). É diferente de `enabled`: registrar uma passkey também liga o
256
+ * MFA, e remover a última passkey não o desliga — então `enabled` sozinho não
257
+ * responde "existe um segundo fator que esta pessoa consegue apresentar?".
258
+ * Quem decide mostrar (ou não) o desafio precisa de `totp`; stores antigos que
259
+ * o omitem continuam funcionando, só não distinguem os dois casos.
253
260
  */
254
261
  getMfaState(accountId: string): Promise<{
255
262
  enabled: boolean;
256
263
  enabledAt?: number | null;
264
+ totp?: boolean;
257
265
  }>;
258
266
  /**
259
267
  * Inicia o enrollment TOTP: gera um segredo PENDENTE (mfaEnabledAt continua
@@ -25,7 +25,15 @@ export function buildMfa(ctx) {
25
25
  const state = await repo.read(accountId);
26
26
  // `enabledAt` (epoch ms) habilita o trusted-device check: um cookie de
27
27
  // confiança emitido ANTES deste instante é inválido (re-enrolar revoga).
28
- return { enabled: !!state?.mfaEnabledAt, enabledAt: state?.mfaEnabledAt ?? null };
28
+ // `totp` é o fator REALMENTE utilizável: segredo confirmado + códigos de
29
+ // recuperação gravados. `enabled` também liga ao registrar passkey (ver
30
+ // `webauthn.ts`) e não desliga ao remover a última — sozinho, não diz se
31
+ // sobrou algum fator para desafiar.
32
+ return {
33
+ enabled: !!state?.mfaEnabledAt,
34
+ enabledAt: state?.mfaEnabledAt ?? null,
35
+ totp: !!(state?.mfaEnabledAt && state?.totpSecret && state?.recoveryCodes),
36
+ };
29
37
  },
30
38
  async startTotpEnrollment(accountId) {
31
39
  // O email/QR vem do model principal; só o ESTADO de MFA vive em auth_mfa.
@@ -52,6 +52,7 @@ import { accountPath } from '../account_paths.js';
52
52
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
53
53
  import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
54
54
  import { sendOrgInvitationEmail } from '../default_mailer.js';
55
+ import { revokeOrgAccess } from '../org_access_revocation.js';
55
56
  // Política efetiva: o MESMO módulo que o console HTML usa. Duas cópias da
56
57
  // resolução são como o espelho JSON acabaria mais frouxo que o formulário.
57
58
  import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
@@ -213,6 +214,8 @@ export default class AccountOrgsApiController {
213
214
  }
214
215
  return ctx.response.notFound(apiErr('not_found', 'Membership not found.'));
215
216
  }
217
+ // Os grants que carregam esta org para a conta caem já — não no refresh.
218
+ await revokeOrgAccess(c.service, orgId, c.accountId);
216
219
  await c.cfg.audit?.record({
217
220
  type: 'organization.member_removed',
218
221
  accountId: c.accountId,
@@ -335,6 +338,8 @@ export default class AccountOrgsApiController {
335
338
  }
336
339
  return ctx.response.notFound(apiErr('not_found', 'Member not found in this organization.'));
337
340
  }
341
+ // Os grants que carregam esta org para a conta caem já — não no refresh.
342
+ await revokeOrgAccess(c.service, orgId, targetId);
338
343
  await c.cfg.audit?.record({
339
344
  type: 'organization.member_removed',
340
345
  actorId: c.accountId,
@@ -446,7 +451,7 @@ export default class AccountOrgsApiController {
446
451
  ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
447
452
  return null;
448
453
  }
449
- return { cfg, store, accountId };
454
+ return { cfg, store, accountId, service };
450
455
  }
451
456
  /**
452
457
  * Exige que o ator seja owner/admin NA ORG DO PATH. Responde 403 e devolve
@@ -1,4 +1,4 @@
1
- import type { ActiveOrgInfo } from '../accounts/account_store.js';
1
+ import type { AccountStore, ActiveOrgInfo } from '../accounts/account_store.js';
2
2
  /** Nome do cookie da org ativa. HttpOnly, SameSite=Lax, Secure em prod. */
3
3
  export declare const ACTIVE_ORG_COOKIE = "authkit_active_org";
4
4
  /** TTL máximo do cookie da org ativa (30 dias em segundos). */
@@ -51,3 +51,23 @@ export declare function normalizeActiveOrg(value: unknown): ActiveOrgInfo | null
51
51
  export declare function readActiveOrgFromHostCtx(ctx: unknown, opts?: {
52
52
  appKey?: string;
53
53
  }): ActiveOrgInfo | null;
54
+ /**
55
+ * Confere, NO STORE, se a org que a sessão diz ser a ativa ainda vale para a conta,
56
+ * e devolve os dados ATUAIS dela — ou `null` quando não vale mais.
57
+ *
58
+ * O `activeOrg` que chega aqui é um RETRATO: foi gravado no Grant no consent (ou lido
59
+ * do cookie) e o refresh token o reemite por até 30 dias. Sem esta conferência, quem
60
+ * é removido da equipe continua recebendo `org_*` até o grant expirar, e um rebaixado
61
+ * continua recebendo o papel antigo. Por isso:
62
+ *
63
+ * - a conta precisa ser membro da org AGORA (`getOrgMembership`);
64
+ * - a org precisa existir AGORA (`findOrgById`) — o slug sai daqui, não do retrato;
65
+ * - o papel é o da membership ATUAL, nunca o `orgRole` do retrato.
66
+ *
67
+ * Store sem `findOrgById`/`getOrgMembership`: não há como conferir, então não há
68
+ * claim (fail-closed). As rotas que gravam o cookie de org só existem com a capacidade.
69
+ *
70
+ * Erro do store PROPAGA: um banco fora do ar não pode virar, em silêncio, um token
71
+ * sem org (que o app leria como "usuário sem tenant") nem um token com a org velha.
72
+ */
73
+ export declare function verifyActiveOrgMembership(store: AccountStore, accountId: string, claimed: ActiveOrgInfo | null): Promise<ActiveOrgInfo | null>;
@@ -158,3 +158,41 @@ export function readActiveOrgFromHostCtx(ctx, opts) {
158
158
  }
159
159
  return readActiveOrgFromKoaCtx(ctx, opts);
160
160
  }
161
+ /**
162
+ * Confere, NO STORE, se a org que a sessão diz ser a ativa ainda vale para a conta,
163
+ * e devolve os dados ATUAIS dela — ou `null` quando não vale mais.
164
+ *
165
+ * O `activeOrg` que chega aqui é um RETRATO: foi gravado no Grant no consent (ou lido
166
+ * do cookie) e o refresh token o reemite por até 30 dias. Sem esta conferência, quem
167
+ * é removido da equipe continua recebendo `org_*` até o grant expirar, e um rebaixado
168
+ * continua recebendo o papel antigo. Por isso:
169
+ *
170
+ * - a conta precisa ser membro da org AGORA (`getOrgMembership`);
171
+ * - a org precisa existir AGORA (`findOrgById`) — o slug sai daqui, não do retrato;
172
+ * - o papel é o da membership ATUAL, nunca o `orgRole` do retrato.
173
+ *
174
+ * Store sem `findOrgById`/`getOrgMembership`: não há como conferir, então não há
175
+ * claim (fail-closed). As rotas que gravam o cookie de org só existem com a capacidade.
176
+ *
177
+ * Erro do store PROPAGA: um banco fora do ar não pode virar, em silêncio, um token
178
+ * sem org (que o app leria como "usuário sem tenant") nem um token com a org velha.
179
+ */
180
+ export async function verifyActiveOrgMembership(store, accountId, claimed) {
181
+ if (!claimed)
182
+ return null;
183
+ // Sonda os DOIS métodos que esta função usa — não o `createOrg` do
184
+ // `supportsOrganizations`. Um store com a capacidade PARCIAL (sem um deles)
185
+ // derrubaria todo mint com TypeError; aqui ele só deixa de emitir a claim.
186
+ const orgs = store;
187
+ if (typeof orgs.findOrgById !== 'function' || typeof orgs.getOrgMembership !== 'function') {
188
+ return null;
189
+ }
190
+ // Org primeiro: apagada, nem pergunta pela membership.
191
+ const org = await orgs.findOrgById(claimed.orgId);
192
+ if (!org)
193
+ return null;
194
+ const membership = await orgs.getOrgMembership(claimed.orgId, accountId);
195
+ if (!membership || typeof membership.role !== 'string' || !membership.role)
196
+ return null;
197
+ return { orgId: org.id, orgSlug: org.slug, orgRole: membership.role };
198
+ }
@@ -1,6 +1,7 @@
1
1
  import type { HttpContext } from '@adonisjs/core/http';
2
2
  import type { OrgInvitation, OrgMember, OrgSummary } from '../../accounts/account_store.js';
3
3
  import type { ResolvedServerConfig } from '../../define_config.js';
4
+ import type { OidcService } from '../../provider/oidc_service.js';
4
5
  import type { SettingsCapability } from '../runtime_settings.js';
5
6
  import type { AdminActor } from './admin_users_service.js';
6
7
  export interface OrgWithMemberCount extends OrgSummary {
@@ -52,7 +53,12 @@ export type InvalidRoleResult = {
52
53
  */
53
54
  export declare class AdminOrgsService {
54
55
  private cfg;
55
- constructor(cfg: ResolvedServerConfig);
56
+ private oidc?;
57
+ /**
58
+ * @param oidc Serviço OIDC — onde vivem os grants a revogar quando um membro sai
59
+ * ou a org é apagada ({@link revokeOrgAccess}). Opcional para quem só lê.
60
+ */
61
+ constructor(cfg: ResolvedServerConfig, oidc?: Pick<OidcService, 'config'> | undefined);
56
62
  get supported(): boolean;
57
63
  /**
58
64
  * Resolve o catálogo efetivo de roles de org (runtime settings → config →
@@ -2,6 +2,7 @@ import { supportsOrganizations } from '../../accounts/account_store.js';
2
2
  import { ADMIN_LIST_DEFAULT_SIZE, LIST_FIRST_PAGE } from '../../pagination.js';
3
3
  import { accountPath } from '../account_paths.js';
4
4
  import { sendOrgInvitationEmail } from '../default_mailer.js';
5
+ import { revokeOrgAccess } from '../org_access_revocation.js';
5
6
  import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy, resolveRoleCatalogList, } from '../runtime_toggles.js';
6
7
  /**
7
8
  * Lógica de gestão de organizações compartilhada entre o console admin (HTML)
@@ -10,8 +11,14 @@ import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy, resolveRoleCatalo
10
11
  */
11
12
  export class AdminOrgsService {
12
13
  cfg;
13
- constructor(cfg) {
14
+ oidc;
15
+ /**
16
+ * @param oidc Serviço OIDC — onde vivem os grants a revogar quando um membro sai
17
+ * ou a org é apagada ({@link revokeOrgAccess}). Opcional para quem só lê.
18
+ */
19
+ constructor(cfg, oidc) {
14
20
  this.cfg = cfg;
21
+ this.oidc = oidc;
15
22
  }
16
23
  get supported() {
17
24
  return supportsOrganizations(this.cfg.accountStore);
@@ -157,6 +164,8 @@ export class AdminOrgsService {
157
164
  if (!existing)
158
165
  return { ok: false, reason: 'not_found' };
159
166
  await store.deleteOrg(orgId);
167
+ // Grants que carregam esta org (de qualquer conta) caem já — não no refresh.
168
+ await revokeOrgAccess(this.oidc, orgId);
160
169
  await this.cfg.audit?.record({
161
170
  type: 'organization.deleted',
162
171
  actorId: actor.actorId,
@@ -210,6 +219,7 @@ export class AdminOrgsService {
210
219
  return { ok: false, reason: 'last_owner' };
211
220
  return { ok: false, reason: 'member_not_found' };
212
221
  }
222
+ await revokeOrgAccess(this.oidc, orgId, accountId);
213
223
  await this.cfg.audit?.record({
214
224
  type: 'organization.member_removed',
215
225
  actorId: actor.actorId,
@@ -22,8 +22,8 @@ function notSupported(ctx) {
22
22
  export default class ApiOrgsController {
23
23
  /** GET /organizations — lista todas as orgs com contagem de membros. */
24
24
  async index(ctx) {
25
- const { cfg } = await ctxBits(ctx);
26
- const svc = new AdminOrgsService(cfg);
25
+ const { cfg, service } = await ctxBits(ctx);
26
+ const svc = new AdminOrgsService(cfg, service);
27
27
  const result = await svc.listOrgs();
28
28
  if (!Array.isArray(result)) {
29
29
  if (result.reason === 'not_supported')
@@ -36,8 +36,8 @@ export default class ApiOrgsController {
36
36
  }
37
37
  /** POST /organizations — cria uma org. Body: { name, slug, ownerAccountId, logoUrl? } */
38
38
  async store(ctx) {
39
- const { cfg, actor } = await ctxBits(ctx);
40
- const svc = new AdminOrgsService(cfg);
39
+ const { cfg, actor, service } = await ctxBits(ctx);
40
+ const svc = new AdminOrgsService(cfg, service);
41
41
  const { name, slug, ownerAccountId, logoUrl } = await ctx.request.validateUsing(orgCreateValidator);
42
42
  const result = await svc.createOrg({ name, slug, logoUrl: logoUrl ?? null, ownerAccountId }, actor);
43
43
  if ('ok' in result && result.ok === false) {
@@ -50,8 +50,8 @@ export default class ApiOrgsController {
50
50
  }
51
51
  /** GET /organizations/:id — obtém uma org com membros e convites. */
52
52
  async show(ctx) {
53
- const { cfg } = await ctxBits(ctx);
54
- const svc = new AdminOrgsService(cfg);
53
+ const { cfg, service } = await ctxBits(ctx);
54
+ const svc = new AdminOrgsService(cfg, service);
55
55
  const id = ctx.request.param('id');
56
56
  const result = await svc.getOrg(id);
57
57
  if ('ok' in result && result.ok === false) {
@@ -63,8 +63,8 @@ export default class ApiOrgsController {
63
63
  }
64
64
  /** PATCH /organizations/:id — atualiza nome e/ou logo. */
65
65
  async update(ctx) {
66
- const { cfg, actor } = await ctxBits(ctx);
67
- const svc = new AdminOrgsService(cfg);
66
+ const { cfg, actor, service } = await ctxBits(ctx);
67
+ const svc = new AdminOrgsService(cfg, service);
68
68
  const id = ctx.request.param('id');
69
69
  const { name, logoUrl } = await ctx.request.validateUsing(orgUpdateValidator);
70
70
  const patch = {};
@@ -84,8 +84,8 @@ export default class ApiOrgsController {
84
84
  }
85
85
  /** DELETE /organizations/:id */
86
86
  async destroy(ctx) {
87
- const { cfg, actor } = await ctxBits(ctx);
88
- const svc = new AdminOrgsService(cfg);
87
+ const { cfg, actor, service } = await ctxBits(ctx);
88
+ const svc = new AdminOrgsService(cfg, service);
89
89
  const id = ctx.request.param('id');
90
90
  const result = await svc.deleteOrg(id, actor);
91
91
  if (!result.ok) {
@@ -97,8 +97,8 @@ export default class ApiOrgsController {
97
97
  }
98
98
  /** POST /organizations/:id/members — adiciona membro. Body: { accountId, role } */
99
99
  async addMember(ctx) {
100
- const { cfg, actor } = await ctxBits(ctx);
101
- const svc = new AdminOrgsService(cfg);
100
+ const { cfg, actor, service } = await ctxBits(ctx);
101
+ const svc = new AdminOrgsService(cfg, service);
102
102
  const orgId = ctx.request.param('id');
103
103
  const { accountId, role } = await ctx.request.validateUsing(orgAddMemberValidator);
104
104
  const result = await svc.addMember(orgId, { accountId, role: role ?? 'member' }, actor, await resolveRuntimeSettings(ctx));
@@ -116,8 +116,8 @@ export default class ApiOrgsController {
116
116
  }
117
117
  /** DELETE /organizations/:id/members/:accountId */
118
118
  async removeMember(ctx) {
119
- const { cfg, actor } = await ctxBits(ctx);
120
- const svc = new AdminOrgsService(cfg);
119
+ const { cfg, actor, service } = await ctxBits(ctx);
120
+ const svc = new AdminOrgsService(cfg, service);
121
121
  const orgId = ctx.request.param('id');
122
122
  const accountId = ctx.request.param('accountId');
123
123
  const result = await svc.removeMember(orgId, accountId, actor);
@@ -134,8 +134,8 @@ export default class ApiOrgsController {
134
134
  }
135
135
  /** PATCH /organizations/:id/members/:accountId — troca o papel. Body: { role } */
136
136
  async updateMemberRole(ctx) {
137
- const { cfg, actor } = await ctxBits(ctx);
138
- const svc = new AdminOrgsService(cfg);
137
+ const { cfg, actor, service } = await ctxBits(ctx);
138
+ const svc = new AdminOrgsService(cfg, service);
139
139
  const orgId = ctx.request.param('id');
140
140
  const accountId = ctx.request.param('accountId');
141
141
  const { role } = await ctx.request.validateUsing(orgMemberRoleValidator);
@@ -155,8 +155,8 @@ export default class ApiOrgsController {
155
155
  }
156
156
  /** POST /organizations/:id/invitations — cria convite. Body: { email, role } */
157
157
  async createInvitation(ctx) {
158
- const { cfg, actor } = await ctxBits(ctx);
159
- const svc = new AdminOrgsService(cfg);
158
+ const { cfg, actor, service } = await ctxBits(ctx);
159
+ const svc = new AdminOrgsService(cfg, service);
160
160
  const orgId = ctx.request.param('id');
161
161
  const { email, role } = await ctx.request.validateUsing(orgInvitationValidator);
162
162
  const origin = authkitOrigin(cfg);
@@ -173,8 +173,8 @@ export default class ApiOrgsController {
173
173
  }
174
174
  /** DELETE /organizations/:id/invitations/:invitationId */
175
175
  async revokeInvitation(ctx) {
176
- const { cfg, actor } = await ctxBits(ctx);
177
- const svc = new AdminOrgsService(cfg);
176
+ const { cfg, actor, service } = await ctxBits(ctx);
177
+ const svc = new AdminOrgsService(cfg, service);
178
178
  const orgId = ctx.request.param('id');
179
179
  const invitationId = ctx.request.param('invitationId');
180
180
  const result = await svc.revokeInvitation(orgId, invitationId, actor);
@@ -23,7 +23,7 @@ import { resolveRuntimeSettings } from '../runtime_settings.js';
23
23
  export default class ConsoleOrgsController {
24
24
  async svc(ctx) {
25
25
  const service = await ctx.containerResolver.make('authkit.server');
26
- return new AdminOrgsService(service.config);
26
+ return new AdminOrgsService(service.config, service);
27
27
  }
28
28
  actor(ctx) {
29
29
  return {
@@ -118,4 +118,25 @@ export declare class AdminSessionsService {
118
118
  * contagens do que foi removido.
119
119
  */
120
120
  revokeClientGrants(accountId: string, clientId: string): Promise<RevokeResult>;
121
+ /**
122
+ * Revoga os grants que carregam a org `orgId` como org ativa (`Grant.activeOrg`,
123
+ * gravado no consent) — de UMA conta (`accountId`) ou de TODAS (omitido, usado
124
+ * quando a org é apagada). Destrói os AT/RT desses grants e os próprios grants,
125
+ * como {@link revokeClientGrants}; depois grava a revogação por `sub` de cada conta
126
+ * afetada, para que clients COOKIE-BASED derrubem a sessão já na próxima request.
127
+ *
128
+ * Por que existe: o `org_*` do token sai do grant. Quem é removido da org (ou
129
+ * perde a org inteira) seguiria com o access token e o refresh token daquele grant
130
+ * até o fim do TTL. A emissão já confere a membership no store a cada mint (ver
131
+ * `verifyActiveOrgMembership`); esta revogação faz a mudança valer AGORA — o
132
+ * access token opaco para de introspectar, o refresh falha e o client volta ao
133
+ * authorize, onde o novo grant sai sem a org.
134
+ *
135
+ * Grants de OUTRAS orgs (e sem org) ficam intactos. Sessões do IdP não carregam
136
+ * org (a org vive no grant e no cookie), então não são tocadas.
137
+ *
138
+ * Sem enumeração no adapter não há como achar os grants: devolve zeros e fica
139
+ * valendo só a conferência da emissão (no próximo refresh).
140
+ */
141
+ revokeOrgGrants(orgId: string, accountId?: string): Promise<RevokeResult>;
121
142
  }
@@ -1,5 +1,6 @@
1
1
  import { getBootedApp } from '../../services/booted_app.js';
2
2
  import { pickModelAdapterClass } from '../adapters/factory.js';
3
+ import { normalizeActiveOrg } from './active_org_cookie.js';
3
4
  /**
4
5
  * Serviço de inspeção/revogação das SESSÕES e GRANTS ativos de uma conta.
5
6
  * Lê/escreve via `pickModelAdapterClass` (a MESMA regra do provider): com
@@ -302,6 +303,54 @@ export class AdminSessionsService {
302
303
  }
303
304
  return { sessions: 0, grants: grants.length, accessTokens, refreshTokens };
304
305
  }
306
+ /**
307
+ * Revoga os grants que carregam a org `orgId` como org ativa (`Grant.activeOrg`,
308
+ * gravado no consent) — de UMA conta (`accountId`) ou de TODAS (omitido, usado
309
+ * quando a org é apagada). Destrói os AT/RT desses grants e os próprios grants,
310
+ * como {@link revokeClientGrants}; depois grava a revogação por `sub` de cada conta
311
+ * afetada, para que clients COOKIE-BASED derrubem a sessão já na próxima request.
312
+ *
313
+ * Por que existe: o `org_*` do token sai do grant. Quem é removido da org (ou
314
+ * perde a org inteira) seguiria com o access token e o refresh token daquele grant
315
+ * até o fim do TTL. A emissão já confere a membership no store a cada mint (ver
316
+ * `verifyActiveOrgMembership`); esta revogação faz a mudança valer AGORA — o
317
+ * access token opaco para de introspectar, o refresh falha e o client volta ao
318
+ * authorize, onde o novo grant sai sem a org.
319
+ *
320
+ * Grants de OUTRAS orgs (e sem org) ficam intactos. Sessões do IdP não carregam
321
+ * org (a org vive no grant e no cookie), então não são tocadas.
322
+ *
323
+ * Sem enumeração no adapter não há como achar os grants: devolve zeros e fica
324
+ * valendo só a conferência da emissão (no próximo refresh).
325
+ */
326
+ async revokeOrgGrants(orgId, accountId) {
327
+ const grantAdapter = this.#adapter('Grant');
328
+ const rows = await this.#listModel('Grant');
329
+ const grants = rows.filter((r) => {
330
+ if (normalizeActiveOrg(r.payload.activeOrg)?.orgId !== orgId)
331
+ return false;
332
+ return accountId === undefined || r.payload.accountId === accountId;
333
+ });
334
+ if (grants.length === 0) {
335
+ return { sessions: 0, grants: 0, accessTokens: 0, refreshTokens: 0 };
336
+ }
337
+ const grantIds = new Set(grants.map((g) => g.id));
338
+ const accessTokens = await this.#destroyTokensOfGrants('AccessToken', grantIds);
339
+ const refreshTokens = await this.#destroyTokensOfGrants('RefreshToken', grantIds);
340
+ for (const g of grants) {
341
+ await grantAdapter.revokeByGrantId(g.id);
342
+ await grantAdapter.destroy(g.id);
343
+ }
344
+ // Clients cookie-based não têm refresh para falhar: sem a linha por `sub`, o
345
+ // id_token com a org velha seguiria valendo na sessão do client até expirar.
346
+ const accounts = new Set(grants
347
+ .map((g) => g.payload.accountId)
348
+ .filter((id) => typeof id === 'string' && id.length > 0));
349
+ for (const id of accounts) {
350
+ await this.recordSubRevocation(id);
351
+ }
352
+ return { sessions: 0, grants: grants.length, accessTokens, refreshTokens };
353
+ }
305
354
  /** Destrói (quando enumerável) os artefatos de um model token cujos grantId estão em `grantIds`. */
306
355
  async #destroyTokensOfGrants(model, grantIds) {
307
356
  const adapter = this.#adapter(model);
@@ -6,6 +6,7 @@ import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
6
6
  import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
7
7
  import { sendOrgInvitationEmail } from '../default_mailer.js';
8
8
  import { ensureConsoleSession } from '../idp_session_bridge.js';
9
+ import { revokeOrgAccess } from '../org_access_revocation.js';
9
10
  // Política efetiva compartilhada com o espelho JSON (`account_orgs_api_controller`):
10
11
  // ponto de verdade único, para as duas superfícies nunca divergirem.
11
12
  import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
@@ -154,6 +155,7 @@ export default class AccountOrgsController {
154
155
  return response.forbidden();
155
156
  const result = await store.removeOrgMember(params.id, accountId);
156
157
  if (result.ok) {
158
+ await revokeOrgAccess(service, params.id, accountId);
157
159
  await cfg.audit?.record({
158
160
  type: 'organization.member_removed',
159
161
  accountId,
@@ -314,6 +316,7 @@ export default class AccountOrgsController {
314
316
  }
315
317
  const result = await store.removeOrgMember(params.id, params.accountId);
316
318
  if (result.ok) {
319
+ await revokeOrgAccess(service, params.id, params.accountId);
317
320
  await cfg.audit?.record({
318
321
  type: 'organization.member_removed',
319
322
  actorId,
@@ -41,6 +41,51 @@ export default class AuthInteractionController {
41
41
  * Retorna `undefined` quando não há step-up — completeLogin usa o default.
42
42
  */
43
43
  private stepUpExtra;
44
+ /**
45
+ * acr/amr de um login que PASSOU pelo desafio do 2º fator: `amr` =
46
+ * `[primário, 'mfa', método]` (RFC 8176), SEMPRE — não só no step-up. Antes o
47
+ * `completeLogin` só recebia amr no step-up; fora dele o id_token saía sem `amr`
48
+ * nenhum, e o fator primário (senha ou e-mail) se perdia. O step-up continua
49
+ * carimbando o `acr`.
50
+ *
51
+ * `primary` ausente (sessão gravada antes desta versão, ou valor adulterado):
52
+ * o amr sai sem o primário, em vez de inventar um.
53
+ */
54
+ private mfaCompletion;
55
+ /**
56
+ * Lê E esquece o desafio pendente do 2º fator (accountId + fator primário). Os
57
+ * dois saem juntos: um primário sobrando na sessão não pode colar no próximo login.
58
+ */
59
+ private takeMfaPending;
60
+ /**
61
+ * Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
62
+ * mágico, código por e-mail e troca forçada de senha. Antes disto só o login
63
+ * por senha passava pelo MFA — link e código completavam a interaction direto,
64
+ * então uma conta com TOTP ou passkey entrava apresentando só o e-mail e o
65
+ * segundo fator virava enfeite.
66
+ *
67
+ * A pergunta que o gate responde é "existe um fator que esta pessoa consegue
68
+ * apresentar AGORA?", e não `mfa.enabled` — que liga ao registrar uma passkey
69
+ * e não desliga ao remover a última. Fator utilizável = TOTP confirmado
70
+ * (`mfa.totp`) OU ao menos uma passkey registrada. Pela regra antiga, quem
71
+ * registrava uma passkey e depois a removia ficava preso num desafio TOTP que
72
+ * não tinha como responder.
73
+ *
74
+ * - sem fator e sem step-up → `none`, o login segue;
75
+ * - step-up (acr) exigindo MFA numa conta sem fator → `challenge` com a
76
+ * instrução de enrolar (não há o que desafiar);
77
+ * - fator presente, trusted-device válido e sem step-up → `trusted`; quem
78
+ * chamou decide como finalizar, porque o amr muda conforme o caminho;
79
+ * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
80
+ * primário (`primary`) em `MFA_PRIMARY_KEY`.
81
+ */
82
+ private secondFactorGate;
83
+ /**
84
+ * true se a conta tem um app autenticador CONFIRMADO. Stores que não reportam
85
+ * `totp` (a chave é opcional em {@link MfaCapability}) caem no `enabled`, o
86
+ * comportamento antigo.
87
+ */
88
+ private hasTotp;
44
89
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
45
90
  private hasPasskeys;
46
91
  /**
@@ -45,6 +45,12 @@ function accountStatusErrorKey(reason) {
45
45
  const SESSION_KEY = 'authkit_login_email';
46
46
  /** accountId aguardando o 2º fator depois da senha verificada. */
47
47
  const MFA_PENDING_KEY = 'authkit_mfa_pending';
48
+ /**
49
+ * Fator PRIMÁRIO que levou ao desafio do 2º fator (`pwd` ou `email`), guardado JUNTO
50
+ * do {@link MFA_PENDING_KEY}. Sem ele, quem completa o 2º fator não sabe como o login
51
+ * começou e o `amr` do id_token perdia o primeiro fator (ver `mfaCompletion`).
52
+ */
53
+ const MFA_PRIMARY_KEY = 'authkit_mfa_primary';
48
54
  /** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
49
55
  const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
50
56
  /**
@@ -405,55 +411,24 @@ export default class AuthInteractionController {
405
411
  }
406
412
  // Admin: prossegue normalmente (sem bloqueio).
407
413
  }
408
- // Step-up auth (acr_values): o client pode EXIGIR MFA nesta requisição
409
- // solicitando o `mfaAcr` em acr_values, mesmo que a conta tenha MFA opcional.
410
- const mfaRequired = this.acrRequiresMfa(cfg, details);
411
- // MFA gate: força o 2º fator se a conta tem TOTP ativo OU se o client exige MFA
412
- // via acr. Não finaliza a interaction agora — guarda o accountId pendente.
413
- const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
414
- if (mfa.enabled || mfaRequired) {
415
- if (mfaRequired && !mfa.enabled) {
416
- // Client exige MFA mas a conta não tem MFA enrolado: bloqueia este login
417
- // com a instrução de configurar MFA no console (não há 2º fator a desafiar).
418
- return render(ctx, 'mfa-challenge', {
419
- uid: ctx.request.param('uid'),
420
- csrfToken: ctx.request.csrfToken,
421
- brand,
422
- passkeyAvailable: false,
423
- error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
424
- noEnrollment: true,
425
- });
426
- }
427
- // Trusted device: se o mecanismo está ligado, a conta JÁ tem MFA enrolado e
428
- // o request NÃO é um step-up (que sempre força o MFA), um cookie de confiança
429
- // válido para ESTA conta pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
430
- if (cfg.trustedDevices.enabled && mfa.enabled && !mfaRequired) {
431
- const trusted = await this.checkTrustedDevice(ctx, acc.id, mfa.enabledAt ?? null);
432
- if (trusted) {
433
- await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
434
- await notifyLoginSuccess(ctx, cfg, {
435
- accountId: acc.id,
436
- email,
437
- ip,
438
- clientId,
439
- trustedDevice: true,
440
- });
441
- forgetLoginEmail(ctx);
442
- return;
443
- }
444
- }
445
- ctx.session.put(MFA_PENDING_KEY, acc.id);
446
- // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
447
- // tem ao menos uma credencial registrada.
448
- const passkeyAvailable = await this.hasPasskeys(cfg, acc.id);
449
- return render(ctx, 'mfa-challenge', {
450
- uid: ctx.request.param('uid'),
451
- csrfToken: ctx.request.csrfToken,
452
- brand,
453
- passkeyAvailable,
454
- trustedDevicesEnabled: cfg.trustedDevices.enabled,
455
- trustedDeviceDays: cfg.trustedDevices.days,
414
+ // Gate do 2º fator — a MESMA regra dos três caminhos de login (ver
415
+ // `secondFactorGate`). Quando desafia, não finaliza a interaction: guarda o
416
+ // accountId pendente e devolve a tela.
417
+ const gate = await this.secondFactorGate(ctx, cfg, acc.id, details, 'pwd');
418
+ if (gate.kind === 'challenge')
419
+ return gate.response;
420
+ if (gate.kind === 'trusted') {
421
+ // Dispositivo confiável: pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
422
+ await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
423
+ await notifyLoginSuccess(ctx, cfg, {
424
+ accountId: acc.id,
425
+ email,
426
+ ip,
427
+ clientId,
428
+ trustedDevice: true,
456
429
  });
430
+ forgetLoginEmail(ctx);
431
+ return;
457
432
  }
458
433
  // Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
459
434
  // Resolve session_policy para remember-me e single-session.
@@ -531,6 +506,7 @@ export default class AuthInteractionController {
531
506
  error: translate(cfg.messages, 'errors.otp_locked'),
532
507
  brand,
533
508
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
509
+ totpAvailable: await this.hasTotp(cfg, accountId),
534
510
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
535
511
  trustedDeviceDays: cfg.trustedDevices.days,
536
512
  otpLocked: true,
@@ -564,6 +540,7 @@ export default class AuthInteractionController {
564
540
  error: translate(cfg.messages, 'errors.otp_locked'),
565
541
  brand,
566
542
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
543
+ totpAvailable: await this.hasTotp(cfg, accountId),
567
544
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
568
545
  trustedDeviceDays: cfg.trustedDevices.days,
569
546
  otpLocked: true,
@@ -575,6 +552,7 @@ export default class AuthInteractionController {
575
552
  error: translate(cfg.messages, 'errors.invalid_code'),
576
553
  brand,
577
554
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
555
+ totpAvailable: await this.hasTotp(cfg, accountId),
578
556
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
579
557
  trustedDeviceDays: cfg.trustedDevices.days,
580
558
  });
@@ -584,7 +562,7 @@ export default class AuthInteractionController {
584
562
  // Sucesso no 2º fator: opcionalmente confia neste dispositivo (checkbox).
585
563
  await this.maybeTrustDevice(ctx, cfg, accountId);
586
564
  // Finaliza a interaction para o accountId pendente.
587
- ctx.session.forget(MFA_PENDING_KEY);
565
+ const { primary } = this.takeMfaPending(ctx);
588
566
  forgetLoginEmail(ctx);
589
567
  await notifyLoginSuccess(ctx, cfg, {
590
568
  accountId,
@@ -592,9 +570,9 @@ export default class AuthInteractionController {
592
570
  clientId,
593
571
  metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
594
572
  });
595
- // Step-up: um 2º fator foi de fato verificado — carimba acr/amr no id_token
596
- // se o client solicitou o mfaAcr nesta requisição.
597
- await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, usedRecovery ? 'recovery' : 'totp'));
573
+ // Um 2º fator foi de fato verificado: amr = [primário, 'mfa', método]. Com
574
+ // step-up (mfaAcr solicitado nesta requisição), carimba também o acr.
575
+ await service.interactions.completeLogin(ctx, accountId, this.mfaCompletion(cfg, details, primary, usedRecovery ? 'recovery' : 'totp'));
598
576
  }
599
577
  /**
600
578
  * true se o authorize request exige MFA via acr_values (contém o mfaAcr da
@@ -619,6 +597,113 @@ export default class AuthInteractionController {
619
597
  return undefined;
620
598
  return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
621
599
  }
600
+ /**
601
+ * acr/amr de um login que PASSOU pelo desafio do 2º fator: `amr` =
602
+ * `[primário, 'mfa', método]` (RFC 8176), SEMPRE — não só no step-up. Antes o
603
+ * `completeLogin` só recebia amr no step-up; fora dele o id_token saía sem `amr`
604
+ * nenhum, e o fator primário (senha ou e-mail) se perdia. O step-up continua
605
+ * carimbando o `acr`.
606
+ *
607
+ * `primary` ausente (sessão gravada antes desta versão, ou valor adulterado):
608
+ * o amr sai sem o primário, em vez de inventar um.
609
+ */
610
+ mfaCompletion(cfg, details, primary, method) {
611
+ const amr = [...(primary ? [primary] : []), 'mfa', method];
612
+ const stepUp = this.stepUpExtra(cfg, details, method);
613
+ return stepUp ? { acr: stepUp.acr, amr } : { amr };
614
+ }
615
+ /**
616
+ * Lê E esquece o desafio pendente do 2º fator (accountId + fator primário). Os
617
+ * dois saem juntos: um primário sobrando na sessão não pode colar no próximo login.
618
+ */
619
+ takeMfaPending(ctx) {
620
+ const raw = ctx.session.get(MFA_PRIMARY_KEY);
621
+ ctx.session.forget(MFA_PENDING_KEY);
622
+ ctx.session.forget(MFA_PRIMARY_KEY);
623
+ return { primary: raw === 'pwd' || raw === 'email' ? raw : undefined };
624
+ }
625
+ /**
626
+ * Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
627
+ * mágico, código por e-mail e troca forçada de senha. Antes disto só o login
628
+ * por senha passava pelo MFA — link e código completavam a interaction direto,
629
+ * então uma conta com TOTP ou passkey entrava apresentando só o e-mail e o
630
+ * segundo fator virava enfeite.
631
+ *
632
+ * A pergunta que o gate responde é "existe um fator que esta pessoa consegue
633
+ * apresentar AGORA?", e não `mfa.enabled` — que liga ao registrar uma passkey
634
+ * e não desliga ao remover a última. Fator utilizável = TOTP confirmado
635
+ * (`mfa.totp`) OU ao menos uma passkey registrada. Pela regra antiga, quem
636
+ * registrava uma passkey e depois a removia ficava preso num desafio TOTP que
637
+ * não tinha como responder.
638
+ *
639
+ * - sem fator e sem step-up → `none`, o login segue;
640
+ * - step-up (acr) exigindo MFA numa conta sem fator → `challenge` com a
641
+ * instrução de enrolar (não há o que desafiar);
642
+ * - fator presente, trusted-device válido e sem step-up → `trusted`; quem
643
+ * chamou decide como finalizar, porque o amr muda conforme o caminho;
644
+ * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
645
+ * primário (`primary`) em `MFA_PRIMARY_KEY`.
646
+ */
647
+ async secondFactorGate(ctx, cfg, accountId, details, primary) {
648
+ const mfaRequired = this.acrRequiresMfa(cfg, details);
649
+ const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
650
+ // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
651
+ // tem ao menos uma credencial registrada.
652
+ const passkeyAvailable = await this.hasPasskeys(cfg, accountId);
653
+ // Store que não reporta `totp` (a chave é opcional) cai no `enabled` — o
654
+ // comportamento antigo. Nunca o contrário: assumir "sem TOTP" por omissão
655
+ // deixaria de desafiar quem tem o fator.
656
+ const totpAvailable = mfa.totp ?? !!mfa.enabled;
657
+ const hasFactor = passkeyAvailable || totpAvailable;
658
+ if (!hasFactor && !mfaRequired)
659
+ return { kind: 'none' };
660
+ const render = cfg.render;
661
+ const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
662
+ const uid = ctx.request.param('uid');
663
+ if (!hasFactor) {
664
+ return {
665
+ kind: 'challenge',
666
+ response: await render(ctx, 'mfa-challenge', {
667
+ uid,
668
+ csrfToken: ctx.request.csrfToken,
669
+ brand,
670
+ passkeyAvailable: false,
671
+ error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
672
+ noEnrollment: true,
673
+ }),
674
+ };
675
+ }
676
+ if (cfg.trustedDevices.enabled && !mfaRequired) {
677
+ const trusted = await this.checkTrustedDevice(ctx, accountId, mfa.enabledAt ?? null);
678
+ if (trusted)
679
+ return { kind: 'trusted' };
680
+ }
681
+ ctx.session.put(MFA_PENDING_KEY, accountId);
682
+ // O fator primário viaja junto: quem completa o desafio (TOTP, recovery ou
683
+ // passkey) precisa dele para montar o `amr` inteiro.
684
+ ctx.session.put(MFA_PRIMARY_KEY, primary);
685
+ return {
686
+ kind: 'challenge',
687
+ response: await render(ctx, 'mfa-challenge', {
688
+ uid,
689
+ csrfToken: ctx.request.csrfToken,
690
+ brand,
691
+ passkeyAvailable,
692
+ totpAvailable,
693
+ trustedDevicesEnabled: cfg.trustedDevices.enabled,
694
+ trustedDeviceDays: cfg.trustedDevices.days,
695
+ }),
696
+ };
697
+ }
698
+ /**
699
+ * true se a conta tem um app autenticador CONFIRMADO. Stores que não reportam
700
+ * `totp` (a chave é opcional em {@link MfaCapability}) caem no `enabled`, o
701
+ * comportamento antigo.
702
+ */
703
+ async hasTotp(cfg, accountId) {
704
+ const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
705
+ return mfa.totp ?? !!mfa.enabled;
706
+ }
622
707
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
623
708
  async hasPasskeys(cfg, accountId) {
624
709
  if (!supportsPasskeys(cfg.accountStore))
@@ -920,12 +1005,21 @@ export default class AuthInteractionController {
920
1005
  error: translate(cfg.messages, accountStatusErrorKey(magicLinkStatusGate.reason)),
921
1006
  });
922
1007
  }
1008
+ // Conta com segundo fator não termina o login só com o e-mail.
1009
+ const magicGate = await this.secondFactorGate(ctx, cfg, acc.id, await service.interactions.details(ctx), 'email');
1010
+ if (magicGate.kind === 'challenge')
1011
+ return magicGate.response;
923
1012
  await notifyLoginSuccess(ctx, cfg, {
924
1013
  accountId: acc.id,
925
1014
  email: acc.email,
926
1015
  ip,
927
1016
  clientId: clientId ?? null,
928
1017
  metadata: { method: 'magic_link' },
1018
+ // `trusted` = o gate já validou o cookie de confiança nesta request; dizer
1019
+ // isso aqui poupa `notifyLoginSuccess` de reler o mesmo cookie para decidir
1020
+ // se é dispositivo novo. O amr continua `['email']`: o fator primário foi o
1021
+ // e-mail, a confiança só dispensou o segundo.
1022
+ trustedDevice: magicGate.kind === 'trusted',
929
1023
  });
930
1024
  forgetLoginEmail(ctx);
931
1025
  await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
@@ -1033,12 +1127,17 @@ export default class AuthInteractionController {
1033
1127
  ip,
1034
1128
  clientId,
1035
1129
  });
1130
+ // Conta com segundo fator não termina o login só com o código do e-mail.
1131
+ const otpGate = await this.secondFactorGate(ctx, cfg, result.account.id, await service.interactions.details(ctx), 'email');
1132
+ if (otpGate.kind === 'challenge')
1133
+ return otpGate.response;
1036
1134
  await notifyLoginSuccess(ctx, cfg, {
1037
1135
  accountId: result.account.id,
1038
1136
  email: result.account.email,
1039
1137
  ip,
1040
1138
  clientId: clientId ?? null,
1041
1139
  metadata: { method: 'otp' },
1140
+ trustedDevice: otpGate.kind === 'trusted',
1042
1141
  });
1043
1142
  forgetLoginEmail(ctx);
1044
1143
  return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
@@ -1225,6 +1324,7 @@ export default class AuthInteractionController {
1225
1324
  error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
1226
1325
  brand,
1227
1326
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1327
+ totpAvailable: await this.hasTotp(cfg, accountId),
1228
1328
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
1229
1329
  trustedDeviceDays: cfg.trustedDevices.days,
1230
1330
  });
@@ -1247,6 +1347,7 @@ export default class AuthInteractionController {
1247
1347
  error: translate(cfg.messages, 'errors.email_unverified'),
1248
1348
  brand,
1249
1349
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1350
+ totpAvailable: await this.hasTotp(cfg, accountId),
1250
1351
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
1251
1352
  trustedDeviceDays: cfg.trustedDevices.days,
1252
1353
  });
@@ -1277,13 +1378,17 @@ export default class AuthInteractionController {
1277
1378
  error: translate(cfg.messages, accountStatusErrorKey(passkeyStatusGate.reason)),
1278
1379
  brand,
1279
1380
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1381
+ totpAvailable: await this.hasTotp(cfg, accountId),
1280
1382
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
1281
1383
  trustedDeviceDays: cfg.trustedDevices.days,
1282
1384
  });
1283
1385
  }
1284
1386
  // Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
1285
1387
  await this.maybeTrustDevice(ctx, cfg, accountId);
1286
- ctx.session.forget(MFA_PENDING_KEY);
1388
+ // Passkey como 2º fator (havia desafio pendente) ou passkey-first (não havia)?
1389
+ // Lido ANTES de esquecer: é o que decide o amr abaixo.
1390
+ const wasSecondFactor = ctx.session.get(MFA_PENDING_KEY) === accountId;
1391
+ const { primary } = this.takeMfaPending(ctx);
1287
1392
  forgetLoginEmail(ctx);
1288
1393
  await notifyLoginSuccess(ctx, cfg, {
1289
1394
  accountId,
@@ -1291,9 +1396,12 @@ export default class AuthInteractionController {
1291
1396
  clientId,
1292
1397
  metadata: { mfa: 'webauthn' },
1293
1398
  });
1294
- // Step-up carimba acr/amr quando solicitado; senão a passkey conta como o fator
1295
- // forte do login (amr `['webauthn']`) — vale tanto p/ MFA quanto passkey-first.
1296
- await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] });
1399
+ // 2º fator: amr = [primário, 'mfa', 'webauthn'] (+ acr no step-up), igual ao
1400
+ // TOTP. Passkey-first: a passkey É o login (amr `['webauthn']`); o step-up
1401
+ // carimba acr/amr quando solicitado.
1402
+ await service.interactions.completeLogin(ctx, accountId, wasSecondFactor
1403
+ ? this.mfaCompletion(cfg, details, primary, 'webauthn')
1404
+ : (this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] }));
1297
1405
  }
1298
1406
  /**
1299
1407
  * GET /auth/interaction/:uid/switch
@@ -1373,20 +1481,10 @@ export default class AuthInteractionController {
1373
1481
  });
1374
1482
  // Senha trocada: limpa o step e finaliza o login.
1375
1483
  ctx.session.forget(PASSWORD_EXPIRED_KEY);
1376
- // Verifica se precisa de MFA mesmo após a troca.
1377
- const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
1378
- if (mfa.enabled) {
1379
- ctx.session.put(MFA_PENDING_KEY, accountId);
1380
- const passkeyAvailable = await this.hasPasskeys(cfg, accountId);
1381
- return render(ctx, 'mfa-challenge', {
1382
- uid: ctx.request.param('uid'),
1383
- csrfToken: ctx.request.csrfToken,
1384
- brand,
1385
- passkeyAvailable,
1386
- trustedDevicesEnabled: cfg.trustedDevices.enabled,
1387
- trustedDeviceDays: cfg.trustedDevices.days,
1388
- });
1389
- }
1484
+ // Verifica se precisa de MFA mesmo após a troca — mesma regra do resto.
1485
+ const expiredGate = await this.secondFactorGate(ctx, cfg, accountId, details, 'pwd');
1486
+ if (expiredGate.kind === 'challenge')
1487
+ return expiredGate.response;
1390
1488
  await service.interactions.completeLogin(ctx, accountId);
1391
1489
  const account = await cfg.accountStore.findById(accountId);
1392
1490
  await notifyLoginSuccess(ctx, cfg, {
@@ -0,0 +1,20 @@
1
+ import type { OidcService } from '../provider/oidc_service.js';
2
+ import { type RevokeResult } from './admin_sessions_service.js';
3
+ /**
4
+ * Derruba os grants (e os tokens deles) que carregam a org `orgId` — de uma conta
5
+ * (membro removido / saiu da org) ou de todas (org apagada, `accountId` omitido).
6
+ *
7
+ * Chamado DEPOIS que o store confirmou a remoção. Os quatro caminhos que removem
8
+ * membro (form `/account/orgs`, `/account/api/orgs`, console admin, Admin API) e o
9
+ * `deleteOrg` passam por aqui, para que a regra seja uma só.
10
+ *
11
+ * Best-effort, e nunca silencioso: a membership JÁ saiu do store, então falhar a
12
+ * request aqui só mentiria para quem removeu ("deu erro", mas o membro saiu). A
13
+ * conferência da emissão (`verifyActiveOrgMembership`) continua sendo a rede de
14
+ * segurança — no pior caso, o claim cai no próximo refresh. A falha é logada em
15
+ * `error`, porque uma revogação que não aconteceu precisa aparecer.
16
+ *
17
+ * `service` sem `config.AdapterClass` (um ctx de teste que só carrega a config)
18
+ * não tem adapter OIDC onde revogar: devolve `null` sem tentar.
19
+ */
20
+ export declare function revokeOrgAccess(service: Pick<OidcService, 'config'> | undefined, orgId: string, accountId?: string): Promise<RevokeResult | null>;
@@ -0,0 +1,41 @@
1
+ import { getBootedApp } from '../../services/booted_app.js';
2
+ import { AdminSessionsService } from './admin_sessions_service.js';
3
+ /**
4
+ * Derruba os grants (e os tokens deles) que carregam a org `orgId` — de uma conta
5
+ * (membro removido / saiu da org) ou de todas (org apagada, `accountId` omitido).
6
+ *
7
+ * Chamado DEPOIS que o store confirmou a remoção. Os quatro caminhos que removem
8
+ * membro (form `/account/orgs`, `/account/api/orgs`, console admin, Admin API) e o
9
+ * `deleteOrg` passam por aqui, para que a regra seja uma só.
10
+ *
11
+ * Best-effort, e nunca silencioso: a membership JÁ saiu do store, então falhar a
12
+ * request aqui só mentiria para quem removeu ("deu erro", mas o membro saiu). A
13
+ * conferência da emissão (`verifyActiveOrgMembership`) continua sendo a rede de
14
+ * segurança — no pior caso, o claim cai no próximo refresh. A falha é logada em
15
+ * `error`, porque uma revogação que não aconteceu precisa aparecer.
16
+ *
17
+ * `service` sem `config.AdapterClass` (um ctx de teste que só carrega a config)
18
+ * não tem adapter OIDC onde revogar: devolve `null` sem tentar.
19
+ */
20
+ export async function revokeOrgAccess(service, orgId, accountId) {
21
+ if (!service?.config?.AdapterClass)
22
+ return null;
23
+ try {
24
+ return await new AdminSessionsService(service).revokeOrgGrants(orgId, accountId);
25
+ }
26
+ catch (error) {
27
+ await logFailure(error, orgId, accountId);
28
+ return null;
29
+ }
30
+ }
31
+ async function logFailure(error, orgId, accountId) {
32
+ const msg = 'authkit: falha ao revogar os grants da org — o claim de org cai só no próximo refresh';
33
+ try {
34
+ const logger = await getBootedApp().container.make('logger');
35
+ logger.error({ err: error, orgId, accountId }, msg);
36
+ }
37
+ catch {
38
+ // eslint-disable-next-line no-console
39
+ console.error(msg, { orgId, accountId, error });
40
+ }
41
+ }
@@ -2,7 +2,7 @@ import { timingSafeEqual } from 'node:crypto';
2
2
  import { NoopRecorder } from '@adonis-agora/authkit-core';
3
3
  import Koa from 'koa';
4
4
  import mount from 'koa-mount';
5
- import { normalizeActiveOrg, readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
5
+ import { normalizeActiveOrg, readActiveOrgFromKoaCtx, verifyActiveOrgMembership, } from '../host/active_org_cookie.js';
6
6
  import { isFirstPartyClient } from '../host/branding.js';
7
7
  import { listKeyInfos, signingKeyAgeDays } from '../keys/keystore.js';
8
8
  import { wireProviderEvents } from '../observability/wire_provider_events.js';
@@ -87,8 +87,15 @@ export class OidcService {
87
87
  // — server-a-servidor, SEM cookies — então o cookie abaixo não existe
88
88
  // ali. O cookie continua como FALLBACK para fluxos em que o id_token
89
89
  // sai no próprio authorize (implicit/hybrid), onde a request é do browser.
90
- const activeOrg = normalizeActiveOrg(ctx?.oidc?.entities?.Grant?.activeOrg) ??
90
+ const claimedOrg = normalizeActiveOrg(ctx?.oidc?.entities?.Grant?.activeOrg) ??
91
91
  readActiveOrgFromKoaCtx(ctx);
92
+ // O `claimedOrg` é um RETRATO do consent (ou do cookie): o refresh o
93
+ // reemite por até 30 dias. Antes de virar claim, ele é conferido no store
94
+ // — membership e org ATUAIS, papel ATUAL (ver `verifyActiveOrgMembership`).
95
+ // Memoizado por chamada de `findAccount`: `claims()` pode rodar mais de uma
96
+ // vez no mesmo mint (id_token + userinfo) e a pergunta é a mesma.
97
+ let verifiedOrg;
98
+ const currentOrg = () => (verifiedOrg ??= verifyActiveOrgMembership(config.accountStore, user.id, claimedOrg));
92
99
  // GATE de least-privilege: roles globais e claims de org só são emitidas
93
100
  // para clients FIRST-PARTY. Capturamos o clientId aqui (fora do closure
94
101
  // `claims()`, pois o `ctx` da request vive neste escopo).
@@ -119,10 +126,12 @@ export class OidcService {
119
126
  };
120
127
  // roles/org_* são dados de autorização interna: só para first-party.
121
128
  if (firstParty) {
129
+ const activeOrg = await currentOrg();
122
130
  base[config.globalRolesClaim] = config.resolveTokenRoles
123
131
  ? await config.resolveTokenRoles(user, { clientId, activeOrg })
124
132
  : (user.globalRoles ?? []);
125
- // Emite claims de org somente quando há uma org ativa na sessão.
133
+ // Emite claims de org somente quando há uma org ativa na sessão E a
134
+ // conta ainda é membro dela (conferido no store, papel atual).
126
135
  if (activeOrg) {
127
136
  base.org_id = activeOrg.orgId;
128
137
  base.org_slug = activeOrg.orgSlug;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.71.0",
3
+ "version": "0.73.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",