@adonis-agora/authkit-server 0.69.0 → 0.71.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.
Files changed (48) hide show
  1. package/build/commands/commands.json +18 -0
  2. package/build/commands/normalize_emails.d.ts +21 -0
  3. package/build/commands/normalize_emails.js +106 -0
  4. package/build/index.d.ts +4 -3
  5. package/build/index.js +6 -2
  6. package/build/src/accounts/account_store.d.ts +65 -1
  7. package/build/src/accounts/account_store.js +24 -0
  8. package/build/src/accounts/lucid_store/core.d.ts +2 -2
  9. package/build/src/accounts/lucid_store/core.js +16 -0
  10. package/build/src/accounts/lucid_store/mfa.js +22 -0
  11. package/build/src/audit/audit_sink.d.ts +1 -1
  12. package/build/src/audit/audit_sink.js +4 -0
  13. package/build/src/commands/import_users.js +7 -2
  14. package/build/src/commands/normalize_emails.d.ts +78 -0
  15. package/build/src/commands/normalize_emails.js +129 -0
  16. package/build/src/host/account_api/account_api_controller.d.ts +2 -0
  17. package/build/src/host/account_api/account_api_controller.js +22 -3
  18. package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
  19. package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
  20. package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
  21. package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
  22. package/build/src/host/account_lockout.js +7 -2
  23. package/build/src/host/admin_api/admin_users_service.js +11 -3
  24. package/build/src/host/admin_api/dto.d.ts +1 -1
  25. package/build/src/host/admin_validators.d.ts +2 -2
  26. package/build/src/host/admin_validators.js +3 -2
  27. package/build/src/host/controllers/account_mfa_controller.js +1 -2
  28. package/build/src/host/controllers/account_orgs_controller.js +4 -18
  29. package/build/src/host/controllers/account_security_controller.js +3 -1
  30. package/build/src/host/controllers/account_session_controller.js +7 -5
  31. package/build/src/host/controllers/interaction_controller.d.ts +10 -0
  32. package/build/src/host/controllers/interaction_controller.js +46 -14
  33. package/build/src/host/controllers/registration_controller.js +15 -5
  34. package/build/src/host/controllers/social_controller.js +8 -1
  35. package/build/src/host/email_identifier.d.ts +29 -0
  36. package/build/src/host/email_identifier.js +31 -0
  37. package/build/src/host/org_policy.d.ts +26 -0
  38. package/build/src/host/org_policy.js +35 -0
  39. package/build/src/host/passkey_registration_challenge.d.ts +12 -0
  40. package/build/src/host/passkey_registration_challenge.js +12 -0
  41. package/build/src/host/register_auth_host.js +51 -0
  42. package/build/src/host/sudo_mode.d.ts +17 -0
  43. package/build/src/host/sudo_mode.js +31 -12
  44. package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
  45. package/build/src/host/ui-dist/index.html +1 -1
  46. package/build/src/host/validators.d.ts +5 -5
  47. package/build/src/host/validators.js +17 -5
  48. package/package.json +2 -2
@@ -0,0 +1,129 @@
1
+ import { supportsAccountEmailRewrite } from '../accounts/account_store.js';
2
+ import { normalizeEmailIdentifier } from '../host/email_identifier.js';
3
+ import { ADMIN_LIST_DEFAULT_SIZE } from '../pagination.js';
4
+ /** Comparação por code unit — mesma ordem em qualquer máquina, sem depender do ICU. */
5
+ function byCodeUnit(a, b) {
6
+ if (a === b)
7
+ return 0;
8
+ return a < b ? -1 : 1;
9
+ }
10
+ /**
11
+ * Migração dos endereços GRAVADOS para a forma normalizada da identidade
12
+ * (`normalizeEmailIdentifier`: `trim` + `toLowerCase`).
13
+ *
14
+ * POR QUE ELA EXISTE: até a v0.68 o cadastro gravava o endereço mutilado pelo
15
+ * `.normalizeEmail()` do VineJS (no gmail, sem pontos e sem `+tag`), e import,
16
+ * convite e provider social gravavam a grafia crua, com maiúsculas. O login
17
+ * busca UMA forma só — a normalizada. Uma conta gravada em qualquer outra grafia
18
+ * fica INALCANÇÁVEL, e, por o login ser à prova de enumeração, sem nenhuma
19
+ * mensagem de erro. Esta migração é o que reencontra essas contas.
20
+ *
21
+ * Em modo RELATÓRIO (default) não escreve nada: varre, calcula e devolve o que
22
+ * mudaria. Com `apply`, grava — RECUSANDO-SE a tocar em qualquer conta envolvida
23
+ * numa colisão (duas contas que colapsam no mesmo endereço). Nunca funde contas,
24
+ * nunca escolhe vencedor: as colididas saem listadas para decisão humana.
25
+ *
26
+ * A varredura acontece INTEIRA antes de qualquer escrita, de propósito: a
27
+ * listagem é ordenada por e-mail, e reescrever endereços no meio da paginação
28
+ * moveria linhas entre páginas — contas seriam puladas sem nenhum sinal.
29
+ *
30
+ * Lógica PURA quanto a CLI (recebe o store, devolve o relatório) — testável sem
31
+ * ace e sem banco.
32
+ */
33
+ export async function normalizeAccountEmails(store, options = {}) {
34
+ const pageSize = options.pageSize ?? ADMIN_LIST_DEFAULT_SIZE;
35
+ const report = {
36
+ scanned: 0,
37
+ alreadyNormalized: 0,
38
+ changes: [],
39
+ applied: 0,
40
+ collisions: [],
41
+ skippedByCollision: 0,
42
+ unusable: [],
43
+ };
44
+ // 1) Varredura completa. Guarda só id + e-mail (strings), agrupados pela forma
45
+ // normalizada — é o agrupamento que revela as colisões.
46
+ const buckets = new Map();
47
+ for (let page = 1;; page++) {
48
+ const { data, total } = await store.listAccounts({ page, size: pageSize });
49
+ if (!data.length)
50
+ break;
51
+ for (const account of data) {
52
+ report.scanned++;
53
+ const normalized = normalizeEmailIdentifier(account.email);
54
+ const bucket = buckets.get(normalized);
55
+ if (bucket)
56
+ bucket.push({ accountId: account.id, email: account.email });
57
+ else
58
+ buckets.set(normalized, [{ accountId: account.id, email: account.email }]);
59
+ }
60
+ if (report.scanned >= total)
61
+ break;
62
+ }
63
+ // 2) Classificação: colisão, mudança ou já normalizada.
64
+ const pending = [];
65
+ for (const [normalized, accounts] of buckets) {
66
+ // `normalized` vazio (coluna nula/vazia/só espaços) não é migrável. Sai
67
+ // LISTADO, não somado às "já normalizadas": o relatório não pode dar
68
+ // atestado de saúde para a linha que ele deliberadamente deixou para trás.
69
+ // ANTES da checagem de colisão: duas linhas em branco caem no mesmo balde
70
+ // `""` e sairiam como "colisão no endereço ''", que não descreve nada.
71
+ if (!normalized) {
72
+ report.unusable.push(...accounts);
73
+ continue;
74
+ }
75
+ if (accounts.length > 1) {
76
+ // Duas contas distintas no mesmo endereço normalizado. NENHUMA é tocada —
77
+ // nem a que já está normalizada, porque a decisão (fundir? renomear? qual
78
+ // delas é a pessoa?) é humana e envolve as duas.
79
+ report.collisions.push({ email: normalized, accounts });
80
+ report.skippedByCollision += accounts.length;
81
+ continue;
82
+ }
83
+ const [account] = accounts;
84
+ if (account.email === normalized) {
85
+ report.alreadyNormalized++;
86
+ continue;
87
+ }
88
+ pending.push({
89
+ accountId: account.accountId,
90
+ from: account.email,
91
+ to: normalized,
92
+ applied: false,
93
+ });
94
+ }
95
+ // Ordem estável (por endereço gravado) para o relatório ser diffável entre
96
+ // runs. Comparação por code unit, NÃO `localeCompare`: a ordem desta depende
97
+ // do ICU da máquina, e aí "diffável" valeria só dentro de um host.
98
+ pending.sort((a, b) => byCodeUnit(a.from, b.from));
99
+ report.collisions.sort((a, b) => byCodeUnit(a.email, b.email));
100
+ // `unusable` também: sem isto a lista sairia na ordem da varredura, que é a
101
+ // ordem do store — a mesma garantia não valeria para ela.
102
+ report.unusable.sort((a, b) => byCodeUnit(a.accountId, b.accountId));
103
+ report.changes = pending;
104
+ if (!options.apply)
105
+ return report;
106
+ // 3) Escrita. Capacidade probada: um store sem ela não tem como regravar o
107
+ // endereço, e inventar um caminho por fora do store não é da lib.
108
+ if (!supportsAccountEmailRewrite(store)) {
109
+ throw new Error('accountStore não implementa rewriteAccountEmail: a migração não tem como gravar (relatório segue funcionando).');
110
+ }
111
+ for (const change of pending) {
112
+ try {
113
+ const ok = await store.rewriteAccountEmail(change.accountId, change.to);
114
+ if (ok) {
115
+ change.applied = true;
116
+ report.applied++;
117
+ }
118
+ else {
119
+ // O store recusou: conta sumiu ou o endereço passou a ser de OUTRA conta
120
+ // entre a varredura e a escrita (colisão que nasceu no meio do caminho).
121
+ change.error = 'o store recusou a regravação (conta inexistente ou endereço já tomado)';
122
+ }
123
+ }
124
+ catch (error) {
125
+ change.error = error.message;
126
+ }
127
+ }
128
+ return report;
129
+ }
@@ -170,6 +170,8 @@ export default class AccountApiController {
170
170
  };
171
171
  recovery: {
172
172
  available: any;
173
+ remaining: number | null;
174
+ regenerable: boolean;
173
175
  };
174
176
  }>;
175
177
  /**
@@ -28,7 +28,7 @@
28
28
  * GET /account/api/orgs/invitations → convites pendentes
29
29
  */
30
30
  import '../augmentations.js';
31
- import { supportsAccountSecurity, supportsLoginMethodsPreference, supportsMagicLink, supportsOrganizations, supportsPasskeys, supportsProfile, } from '../../accounts/account_store.js';
31
+ import { supportsAccountSecurity, supportsLoginMethodsPreference, supportsMagicLink, supportsOrganizations, supportsPasskeys, supportsProfile, supportsRecoveryCodeCount, supportsRecoveryCodeRegeneration, } from '../../accounts/account_store.js';
32
32
  import { PasswordPolicyError } from '../../password/password_manager.js';
33
33
  import { accountPath } from '../account_paths.js';
34
34
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
@@ -627,6 +627,16 @@ export default class AccountApiController {
627
627
  /* fail-safe */
628
628
  }
629
629
  }
630
+ // Quantos recovery codes restam (capability-probed, fail-safe).
631
+ let recoveryRemaining = null;
632
+ if (supportsRecoveryCodeCount(cfg.accountStore)) {
633
+ try {
634
+ recoveryRemaining = await cfg.accountStore.countRecoveryCodes(userId);
635
+ }
636
+ catch {
637
+ /* fail-safe */
638
+ }
639
+ }
630
640
  return {
631
641
  enabled,
632
642
  totp: { enrolled: enabled },
@@ -639,8 +649,17 @@ export default class AccountApiController {
639
649
  createdAt: p.createdAt ?? null,
640
650
  })),
641
651
  },
642
- // Recovery codes are shown once via the existing POST /account/mfa/confirm flow.
643
- recovery: { available: enabled },
652
+ recovery: {
653
+ // Os códigos em si NUNCA voltam aqui — só no instante em que são
654
+ // criados (`/mfa/totp/confirm` ou `/mfa/recovery-codes`), porque é só
655
+ // o hash deles que fica guardado.
656
+ available: enabled,
657
+ // `remaining` é `null` quando o store não sabe contar (capacidade
658
+ // opcional) — diferente de `0`, que significa "acabaram, gere novos".
659
+ remaining: recoveryRemaining,
660
+ // O host só mostra o botão "gerar novos códigos" se houver como.
661
+ regenerable: supportsRecoveryCodeRegeneration(cfg.accountStore),
662
+ },
644
663
  };
645
664
  }
646
665
  // ─── GET /account/api/login-methods ─────────────────────────────────────
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Account Self-Service JSON API — SEGUNDO FATOR (TOTP + passkeys)
3
+ *
4
+ * Espelho JSON do `AccountMfaController`, para o host que desenha a própria
5
+ * tela de "segundo fator" e não pode navegar o browser no meio do fluxo.
6
+ *
7
+ * Mapa de rotas (sob o `accountGuard`; mutantes sob o CSRF do shield do host):
8
+ * POST /account/api/mfa/totp/enroll → inicia o enrolamento (sudo)
9
+ * POST /account/api/mfa/totp/confirm → confirma com o código (throttled)
10
+ * POST /account/api/mfa/totp/disable → desliga o MFA (sudo)
11
+ * POST /account/api/mfa/recovery-codes → regenera os códigos (sudo)
12
+ * POST /account/api/mfa/passkeys/options → options da cerimônia de registro
13
+ * POST /account/api/mfa/passkeys/verify → verifica o attestation (sudo)
14
+ *
15
+ * Paridade de gates com o console HTML, endpoint a endpoint:
16
+ *
17
+ * | ação | console HTML | aqui |
18
+ * | ---------------- | ------------ | ---------------- |
19
+ * | enroll | sudo | sudo → 403 JSON |
20
+ * | confirm | sem sudo | sem sudo |
21
+ * | disable | sudo | sudo → 403 JSON |
22
+ * | passkey options | sem sudo | sem sudo |
23
+ * | passkey verify | sudo | sudo → 403 JSON |
24
+ * | recovery codes | (não existe) | sudo → 403 JSON |
25
+ *
26
+ * A única diferença é a FORMA da recusa: `requireSudo` devolve um redirect para
27
+ * `/account/confirm`, que numa SPA chega como uma página HTML onde ela esperava
28
+ * JSON. Aqui a recusa vira `403 { error: { code: 'sudo_required' } }` e a tela
29
+ * do host manda o usuário confirmar identidade por conta própria. Mesma
30
+ * política, resposta legível por máquina.
31
+ *
32
+ * `confirm` NÃO exige sudo — porque o `enroll` que criou o segredo pendente já
33
+ * exigiu, e pedir de novo no passo seguinte quebraria o enrolamento de quem
34
+ * demorou a digitar o código. É exatamente o que o console faz. O que o console
35
+ * não tem, e aqui existe, é o THROTTLE: o código de 6 dígitos é adivinhável, e
36
+ * a rota carrega o bucket de sudo (por IP) — mais apertado que o form, nunca
37
+ * mais frouxo.
38
+ *
39
+ * Os dois caminhos (clássico e JSON) COMPARTILHAM o slot do desafio WebAuthn
40
+ * (`PASSKEY_REG_CHALLENGE_KEY`), então um `options` pedido num deles pode ser
41
+ * finalizado pelo outro e nunca existem dois desafios vivos na mesma sessão.
42
+ */
43
+ import '../augmentations.js';
44
+ import type { HttpContext } from '@adonisjs/core/http';
45
+ export default class AccountMfaApiController {
46
+ #private;
47
+ /**
48
+ * Inicia o enrolamento TOTP: segredo pendente + `otpauth://` URI + o QR já
49
+ * renderizado como data-URL (o console renderiza server-side; aqui a tela do
50
+ * host recebe pronto e decide se mostra o QR, o segredo, ou os dois).
51
+ */
52
+ enrollTotp(ctx: HttpContext): Promise<void | {
53
+ secret: string;
54
+ otpauthUri: string;
55
+ qrDataUrl: string;
56
+ }>;
57
+ /**
58
+ * Confirma o enrolamento com o código do app autenticador. Sucesso ativa o
59
+ * MFA e devolve os recovery codes — UMA vez, como no console (lá eles vão num
60
+ * flash; aqui, no corpo da resposta, que é o equivalente headless).
61
+ */
62
+ confirmTotp(ctx: HttpContext): Promise<void | {
63
+ ok: boolean;
64
+ enabled: boolean;
65
+ recoveryCodes: string[];
66
+ }>;
67
+ /** Desliga o MFA (TOTP + recovery codes). Exige sudo, como o console. */
68
+ disableTotp(ctx: HttpContext): Promise<{
69
+ ok: boolean;
70
+ enabled: boolean;
71
+ } | undefined>;
72
+ /**
73
+ * Regenera os recovery codes de uma conta com MFA ATIVO e devolve os novos —
74
+ * uma única vez. Exige sudo: o resultado é um conjunto de credenciais que
75
+ * contorna o segundo fator, então vale o mesmo gate do `enroll`/`disable`.
76
+ *
77
+ * Capability-probed: stores que não implementam
78
+ * `regenerateRecoveryCodes` respondem 422 em vez de 500.
79
+ */
80
+ regenerateRecoveryCodes(ctx: HttpContext): Promise<void | {
81
+ ok: boolean;
82
+ recoveryCodes: string[];
83
+ }>;
84
+ /**
85
+ * Options da cerimônia de REGISTRO de passkey, guardando o desafio na sessão.
86
+ * Sem sudo, igual ao endpoint clássico: quem paga o gate é o `verify`, que é
87
+ * onde a credencial passa a existir.
88
+ */
89
+ passkeyRegisterOptions(ctx: HttpContext): Promise<any>;
90
+ /**
91
+ * Verifica o attestation contra o desafio da sessão e persiste a credencial.
92
+ *
93
+ * O endpoint clássico responde 302 numa navegação e `{ok:true}` num fetch;
94
+ * este responde SEMPRE JSON — inclusive na recusa de sudo, que lá é um
95
+ * redirect. É a diferença que faz a cerimônia caber numa SPA: o browser não
96
+ * pode navegar entre `startRegistration()` e a verificação.
97
+ */
98
+ passkeyRegisterVerify(ctx: HttpContext): Promise<void | {
99
+ ok: boolean;
100
+ }>;
101
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Account Self-Service JSON API — SEGUNDO FATOR (TOTP + passkeys)
3
+ *
4
+ * Espelho JSON do `AccountMfaController`, para o host que desenha a própria
5
+ * tela de "segundo fator" e não pode navegar o browser no meio do fluxo.
6
+ *
7
+ * Mapa de rotas (sob o `accountGuard`; mutantes sob o CSRF do shield do host):
8
+ * POST /account/api/mfa/totp/enroll → inicia o enrolamento (sudo)
9
+ * POST /account/api/mfa/totp/confirm → confirma com o código (throttled)
10
+ * POST /account/api/mfa/totp/disable → desliga o MFA (sudo)
11
+ * POST /account/api/mfa/recovery-codes → regenera os códigos (sudo)
12
+ * POST /account/api/mfa/passkeys/options → options da cerimônia de registro
13
+ * POST /account/api/mfa/passkeys/verify → verifica o attestation (sudo)
14
+ *
15
+ * Paridade de gates com o console HTML, endpoint a endpoint:
16
+ *
17
+ * | ação | console HTML | aqui |
18
+ * | ---------------- | ------------ | ---------------- |
19
+ * | enroll | sudo | sudo → 403 JSON |
20
+ * | confirm | sem sudo | sem sudo |
21
+ * | disable | sudo | sudo → 403 JSON |
22
+ * | passkey options | sem sudo | sem sudo |
23
+ * | passkey verify | sudo | sudo → 403 JSON |
24
+ * | recovery codes | (não existe) | sudo → 403 JSON |
25
+ *
26
+ * A única diferença é a FORMA da recusa: `requireSudo` devolve um redirect para
27
+ * `/account/confirm`, que numa SPA chega como uma página HTML onde ela esperava
28
+ * JSON. Aqui a recusa vira `403 { error: { code: 'sudo_required' } }` e a tela
29
+ * do host manda o usuário confirmar identidade por conta própria. Mesma
30
+ * política, resposta legível por máquina.
31
+ *
32
+ * `confirm` NÃO exige sudo — porque o `enroll` que criou o segredo pendente já
33
+ * exigiu, e pedir de novo no passo seguinte quebraria o enrolamento de quem
34
+ * demorou a digitar o código. É exatamente o que o console faz. O que o console
35
+ * não tem, e aqui existe, é o THROTTLE: o código de 6 dígitos é adivinhável, e
36
+ * a rota carrega o bucket de sudo (por IP) — mais apertado que o form, nunca
37
+ * mais frouxo.
38
+ *
39
+ * Os dois caminhos (clássico e JSON) COMPARTILHAM o slot do desafio WebAuthn
40
+ * (`PASSKEY_REG_CHALLENGE_KEY`), então um `options` pedido num deles pode ser
41
+ * finalizado pelo outro e nunca existem dois desafios vivos na mesma sessão.
42
+ */
43
+ import '../augmentations.js';
44
+ import QRCode from 'qrcode';
45
+ import { supportsMfa, supportsPasskeys, supportsRecoveryCodeRegeneration, } from '../../accounts/account_store.js';
46
+ import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
47
+ import { translate } from '../i18n.js';
48
+ import { PASSKEY_REG_CHALLENGE_KEY } from '../passkey_registration_challenge.js';
49
+ import { resolveRuntimeSettings } from '../runtime_settings.js';
50
+ import { dispatchSecurityNotice } from '../security_notice_service.js';
51
+ import { isSudoSatisfied } from '../sudo_mode.js';
52
+ /** Erro JSON padrão — mesmo envelope do `account_api_controller`. */
53
+ function apiErr(code, message) {
54
+ return { error: { code, message } };
55
+ }
56
+ export default class AccountMfaApiController {
57
+ // ─── POST /account/api/mfa/totp/enroll ──────────────────────────────────
58
+ /**
59
+ * Inicia o enrolamento TOTP: segredo pendente + `otpauth://` URI + o QR já
60
+ * renderizado como data-URL (o console renderiza server-side; aqui a tela do
61
+ * host recebe pronto e decide se mostra o QR, o segredo, ou os dois).
62
+ */
63
+ async enrollTotp(ctx) {
64
+ const c = await this.#mfaContext(ctx);
65
+ if (!c)
66
+ return;
67
+ if (!(await this.#requireSudoJson(ctx)))
68
+ return;
69
+ const started = await c.store.startTotpEnrollment(c.userId);
70
+ if (!started) {
71
+ return ctx.response
72
+ .status(422)
73
+ .send(apiErr('enroll_failed', 'Could not start TOTP enrollment.'));
74
+ }
75
+ const qrDataUrl = await QRCode.toDataURL(started.otpauthUri);
76
+ return { secret: started.secret, otpauthUri: started.otpauthUri, qrDataUrl };
77
+ }
78
+ // ─── POST /account/api/mfa/totp/confirm ─────────────────────────────────
79
+ /**
80
+ * Confirma o enrolamento com o código do app autenticador. Sucesso ativa o
81
+ * MFA e devolve os recovery codes — UMA vez, como no console (lá eles vão num
82
+ * flash; aqui, no corpo da resposta, que é o equivalente headless).
83
+ */
84
+ async confirmTotp(ctx) {
85
+ const c = await this.#mfaContext(ctx);
86
+ if (!c)
87
+ return;
88
+ const code = String(ctx.request.input('code', '') ?? '').trim();
89
+ const result = await c.store.confirmTotpEnrollment(c.userId, code);
90
+ if (!result.ok) {
91
+ // NÃO regenera o segredo pendente: o usuário já escaneou o QR, e um
92
+ // segredo novo invalidaria o app autenticador dele. Mesma decisão do
93
+ // console — a tela pede outro código sobre o MESMO segredo.
94
+ return ctx.response
95
+ .status(422)
96
+ .send(apiErr('invalid_code', translate(c.cfg.messages, 'errors.invalid_code')));
97
+ }
98
+ await c.cfg.audit?.record({
99
+ type: 'mfa.enabled',
100
+ accountId: c.userId,
101
+ ip: ctx.request.ip?.() ?? null,
102
+ metadata: { method: 'totp' },
103
+ });
104
+ await this.#notice(ctx, c, 'mfa_enabled');
105
+ return { ok: true, enabled: true, recoveryCodes: result.recoveryCodes ?? [] };
106
+ }
107
+ // ─── POST /account/api/mfa/totp/disable ─────────────────────────────────
108
+ /** Desliga o MFA (TOTP + recovery codes). Exige sudo, como o console. */
109
+ async disableTotp(ctx) {
110
+ const c = await this.#mfaContext(ctx);
111
+ if (!c)
112
+ return;
113
+ if (!(await this.#requireSudoJson(ctx)))
114
+ return;
115
+ await c.store.disableMfa(c.userId);
116
+ await c.cfg.audit?.record({
117
+ type: 'mfa.disabled',
118
+ accountId: c.userId,
119
+ ip: ctx.request.ip?.() ?? null,
120
+ });
121
+ await this.#notice(ctx, c, 'mfa_disabled');
122
+ return { ok: true, enabled: false };
123
+ }
124
+ // ─── POST /account/api/mfa/recovery-codes ───────────────────────────────
125
+ /**
126
+ * Regenera os recovery codes de uma conta com MFA ATIVO e devolve os novos —
127
+ * uma única vez. Exige sudo: o resultado é um conjunto de credenciais que
128
+ * contorna o segundo fator, então vale o mesmo gate do `enroll`/`disable`.
129
+ *
130
+ * Capability-probed: stores que não implementam
131
+ * `regenerateRecoveryCodes` respondem 422 em vez de 500.
132
+ */
133
+ async regenerateRecoveryCodes(ctx) {
134
+ const c = await this.#mfaContext(ctx);
135
+ if (!c)
136
+ return;
137
+ if (!supportsRecoveryCodeRegeneration(c.store)) {
138
+ return ctx.response
139
+ .status(422)
140
+ .send(apiErr('capability_unsupported', 'Recovery code regeneration not supported.'));
141
+ }
142
+ if (!(await this.#requireSudoJson(ctx)))
143
+ return;
144
+ const codes = await c.store.regenerateRecoveryCodes(c.userId);
145
+ if (!codes) {
146
+ // Sem MFA ativo não há conjunto a regenerar — enrolar é o caminho.
147
+ return ctx.response.status(422).send(apiErr('mfa_not_enabled', 'MFA is not enabled.'));
148
+ }
149
+ await c.cfg.audit?.record({
150
+ type: 'mfa.recovery_codes_regenerated',
151
+ accountId: c.userId,
152
+ ip: ctx.request.ip?.() ?? null,
153
+ });
154
+ return { ok: true, recoveryCodes: codes };
155
+ }
156
+ // ─── POST /account/api/mfa/passkeys/options ─────────────────────────────
157
+ /**
158
+ * Options da cerimônia de REGISTRO de passkey, guardando o desafio na sessão.
159
+ * Sem sudo, igual ao endpoint clássico: quem paga o gate é o `verify`, que é
160
+ * onde a credencial passa a existir.
161
+ */
162
+ async passkeyRegisterOptions(ctx) {
163
+ const service = await ctx.containerResolver.make('authkit.server');
164
+ const cfg = service.config;
165
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
166
+ if (!supportsPasskeys(cfg.accountStore)) {
167
+ return ctx.response
168
+ .status(422)
169
+ .send(apiErr('capability_unsupported', translate(cfg.messages, 'errors.passkeys_unavailable')));
170
+ }
171
+ const generated = await cfg.accountStore.generatePasskeyRegistrationOptions?.(userId);
172
+ if (!generated) {
173
+ return ctx.response
174
+ .status(422)
175
+ .send(apiErr('capability_unsupported', translate(cfg.messages, 'errors.passkeys_unavailable')));
176
+ }
177
+ ctx.session.put(PASSKEY_REG_CHALLENGE_KEY, generated.challenge);
178
+ return generated.options;
179
+ }
180
+ // ─── POST /account/api/mfa/passkeys/verify ──────────────────────────────
181
+ /**
182
+ * Verifica o attestation contra o desafio da sessão e persiste a credencial.
183
+ *
184
+ * O endpoint clássico responde 302 numa navegação e `{ok:true}` num fetch;
185
+ * este responde SEMPRE JSON — inclusive na recusa de sudo, que lá é um
186
+ * redirect. É a diferença que faz a cerimônia caber numa SPA: o browser não
187
+ * pode navegar entre `startRegistration()` e a verificação.
188
+ */
189
+ async passkeyRegisterVerify(ctx) {
190
+ const service = await ctx.containerResolver.make('authkit.server');
191
+ const cfg = service.config;
192
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
193
+ if (!supportsPasskeys(cfg.accountStore)) {
194
+ return ctx.response
195
+ .status(422)
196
+ .send(apiErr('capability_unsupported', translate(cfg.messages, 'errors.passkeys_unavailable')));
197
+ }
198
+ if (!(await this.#requireSudoJson(ctx)))
199
+ return;
200
+ const challenge = ctx.session.get(PASSKEY_REG_CHALLENGE_KEY);
201
+ if (!challenge) {
202
+ return ctx.response
203
+ .status(400)
204
+ .send(apiErr('challenge_expired', translate(cfg.messages, 'errors.challenge_expired')));
205
+ }
206
+ const body = ctx.request.input('response', ctx.request.body());
207
+ const ok = (await cfg.accountStore.verifyPasskeyRegistration?.(userId, body, challenge)) ?? false;
208
+ // Queima o desafio em QUALQUER desfecho: um attestation recusado não pode
209
+ // ser retentado contra o mesmo challenge.
210
+ ctx.session.forget(PASSKEY_REG_CHALLENGE_KEY);
211
+ if (!ok) {
212
+ return ctx.response
213
+ .status(400)
214
+ .send(apiErr('invalid_response', translate(cfg.messages, 'errors.invalid_code')));
215
+ }
216
+ const ip = ctx.request.ip?.() ?? null;
217
+ await cfg.audit?.record({
218
+ type: 'mfa.enabled',
219
+ accountId: userId,
220
+ ip,
221
+ metadata: { method: 'webauthn' },
222
+ });
223
+ await cfg.audit?.record({ type: 'passkey.registered', accountId: userId, ip });
224
+ const account = await cfg.accountStore.findById(userId);
225
+ if (account) {
226
+ const ts = new Date().toISOString();
227
+ for (const kind of ['passkey_added', 'mfa_enabled']) {
228
+ await dispatchSecurityNotice(ctx, { account: { id: userId, email: account.email }, kind, ip, timestamp: ts }, cfg.mail, cfg.audit, cfg);
229
+ }
230
+ }
231
+ return { ok: true };
232
+ }
233
+ // ─── Internos ───────────────────────────────────────────────────────────
234
+ /**
235
+ * Resolve config + store com MFA + a conta da sessão. Quando o pré-requisito
236
+ * falha, JÁ RESPONDE e devolve `null`.
237
+ */
238
+ async #mfaContext(ctx) {
239
+ const service = await ctx.containerResolver.make('authkit.server');
240
+ const cfg = service.config;
241
+ const store = cfg.accountStore;
242
+ if (!supportsMfa(store)) {
243
+ ctx.response
244
+ .status(422)
245
+ .send(apiErr('capability_unsupported', 'MFA not supported by the account store.'));
246
+ return null;
247
+ }
248
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
249
+ if (!userId) {
250
+ ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
251
+ return null;
252
+ }
253
+ return { cfg, store, userId };
254
+ }
255
+ /**
256
+ * Gate de sudo em versão JSON. A DECISÃO é a mesmíssima do console —
257
+ * `isSudoSatisfied`, de onde o `requireSudo` do form também tira a dele
258
+ * (mesma setting, mesma janela de graça, mesma vinculação à conta, mesmo
259
+ * fail-safe) —, só a RECUSA muda de forma: `403 sudo_required` em vez do
260
+ * redirect para `/account/confirm`.
261
+ *
262
+ * Chama a decisão, e não o `requireSudo`, porque aquele ESCREVE na resposta
263
+ * ao recusar (`response.redirect`): sobrepor um 403 depois deixaria um
264
+ * `Location` pendurado numa resposta que não é redirect. Devolve `false`
265
+ * quando já respondeu.
266
+ */
267
+ async #requireSudoJson(ctx) {
268
+ const settings = await resolveRuntimeSettings(ctx);
269
+ if (await isSudoSatisfied(ctx, settings))
270
+ return true;
271
+ ctx.response.status(403).send(apiErr('sudo_required', 'Identity confirmation required.'));
272
+ return false;
273
+ }
274
+ /** Notificação de segurança best-effort (nunca derruba a operação). */
275
+ async #notice(ctx, c, kind) {
276
+ const account = await c.cfg.accountStore.findById(c.userId);
277
+ if (!account)
278
+ return;
279
+ await dispatchSecurityNotice(ctx, {
280
+ account: { id: c.userId, email: account.email },
281
+ kind: kind,
282
+ ip: ctx.request.ip?.() ?? null,
283
+ timestamp: new Date().toISOString(),
284
+ }, c.cfg.mail, c.cfg.audit, c.cfg);
285
+ }
286
+ }