@adonis-agora/authkit-server 0.44.0 → 0.46.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 (52) hide show
  1. package/build/commands/ui_preset.js +15 -1
  2. package/build/host/views/account/confirm.edge +118 -52
  3. package/build/host/views/account/mfa.edge +1 -1
  4. package/build/host/views/login.edge +2 -4
  5. package/build/host/views/mfa-challenge.edge +1 -1
  6. package/build/host/views/partials/styles.edge +1 -1
  7. package/build/index.d.ts +5 -1
  8. package/build/index.js +15 -1
  9. package/build/src/define_config.d.ts +71 -3
  10. package/build/src/define_config.js +11 -0
  11. package/build/src/host/account_api/account_api_controller.js +2 -2
  12. package/build/src/host/account_deletion_ops.d.ts +2 -2
  13. package/build/src/host/account_deletion_ops.js +3 -3
  14. package/build/src/host/account_deletion_service.js +1 -1
  15. package/build/src/host/assets/webauthn.js +2 -0
  16. package/build/src/host/avatar_storage.d.ts +40 -14
  17. package/build/src/host/avatar_storage.js +221 -72
  18. package/build/src/host/controllers/account_confirm_controller.d.ts +11 -14
  19. package/build/src/host/controllers/account_confirm_controller.js +42 -130
  20. package/build/src/host/controllers/account_security_controller.js +3 -3
  21. package/build/src/host/controllers/account_session_controller.js +20 -4
  22. package/build/src/host/controllers/webauthn_asset_controller.d.ts +22 -0
  23. package/build/src/host/controllers/webauthn_asset_controller.js +66 -0
  24. package/build/src/host/durable/account_deletion_workflow.js +1 -1
  25. package/build/src/host/i18n.d.ts +14 -0
  26. package/build/src/host/i18n.js +16 -0
  27. package/build/src/host/impersonation_session.js +15 -0
  28. package/build/src/host/rate_limit.d.ts +10 -0
  29. package/build/src/host/rate_limit.js +6 -0
  30. package/build/src/host/register_auth_host.d.ts +19 -0
  31. package/build/src/host/register_auth_host.js +72 -4
  32. package/build/src/host/sudo/index.d.ts +47 -0
  33. package/build/src/host/sudo/index.js +41 -0
  34. package/build/src/host/sudo/methods/magic_link.d.ts +43 -0
  35. package/build/src/host/sudo/methods/magic_link.js +174 -0
  36. package/build/src/host/sudo/methods/oidc_step_up.d.ts +68 -0
  37. package/build/src/host/sudo/methods/oidc_step_up.js +78 -0
  38. package/build/src/host/sudo/methods/passkey.d.ts +28 -0
  39. package/build/src/host/sudo/methods/passkey.js +139 -0
  40. package/build/src/host/sudo/methods/password.d.ts +19 -0
  41. package/build/src/host/sudo/methods/password.js +93 -0
  42. package/build/src/host/sudo/runtime.d.ts +141 -0
  43. package/build/src/host/sudo/runtime.js +327 -0
  44. package/build/src/host/sudo/types.d.ts +93 -0
  45. package/build/src/host/sudo/types.js +1 -0
  46. package/build/src/host/sudo_mode.d.ts +116 -6
  47. package/build/src/host/sudo_mode.js +133 -7
  48. package/package.json +10 -2
  49. package/build/stubs/ui/edge/views/consent.edge +0 -13
  50. package/build/stubs/ui/edge/views/login.edge +0 -19
  51. package/stubs/ui/edge/views/consent.edge +0 -13
  52. package/stubs/ui/edge/views/login.edge +0 -19
@@ -1,144 +1,56 @@
1
1
  /**
2
2
  * Sudo mode — tela de confirmação de identidade (/account/confirm).
3
3
  *
4
- * GET /account/confirm → exibe o formulário de senha (e opção de passkey se disponível).
5
- * POST /account/confirm → verifica a senha e, se correta, marca o sudo na sessão.
6
- * POST /account/confirm/passkey/options gera as opções de autenticação.
7
- * POST /account/confirm/passkey → verifica a resposta da passkey e marca o sudo.
4
+ * O GET lista os métodos DISPONÍVEIS para a conta (SPI `SudoMethod`); a
5
+ * verificação de cada um vive no próprio método, nas rotas que ele registra.
6
+ * Este controller não verifica credencial nem chama `markSudo`.
8
7
  *
9
8
  * A tela está atrás do `accountGuard` (requer sessão de conta ativa).
10
- * Após confirmação, redireciona para `return_to` (validado) ou para `/account/tokens`.
11
9
  */
12
10
  import '../augmentations.js';
13
- import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
14
- import { translate } from '../i18n.js';
15
- import { supportsPasskeys } from '../../accounts/account_store.js';
16
- import { markSudo } from '../sudo_mode.js';
17
- import { validateReturnTo } from './account_session_controller.js';
18
- import { accountHome } from '../account_home.js';
19
- /** Chave de sessão para o challenge de passkey no confirm. */
20
- const CONFIRM_PASSKEY_CHALLENGE_KEY = 'authkit_confirm_passkey_challenge';
11
+ import { resolveAvailableMethods, configuredSudoMethods, isSudoMethodMounted, sudoContextFrom, LAST_METHOD_SESSION_KEY, } from '../sudo/runtime.js';
12
+ /**
13
+ * Reexport de compatibilidade. O construtor canônico do `SudoContext` vive em
14
+ * `sudo/runtime.ts` runtime do SPI, não detalhe da tela); este caminho
15
+ * antigo segue valendo para quem já o importava.
16
+ */
17
+ export { sudoContextFrom };
21
18
  export default class AccountConfirmController {
22
19
  async show(ctx) {
23
- const service = await ctx.containerResolver.make('authkit.server');
24
- const cfg = service.config;
25
- const render = cfg.render;
26
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
27
- const passkeyAvailable = supportsPasskeys(cfg.accountStore)
28
- ? (await cfg.accountStore.listPasskeys(userId)).length > 0
29
- : false;
30
- // Conta passwordless: sem hash de senha (campo `password` vazio/null no model).
31
- // Verificamos indiretamente: verifyCredentials com senha fictícia vai falhar,
32
- // mas precisamos saber se a conta TEM senha. Abordagem: a conta é "passwordless"
33
- // se o store suporta passkeys, a conta tem pelo menos uma passkey E não tem
34
- // hash de senha mas sem expor o hash, usamos uma flag do store se disponível.
35
- // Fallback conservador: assumimos que a conta tem senha se não soubermos.
36
- const passwordless = await this.isPasswordless(cfg, userId);
37
- const rawReturnTo = ctx.request.qs?.()?.return_to ?? ctx.request.input?.('return_to');
38
- const returnTo = validateReturnTo(rawReturnTo);
39
- return render(ctx, 'account/confirm', {
20
+ const c = await sudoContextFrom(ctx);
21
+ const available = await resolveAvailableMethods(c, configuredSudoMethods(c.cfg));
22
+ const methods = await Promise.all(available.map(async (m) => ({ id: m.id, ...(await m.describe(c)) })));
23
+ if (!methods.length) {
24
+ // Nenhum método disponível é erro de CONFIGURAÇÃO do host, não usuário
25
+ // preso: a tela informa e o log aponta o problema.
26
+ ;
27
+ ctx.logger?.error({ accountId: c.accountId }, 'authkit: nenhum método de sudo disponível para a conta — verifique config.sudo.methods');
28
+ }
29
+ // FLAG-DRIFT: `config.sudo.methods` decide o que a tela OFERECE, mas as
30
+ // rotas são montadas em tempo de registro, por `registerAuthHost`. Se as
31
+ // duas listas divergirem, a tela mostra um método cujo endpoint não existe
32
+ // falha silenciosa e confusa. Avisa alto, uma vez por render.
33
+ //
34
+ // o caso de config EXPLÍCITA chega aqui divergindo: sem config,
35
+ // `configuredSudoMethods` devolve a própria lista montada.
36
+ for (const m of methods) {
37
+ if (!isSudoMethodMounted(m.id)) {
38
+ ;
39
+ ctx.logger?.warn({ method: m.id }, `authkit: método de sudo "${m.id}" está em config.sudo.methods mas não teve ` +
40
+ 'rotas montadas por registerAuthHost — a tela vai oferecer uma opção cujo ' +
41
+ 'endpoint não existe');
42
+ }
43
+ }
44
+ return c.cfg.render(ctx, 'account/confirm', {
40
45
  csrfToken: ctx.request.csrfToken,
41
- returnTo,
46
+ returnTo: c.returnTo,
42
47
  error: ctx.session.flashMessages.get('confirmError') ?? null,
43
- passwordless,
44
- passkeyAvailable,
45
- });
46
- }
47
- async confirm(ctx) {
48
- const service = await ctx.containerResolver.make('authkit.server');
49
- const cfg = service.config;
50
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
51
- const account = await cfg.accountStore.findById(userId);
52
- const rawReturnTo = ctx.request.input?.('return_to');
53
- const returnTo = validateReturnTo(rawReturnTo);
54
- const { password } = ctx.request.only(['password']);
55
- if (!password || !account) {
56
- ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.error'));
57
- return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
58
- }
59
- const verified = await cfg.accountStore.verifyCredentials(account.email, password);
60
- if (!verified) {
61
- ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.error'));
62
- return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
63
- }
64
- // Confirmado: marca o sudo na sessão.
65
- markSudo(ctx);
66
- await cfg.audit?.record({
67
- type: 'sudo.confirmed',
68
- accountId: userId,
69
- ip: ctx.request.ip?.() ?? null,
70
- metadata: { method: 'password' },
71
- });
72
- return ctx.response.redirect(returnTo ?? accountHome(cfg));
73
- }
74
- async passkeyOptions(ctx) {
75
- const service = await ctx.containerResolver.make('authkit.server');
76
- const cfg = service.config;
77
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
78
- const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.(userId);
79
- if (!generated) {
80
- return ctx.response.notFound({
81
- message: translate(cfg.messages, 'errors.no_passkey_registered'),
82
- });
83
- }
84
- ctx.session.put(CONFIRM_PASSKEY_CHALLENGE_KEY, generated.challenge);
85
- return generated.options;
86
- }
87
- async passkeyConfirm(ctx) {
88
- const service = await ctx.containerResolver.make('authkit.server');
89
- const cfg = service.config;
90
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
91
- const challenge = ctx.session.get(CONFIRM_PASSKEY_CHALLENGE_KEY);
92
- const rawReturnTo = ctx.request.input?.('return_to');
93
- const returnTo = validateReturnTo(rawReturnTo);
94
- if (!challenge) {
95
- ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.passkey_error'));
96
- return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
97
- }
98
- const raw = ctx.request.input('response');
99
- let parsed = null;
100
- try {
101
- parsed = raw ? JSON.parse(raw) : null;
102
- }
103
- catch {
104
- parsed = null;
105
- }
106
- const ok = parsed
107
- ? ((await cfg.accountStore.verifyPasskeyAuthentication?.(userId, parsed, challenge)) ?? false)
108
- : false;
109
- ctx.session.forget(CONFIRM_PASSKEY_CHALLENGE_KEY);
110
- if (!ok) {
111
- ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.passkey_error'));
112
- return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
113
- }
114
- // Passkey confirmada: marca o sudo.
115
- markSudo(ctx);
116
- await cfg.audit?.record({
117
- type: 'sudo.confirmed',
118
- accountId: userId,
119
- ip: ctx.request.ip?.() ?? null,
120
- metadata: { method: 'passkey' },
48
+ // `confirmNotice` é flashado por `magic_link.ts` ao enviar o link (já
49
+ // traduzido, mesmo padrão de `confirmError`). Sem repassar aqui, quem
50
+ // pede o link volta para a tela sem nenhum feedback de que o e-mail saiu.
51
+ notice: ctx.session.flashMessages.get('confirmNotice') ?? null,
52
+ methods,
53
+ preferredId: ctx.session.get(LAST_METHOD_SESSION_KEY) ?? null,
121
54
  });
122
- return ctx.response.redirect(returnTo ?? accountHome(cfg));
123
- }
124
- /**
125
- * Verifica se a conta é "passwordless" (sem hash de senha definido).
126
- * Fail-safe: retorna `false` quando não é possível determinar.
127
- */
128
- async isPasswordless(cfg, accountId) {
129
- try {
130
- // Se o store expõe __getRawRow, verificamos se o hash de senha está vazio.
131
- const row = await cfg.accountStore.__getRawRow?.(accountId);
132
- if (row) {
133
- const pw = row.password;
134
- // Hash vazio ou nulo → passwordless.
135
- if (!pw || pw === '')
136
- return true;
137
- }
138
- return false;
139
- }
140
- catch {
141
- return false;
142
- }
143
55
  }
144
56
  }
@@ -3,7 +3,7 @@ import { ACCOUNT_SESSION_KEY } from "../middleware/account_auth.js";
3
3
  import { supportsAccountSecurity, supportsAccountDeletion, supportsProfile, supportsPasswordHistory, } from "../../accounts/account_store.js";
4
4
  import { changePasswordValidator, changeEmailValidator, deleteAccountValidator, updateProfileValidator, } from "../validators.js";
5
5
  import { sendEmailChangeConfirmationEmail, sendEmailChangeNoticeEmail, sendEmailChangedCompletedEmail, } from "../default_mailer.js";
6
- import { storeAvatar, isDriveAvailable, AvatarUploadError, } from "../avatar_storage.js";
6
+ import { storeAvatar, isAvatarUploadSupported, AvatarUploadError, } from "../avatar_storage.js";
7
7
  import { translate } from "../i18n.js";
8
8
  import { TRUSTED_DEVICE_COOKIE } from "../trusted_device.js";
9
9
  import { AccountDeletionService } from "../account_deletion_service.js";
@@ -67,8 +67,8 @@ export default class AccountSecurityController {
67
67
  csrfToken: ctx.request.csrfToken,
68
68
  supported: supportsAccountSecurity(cfg.accountStore),
69
69
  profileSupported: supportsProfile(cfg.accountStore),
70
- // Só mostramos o input de arquivo se o drive do app estiver disponível.
71
- avatarUploadSupported: await isDriveAvailable(),
70
+ // Só mostramos o input de arquivo se ALGUM backend (drive OU media) puder armazenar.
71
+ avatarUploadSupported: await isAvatarUploadSupported(cfg.uploads),
72
72
  email: account?.email ?? "",
73
73
  name: account?.name ?? "",
74
74
  avatarUrl: account?.avatarUrl ?? "",
@@ -108,10 +108,26 @@ export default class AccountSessionController {
108
108
  const cfg = service.config;
109
109
  // M6: não basta `forget(ACCOUNT_SESSION_KEY)` — sobravam na sessão
110
110
  // `authkit_sudo_at` (sudo), `authkit_last_seen` e qualquer outro estado
111
- // sensível, com o session id INALTERADO. Regenerar a sessão troca o id E
112
- // descarta todos os dados antigos (sudo/last-seen inclusos), destruindo de
113
- // fato a sessão de privilégio. Mantemos o `forget` explícito por garantia
114
- // (belt-and-braces) caso o store de sessão não suporte regenerate.
111
+ // sensível, com o session id INALTERADO. Regenerar troca o id do cookie,
112
+ // o que invalida o identificador antigo.
113
+ //
114
+ // ATENÇÃO ao que `regenerate()` NÃO faz: ele NÃO descarta os dados. O
115
+ // AdonisJS MIGRA o conteúdo da sessão para o id novo (é o mesmo
116
+ // comportamento descrito no M5 do login, e é por isso que o estado de
117
+ // pré-login sobrevive lá). Quem apaga estado aqui é o `forget` explícito —
118
+ // não o `regenerate`.
119
+ //
120
+ // Consequência de projeto: TODO dado sensível guardado na sessão precisa
121
+ // ser VINCULADO À CONTA que o gravou (ex.: `accountId` no pendente do
122
+ // magic link de sudo, na vinculação do challenge de passkey e na marca de
123
+ // sudo — `SUDO_ACCOUNT_SESSION_KEY`). Confiar que ele "some no logout" é
124
+ // falso — num navegador compartilhado ele sobrevive ao logout de A e ao
125
+ // login de B.
126
+ //
127
+ // A marca de sudo era a EXCEÇÃO à própria regra declarada aqui: só o
128
+ // timestamp, sem dono. Passou a ser vinculada — por isso este `forget`
129
+ // continua sendo só do `ACCOUNT_SESSION_KEY`: trocada a conta, a marca de
130
+ // sudo remanescente já não vale para ninguém.
115
131
  ctx.session.forget(ACCOUNT_SESSION_KEY);
116
132
  await ctx.session.regenerate();
117
133
  // Opt-in: espelha o logout no guard de @adonisjs/auth (ver adonisAuth em define_config.ts).
@@ -0,0 +1,22 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * GET /authkit/assets/webauthn.js
4
+ *
5
+ * Serve o `@simplewebauthn/browser` a partir do próprio host, substituindo o
6
+ * import de `cdn.jsdelivr.net` que as views de login/MFA/confirm faziam.
7
+ *
8
+ * SEM AUTENTICAÇÃO, e é intencional: é asset estático necessário na tela de
9
+ * login, ou seja, antes de existir qualquer sessão. Também não pode viver sob
10
+ * o prefixo do console admin (que é opt-in) — `login.edge` e
11
+ * `mfa-challenge.edge` precisam do script mesmo num host sem console.
12
+ */
13
+ export default class WebauthnAssetController {
14
+ handle(ctx: HttpContext): Promise<void>;
15
+ }
16
+ /**
17
+ * Limpa o cache do bundle. Existe para os testes conseguirem exercitar tanto o
18
+ * caminho feliz quanto o 404 no mesmo processo.
19
+ *
20
+ * @internal
21
+ */
22
+ export declare function resetWebauthnAssetCache(): void;
@@ -0,0 +1,66 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ /**
3
+ * URL do bundle ESM do `@simplewebauthn/browser` empacotado por
4
+ * `scripts/build_webauthn.mjs`.
5
+ *
6
+ * ⚠️ RESOLVIDO VIA `import.meta.url`, NUNCA relativo ao cwd. Os apps que
7
+ * consomem este pacote rodam `pnpm deploy --legacy`, que remonta a árvore de
8
+ * `node_modules` num diretório novo — um caminho relativo ao cwd do host
9
+ * apontaria para o lugar errado e a rota daria 404 em produção. Ancorado no
10
+ * módulo, o caminho segue o pacote para onde quer que ele seja copiado.
11
+ *
12
+ * Funciona nos dois layouts porque o bundle é commitado em `src/host/assets/`
13
+ * e copiado para `build/src/host/assets/` pelo script `build`:
14
+ * dev → src/host/controllers/… → src/host/assets/webauthn.js
15
+ * build → build/src/host/controllers/… → build/src/host/assets/webauthn.js
16
+ */
17
+ const BUNDLE_URL = new URL('../assets/webauthn.js', import.meta.url);
18
+ /**
19
+ * Cache do conteúdo do bundle. É imutável por versão do pacote — ler do disco
20
+ * a cada request de tela de login não compra nada.
21
+ *
22
+ * `null` = ainda não lido. `false` = lido e ausente (404 memoizado); sem isso
23
+ * um bundle faltando viraria um `readFile` que falha por request.
24
+ */
25
+ let cached = null;
26
+ /**
27
+ * GET /authkit/assets/webauthn.js
28
+ *
29
+ * Serve o `@simplewebauthn/browser` a partir do próprio host, substituindo o
30
+ * import de `cdn.jsdelivr.net` que as views de login/MFA/confirm faziam.
31
+ *
32
+ * SEM AUTENTICAÇÃO, e é intencional: é asset estático necessário na tela de
33
+ * login, ou seja, antes de existir qualquer sessão. Também não pode viver sob
34
+ * o prefixo do console admin (que é opt-in) — `login.edge` e
35
+ * `mfa-challenge.edge` precisam do script mesmo num host sem console.
36
+ */
37
+ export default class WebauthnAssetController {
38
+ async handle(ctx) {
39
+ if (cached === null) {
40
+ try {
41
+ cached = await readFile(BUNDLE_URL);
42
+ }
43
+ catch {
44
+ cached = false;
45
+ }
46
+ }
47
+ if (cached === false) {
48
+ // 404 limpo: o bundle não foi gerado (`node scripts/build_webauthn.mjs`).
49
+ // A tela degrada para os demais fatores em vez de estourar 500.
50
+ return ctx.response.notFound();
51
+ }
52
+ return ctx.response
53
+ .type('text/javascript')
54
+ .header('Cache-Control', 'public, max-age=31536000, immutable')
55
+ .send(cached);
56
+ }
57
+ }
58
+ /**
59
+ * Limpa o cache do bundle. Existe para os testes conseguirem exercitar tanto o
60
+ * caminho feliz quanto o 404 no mesmo processo.
61
+ *
62
+ * @internal
63
+ */
64
+ export function resetWebauthnAssetCache() {
65
+ cached = null;
66
+ }
@@ -75,7 +75,7 @@ export function defineAccountDeletionWorkflow(deps) {
75
75
  result.orgMemberships = orgResult.orgMemberships;
76
76
  result.orgInvitations = orgResult.orgInvitations;
77
77
  // 7) Avatar no drive.
78
- result.avatarDeleted = (await ctx.step("delete.avatar", async () => deleteAccountAvatar((await deps.oidc()).config, snapshot.avatarUrl))).avatarDeleted;
78
+ result.avatarDeleted = (await ctx.step("delete.avatar", async () => deleteAccountAvatar((await deps.oidc()).config, accountId, snapshot.avatarUrl))).avatarDeleted;
79
79
  // 8) Anonimiza o histórico de audit.
80
80
  result.auditAnonymized = (await ctx.step("anonymize.audit", async () => anonymizeAudit((await deps.oidc()).config, accountId))).auditAnonymized;
81
81
  // 9) Deleta a linha da conta (ÚLTIMA etapa, forward-only).
@@ -560,6 +560,13 @@ export declare const DEFAULT_MESSAGES: {
560
560
  "account.confirm.error": string;
561
561
  "account.confirm.passkey_error": string;
562
562
  "account.confirm.passwordless_notice": string;
563
+ "account.confirm.method.password": string;
564
+ "account.confirm.method.passkey": string;
565
+ "account.confirm.method.magic_link": string;
566
+ "account.confirm.method.oidc_step_up": string;
567
+ "account.confirm.magic_link_sent": string;
568
+ "account.confirm.no_methods": string;
569
+ "account.confirm.preferred_badge": string;
563
570
  "admin.settings.sudo_mode_section": string;
564
571
  "admin.settings.sudo_mode_intro": string;
565
572
  "admin.settings.sudo_mode_from_config": string;
@@ -1242,6 +1249,13 @@ export declare const PT_BR_MESSAGES: {
1242
1249
  "account.confirm.error": string;
1243
1250
  "account.confirm.passkey_error": string;
1244
1251
  "account.confirm.passwordless_notice": string;
1252
+ "account.confirm.method.password": string;
1253
+ "account.confirm.method.passkey": string;
1254
+ "account.confirm.method.magic_link": string;
1255
+ "account.confirm.method.oidc_step_up": string;
1256
+ "account.confirm.magic_link_sent": string;
1257
+ "account.confirm.no_methods": string;
1258
+ "account.confirm.preferred_badge": string;
1245
1259
  "admin.settings.sudo_mode_section": string;
1246
1260
  "admin.settings.sudo_mode_intro": string;
1247
1261
  "admin.settings.sudo_mode_from_config": string;
@@ -605,6 +605,14 @@ export const DEFAULT_MESSAGES = {
605
605
  "account.confirm.error": "Incorrect password.",
606
606
  "account.confirm.passkey_error": "Could not authenticate with the passkey. Please try again.",
607
607
  "account.confirm.passwordless_notice": "This account does not have a password. Please add a passkey to use sudo-protected features.",
608
+ // Rótulos dos métodos do SPI de sudo (account/confirm.edge, um bloco por método disponível).
609
+ "account.confirm.method.password": "Confirm with your password",
610
+ "account.confirm.method.passkey": "Confirm with a passkey",
611
+ "account.confirm.method.magic_link": "Email me a confirmation link",
612
+ "account.confirm.method.oidc_step_up": "Sign in again to confirm",
613
+ "account.confirm.magic_link_sent": "We sent a confirmation link to your email. It expires in 5 minutes.",
614
+ "account.confirm.no_methods": "No confirmation method is available for this account. Contact support.",
615
+ "account.confirm.preferred_badge": "Used last time",
608
616
  // Admin settings — sudo_mode card.
609
617
  "admin.settings.sudo_mode_section": "Sudo mode (identity confirmation)",
610
618
  "admin.settings.sudo_mode_intro": "When enabled, sensitive actions (password change, email change, account deletion, MFA/passkey management, PAT creation/revocation) require the user to confirm their password. The confirmation is valid for a configurable grace period.",
@@ -1359,6 +1367,14 @@ export const PT_BR_MESSAGES = {
1359
1367
  "account.confirm.error": "Senha incorreta.",
1360
1368
  "account.confirm.passkey_error": "Não foi possível autenticar com a passkey. Tente novamente.",
1361
1369
  "account.confirm.passwordless_notice": "Esta conta não possui senha. Adicione uma passkey para usar funcionalidades protegidas.",
1370
+ // Rótulos dos métodos do SPI de sudo (account/confirm.edge, um bloco por método disponível).
1371
+ "account.confirm.method.password": "Confirmar com a senha",
1372
+ "account.confirm.method.passkey": "Confirmar com passkey",
1373
+ "account.confirm.method.magic_link": "Receber link de confirmação por e-mail",
1374
+ "account.confirm.method.oidc_step_up": "Entrar de novo para confirmar",
1375
+ "account.confirm.magic_link_sent": "Enviamos um link de confirmação para o seu e-mail. Ele expira em 5 minutos.",
1376
+ "account.confirm.no_methods": "Nenhum método de confirmação está disponível para esta conta. Fale com o suporte.",
1377
+ "account.confirm.preferred_badge": "Usado da última vez",
1362
1378
  // Admin settings — sudo_mode card (pt-BR).
1363
1379
  "admin.settings.sudo_mode_section": "Modo sudo (confirmação de identidade)",
1364
1380
  "admin.settings.sudo_mode_intro": "Quando habilitado, ações sensíveis (troca de senha, troca de e-mail, exclusão de conta, gerência de MFA/passkey, criação/revogação de PAT) exigem que o usuário confirme sua senha. A confirmação é válida por um período de graça configurável.",
@@ -98,6 +98,14 @@ export async function startImpersonation(ctx, params) {
98
98
  // Anti-fixation: rotaciona o id da sessão (mantém os dados) antes de gravar a
99
99
  // nova identidade. Mesmo padrão do consumidor real no RP.
100
100
  await ctx.session.regenerate();
101
+ // ESCALAÇÃO DE PRIVILÉGIO (fechada por vinculação): trocar a conta aqui NÃO
102
+ // pode carregar junto o sudo que o admin confirmou sobre a PRÓPRIA conta —
103
+ // senão ele entraria personificando já com a graça aberta sobre a conta
104
+ // alheia (exportar/excluir dados, MFA, PATs). Não limpamos a marca aqui: ela
105
+ // é vinculada à conta que a confirmou (`SUDO_ACCOUNT_SESSION_KEY`), então
106
+ // `isSudoActive` a recusa sozinho assim que `ACCOUNT_SESSION_KEY` muda. A
107
+ // garantia é estrutural — vale para qualquer troca de conta futura, sem
108
+ // depender de um `forget` lembrado em cada nova transição.
101
109
  ctx.session.put(IMPERSONATOR_SESSION_KEY, impersonatorId);
102
110
  ctx.session.put(ACCOUNT_SESSION_KEY, params.targetId);
103
111
  }
@@ -122,6 +130,13 @@ export async function stopImpersonation(ctx) {
122
130
  if (!impersonatorId)
123
131
  return;
124
132
  await ctx.session.regenerate();
133
+ // Simétrico ao `startImpersonation`: o sudo obtido ENQUANTO personificava
134
+ // ficaria valendo sobre a conta do admin ao voltar. A vinculação corta isso —
135
+ // aquela marca aponta para a conta personificada e morre com a volta.
136
+ //
137
+ // Nota: se o admin tinha sudo sobre a própria conta ANTES de personificar e a
138
+ // graça ainda não venceu, ele volta valendo. Correto e intencional: é a
139
+ // confirmação dele, sobre a conta dele, dentro da janela dele.
125
140
  ctx.session.put(ACCOUNT_SESSION_KEY, impersonatorId);
126
141
  ctx.session.forget(IMPERSONATOR_SESSION_KEY);
127
142
  ctx.session.forget(ADMIN_ACCESS_TOKEN_SESSION_KEY);
@@ -20,6 +20,16 @@ export interface AuthThrottles {
20
20
  * de tentativas vindas do MESMO IP independentemente de qual key foi usada (M8).
21
21
  */
22
22
  adminIp: ThrottleMiddleware;
23
+ /**
24
+ * Throttle das rotas dos métodos de sudo (`/account/confirm/*`), keyed por IP
25
+ * como o `login` — e com os MESMOS limites — mas em bucket PRÓPRIO.
26
+ *
27
+ * A separação é o ponto: `login` mede um anônimo tentando adivinhar
28
+ * credenciais, `sudo` mede um usuário já autenticado reprovando a própria
29
+ * identidade. Ver `ResolvedRateLimitConfig.sudo` para o porquê de os dois
30
+ * orçamentos não poderem se consumir.
31
+ */
32
+ sudo: ThrottleMiddleware;
23
33
  }
24
34
  /**
25
35
  * Service do `@adonisjs/limiter` resolvido de forma preguiçosa. Tipado como `any`
@@ -90,5 +90,11 @@ export function createAuthThrottles(config) {
90
90
  adminIp: buildThrottle('authkit_admin_ip', config.adminIp, config.store, (ctx) => {
91
91
  return `admin-ip:${ctx.request.ip?.() ?? 'unknown'}`;
92
92
  }),
93
+ // Rotas dos métodos de sudo: keyed por IP (default do HttpLimiter), igual ao
94
+ // login. O que separa os dois é o NOME do bucket — o limiter namespaceia a
95
+ // contagem por nome, então `authkit_login` e `authkit_sudo` nunca somam,
96
+ // mesmo vindo do mesmo IP. Sem `usingKey` próprio de propósito: inventar uma
97
+ // key aqui seria mudar o EIXO da contagem, e o eixo certo continua sendo o IP.
98
+ sudo: buildThrottle('authkit_sudo', config.sudo, config.store),
93
99
  };
94
100
  }
@@ -1,5 +1,6 @@
1
1
  import type { Router } from '@adonisjs/core/http';
2
2
  import type { AuthSocialConfig, RateLimitConfigInput } from '../define_config.js';
3
+ import type { SudoMethod } from './sudo/types.js';
3
4
  /** Chave da sessão Adonis que registra o timestamp da última atividade (idle timeout). */
4
5
  export declare const ACCOUNT_LAST_SEEN_KEY = "authkit_last_seen";
5
6
  /**
@@ -89,6 +90,24 @@ export interface AuthHostOptions {
89
90
  adminApi?: boolean | {
90
91
  prefix?: string;
91
92
  };
93
+ /**
94
+ * Métodos de sudo cujas rotas devem ser montadas. Necessário aqui (e não só
95
+ * no config) porque a decisão de MONTAR rotas acontece em tempo de registro,
96
+ * antes de o config lazy resolver — mesma razão de `social`/`admin`/`rateLimit`.
97
+ * Espelhe o `sudo.methods` de config/authkit.ts.
98
+ *
99
+ * SUBSTITUI os defaults, não acrescenta: a lista é do host. Quem quer manter
100
+ * senha/passkey ao lado do método novo os inclui explicitamente
101
+ * (`[sudoMethods.password(), sudoMethods.passkey(), meuMetodo()]`).
102
+ *
103
+ * Sem esta opção, `config.sudo.methods` conseguiria OFERECER um método na
104
+ * tela mas nunca montar seu endpoint — a opção aparece e dá 404. Falha
105
+ * fechada, mas é a promessa do SPI pela metade; `magicLink()` em particular
106
+ * não teria como ser alcançado em runtime.
107
+ *
108
+ * Ausente → `[password(), passkey()]`.
109
+ */
110
+ sudoMethods?: SudoMethod[];
92
111
  }
93
112
  /**
94
113
  * Monta todas as rotas do host-kit do Authorization Server numa chamada.
@@ -8,6 +8,19 @@ import { setAdminPrefix, normalizeAdminPrefix, setAdminApiPrefix, normalizeAdmin
8
8
  import { resolveRuntimeSettings } from './runtime_settings.js';
9
9
  import { resolveEffectiveSessionPolicy } from './runtime_toggles.js';
10
10
  import { getAuthHostConfig } from './auth_host_config.js';
11
+ import { completeSudo, fail, guardSudoRoutes, sudoContextFrom, setMountedSudoMethods, } from './sudo/runtime.js';
12
+ import { password as sudoPassword } from './sudo/methods/password.js';
13
+ import { passkey as sudoPasskey } from './sudo/methods/passkey.js';
14
+ /**
15
+ * Métodos de sudo montados quando o host não passa `sudoMethods` —
16
+ * comportamento histórico (senha + passkey).
17
+ *
18
+ * PONTO ÚNICO. A tela não tem mais uma cópia desta lista: sem
19
+ * `config.sudo.methods`, `configuredSudoMethods` cai no que FOI MONTADO, ou
20
+ * seja, no resultado do `??` abaixo. Duas listas de default é como os dois
21
+ * lados divergiam.
22
+ */
23
+ const SUDO_METHOD_DEFAULTS = [sudoPassword(), sudoPasskey()];
11
24
  /** Chave da sessão Adonis que registra o timestamp da última atividade (idle timeout). */
12
25
  export const ACCOUNT_LAST_SEEN_KEY = 'authkit_last_seen';
13
26
  /**
@@ -138,6 +151,7 @@ const C = {
138
151
  accountMfa: () => import('./controllers/account_mfa_controller.js'),
139
152
  accountOrgs: () => import('./controllers/account_orgs_controller.js'),
140
153
  accountConfirm: () => import('./controllers/account_confirm_controller.js'),
154
+ webauthnAsset: () => import('./controllers/webauthn_asset_controller.js'),
141
155
  // Console React JSON API (session-authed, under {prefix}/api/*).
142
156
  consoleShell: () => import('./admin_console/admin_shell_controller.js'),
143
157
  consoleOverview: () => import('./admin_console/console_overview_controller.js'),
@@ -188,6 +202,25 @@ export function registerAuthHost(router, opts = {}) {
188
202
  if (throttles)
189
203
  route.use([throttles.introspection]);
190
204
  };
205
+ // Bucket PRÓPRIO das rotas de sudo. Antes elas levavam o `withLogin`, o que
206
+ // funcionava mas somava dois orçamentos que medem coisas diferentes: login é
207
+ // um anônimo adivinhando credenciais, sudo é um usuário JÁ autenticado
208
+ // reprovando a própria identidade. Ver `ResolvedRateLimitConfig.sudo`.
209
+ const withSudo = (route) => {
210
+ if (throttles)
211
+ route.use([throttles.sudo]);
212
+ };
213
+ // ─── Assets estáticos do host-kit (públicos, sem autenticação) ─────────────
214
+ // Bundle do @simplewebauthn/browser servido pelo próprio app, no lugar do
215
+ // import de CDN público que as views de login/MFA/confirm faziam.
216
+ //
217
+ // Path FIXO e no topo, de propósito:
218
+ // • não pode viver sob o prefixo do console admin (`admin` é opt-in) —
219
+ // login.edge e mfa-challenge.edge precisam do script em qualquer host;
220
+ // • sem guard, porque é carregado NA tela de login, antes de haver sessão;
221
+ // • registrado ANTES do wildcard `${mount}/*` para que nenhum mountPath
222
+ // agressivo (ex.: '/') consiga engolir o asset e quebrar o login.
223
+ router.get('/authkit/assets/webauthn.js', [C.webauthnAsset]).as('authkit.assets.webauthn');
191
224
  // Provider OIDC (wildcard + root) — o que registerOidcRoutes fazia.
192
225
  router.any(`${mount}/*`, [C.oidc]).as('authkit.oidc.wildcard');
193
226
  router.any(mount, [C.oidc]).as('authkit.oidc.root');
@@ -269,11 +302,46 @@ export function registerAuthHost(router, opts = {}) {
269
302
  router.post('/account/mfa/passkeys/options', [C.accountMfa, 'passkeyRegisterOptions']);
270
303
  router.post('/account/mfa/passkeys/verify', [C.accountMfa, 'passkeyRegisterVerify']);
271
304
  router.post('/account/mfa/passkeys/:id/remove', [C.accountMfa, 'passkeyRemove']);
272
- // Sudo mode (confirm identity): GET exibe o formulário; POST verifica a senha.
305
+ // Sudo mode (confirm identity): o GET lista os métodos; cada método
306
+ // registra suas próprias rotas de verificação (SPI `SudoMethod`).
273
307
  router.get('/account/confirm', [C.accountConfirm, 'show']);
274
- router.post('/account/confirm', [C.accountConfirm, 'confirm']);
275
- router.post('/account/confirm/passkey/options', [C.accountConfirm, 'passkeyOptions']);
276
- router.post('/account/confirm/passkey', [C.accountConfirm, 'passkeyConfirm']);
308
+ // Rotas próprias dos métodos de sudo — DENTRO do grupo com `accountGuard`.
309
+ // O guard não é só "tem sessão": ele roda `checkAndRefreshIdle`, que apaga
310
+ // a sessão vencida por idle e refresca `authkit_last_seen`. Fora do grupo,
311
+ // uma sessão já vencida (ainda não colhida) podia postar a senha correta e
312
+ // receber `markSudo` — e as rotas de sudo não refrescavam o last-seen.
313
+ // Nenhum método built-in é alcançável por GET vindo de e-mail: o token de
314
+ // sudo por magic link vive na PRÓPRIA sessão, então o usuário precisa
315
+ // estar logado no mesmo navegador de qualquer forma. Um método que
316
+ // genuinamente não puder ficar sob o guard precisa de uma decisão
317
+ // explícita, não de mover todos para fora.
318
+ const helpers = {
319
+ contextFrom: sudoContextFrom,
320
+ completeSudo,
321
+ fail,
322
+ };
323
+ const sudoMethodsToMount = opts?.sudoMethods ?? SUDO_METHOD_DEFAULTS;
324
+ for (const method of sudoMethodsToMount) {
325
+ // `guardSudoRoutes` embrulha os handlers que o método registrar, para
326
+ // que `config.sudo.methods` os desabilite de fato mesmo que o método
327
+ // não tenha checado nada por dentro. Ver o docblock lá.
328
+ //
329
+ // `withSudo` vai junto: TODA rota de um método de sudo leva o throttle
330
+ // do bucket de SUDO (no-op sem rate-limit). Não é adorno — o POST que
331
+ // emite o magic link de sudo dispara um e-mail por chamada, e o
332
+ // `accountGuard` sozinho só exige uma sessão viva, que o abusador tem.
333
+ // Aplicar aqui, no wrapper, cobre também os métodos customizados, que
334
+ // não teriam como pedir throttle pelo `SudoRouteHelpers`.
335
+ //
336
+ // Bucket próprio, não o de login: mesmos limites, contagem separada —
337
+ // errar a senha na tela de confirmação não pode gastar o orçamento de
338
+ // login do IP, nem vice-versa.
339
+ method.register?.(guardSudoRoutes(router, method.id, helpers, withSudo), helpers);
340
+ }
341
+ // A lista montada é a fonte de verdade dos DOIS lados quando o host não
342
+ // configura `config.sudo.methods`: a tela oferece exatamente isto, e os
343
+ // handlers aceitam exatamente isto.
344
+ setMountedSudoMethods(sudoMethodsToMount);
277
345
  // Organizations (multi-tenancy) — sempre montadas; controller retorna 404/403 sem tabelas.
278
346
  router.get('/account/orgs', [C.accountOrgs, 'index']);
279
347
  router.post('/account/orgs', [C.accountOrgs, 'store']);