@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.
- package/build/host/views/mfa-challenge.edge +8 -3
- package/build/src/accounts/account_store.d.ts +8 -0
- package/build/src/accounts/lucid_store/mfa.js +9 -1
- package/build/src/host/account_api/account_orgs_api_controller.js +6 -1
- package/build/src/host/active_org_cookie.d.ts +21 -1
- package/build/src/host/active_org_cookie.js +38 -0
- package/build/src/host/admin_api/admin_orgs_service.d.ts +7 -1
- package/build/src/host/admin_api/admin_orgs_service.js +11 -1
- package/build/src/host/admin_api/api_orgs_controller.js +20 -20
- package/build/src/host/admin_console/console_orgs_controller.js +1 -1
- package/build/src/host/admin_sessions_service.d.ts +21 -0
- package/build/src/host/admin_sessions_service.js +49 -0
- package/build/src/host/controllers/account_orgs_controller.js +3 -0
- package/build/src/host/controllers/interaction_controller.d.ts +45 -0
- package/build/src/host/controllers/interaction_controller.js +168 -70
- package/build/src/host/org_access_revocation.d.ts +20 -0
- package/build/src/host/org_access_revocation.js +41 -0
- package/build/src/provider/oidc_service.js +12 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
596
|
-
//
|
|
597
|
-
await service.interactions.completeLogin(ctx, accountId, this.
|
|
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
|
-
|
|
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
|
-
//
|
|
1295
|
-
//
|
|
1296
|
-
|
|
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
|
|
1378
|
-
if (
|
|
1379
|
-
|
|
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
|
|
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.
|
|
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",
|