@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
@@ -0,0 +1,93 @@
1
+ import { isSudoMethodEnabled } from '../runtime.js';
2
+ /**
3
+ * Confirmação por senha — o método histórico.
4
+ *
5
+ * URL LEGADA: registra `POST /account/confirm` (não `/account/confirm/password`)
6
+ * porque hosts externos e telas customizadas (fora deste pacote) dependem
7
+ * desse path histórico — trocá-lo quebraria essas integrações sem que o
8
+ * pacote tenha como saber ou migrar por elas. É também a razão de `register`
9
+ * receber o router cru em vez de o runtime montar por convenção a partir do
10
+ * `id`.
11
+ *
12
+ * LIMITAÇÃO CONHECIDA de `isAvailable`: ele responde "a conta tem hash?", não
13
+ * "o usuário conhece a senha?". Host que cria contas passwordless gravando um
14
+ * hash aleatório para satisfazer uma coluna NOT NULL verá este método como
15
+ * disponível e mostrará um campo que ninguém consegue preencher. De dentro do
16
+ * pacote, hash aleatório e hash real são indistinguíveis; a correção é do host
17
+ * (coluna nullable) ou basta omitir `password()` da lista de `methods`.
18
+ */
19
+ export function password() {
20
+ return {
21
+ id: 'password',
22
+ /**
23
+ * Fallback conservador herdado de `isPasswordless` (o original em
24
+ * `account_confirm_controller.ts`): sem informação, assumimos que a conta
25
+ * TEM senha.
26
+ *
27
+ * `__getRawRow` não faz parte do contrato público de `AccountStore` — é um
28
+ * escape hatch interno do store Lucid (mesmo cast usado em outros pontos do
29
+ * pacote, ex.: account_security_controller.ts). Um `AccountStore` do SPI
30
+ * (o público-alvo deste método) não é obrigado a implementá-lo. Se
31
+ * tratássemos "não sei" como indisponível, um store customizado sem esse
32
+ * escape hatch perderia o método `password` mesmo com `verifyCredentials`
33
+ * funcionando — e, sem passkey também, o usuário fica sem nenhum método e
34
+ * travado fora do sudo mode. Por isso os três casos abaixo:
35
+ *
36
+ * - `__getRawRow` ausente (`undefined`) → disponível (não sabemos, não escondemos).
37
+ * - Presente e devolve hash não-vazio → disponível.
38
+ * - Presente e devolve hash vazio/nulo (ou linha nula) → indisponível (sabemos que não há senha).
39
+ * - Lança exceção → disponível (mesmo espírito conservador: não sabemos).
40
+ *
41
+ * Esconder o método é sempre pior que mostrá-lo: uma opção visível que
42
+ * falha é recuperável (o usuário tenta e escolhe outra); uma opção
43
+ * escondida não é nem descobrível.
44
+ */
45
+ async isAvailable(c) {
46
+ const getRawRow = c.cfg.accountStore.__getRawRow;
47
+ if (typeof getRawRow !== 'function')
48
+ return true;
49
+ try {
50
+ const row = await getRawRow.call(c.cfg.accountStore, c.accountId);
51
+ // Store expõe a função e respondeu (linha nula ou hash vazio/nulo): sabemos
52
+ // que não há senha → indisponível.
53
+ return Boolean(row?.password);
54
+ }
55
+ catch {
56
+ return true;
57
+ }
58
+ },
59
+ async describe() {
60
+ return {
61
+ labelKey: 'account.confirm.method.password',
62
+ kind: 'form',
63
+ endpoint: '/account/confirm',
64
+ fields: [
65
+ { name: 'password', type: 'password', labelKey: 'account.confirm.password_label' },
66
+ ],
67
+ };
68
+ },
69
+ register(router, h) {
70
+ router.post('/account/confirm', async (ctx) => {
71
+ const c = await h.contextFrom(ctx);
72
+ // ANTES de qualquer verificação: o host desligou este método?
73
+ // A rota é montada incondicionalmente (decisão de tempo de registro),
74
+ // então `config.sudo.methods` só desabilita de fato se o handler
75
+ // recusar. Responde `fail` — o mesmo redirect+flash de uma senha
76
+ // errada — em vez de 404: assim a resposta não distingue "método
77
+ // desligado" de "senha incorreta" e não vaza a config do host.
78
+ if (!isSudoMethodEnabled(c.cfg, 'password'))
79
+ return h.fail(c, 'account.confirm.error');
80
+ const { password: submitted } = ctx.request.only(['password']);
81
+ // `c.account` é nullable (sessão viva de conta apagada → findById null)
82
+ // e `email` pode vir vazio de um store customizado; sem e-mail não há
83
+ // como verificar credenciais.
84
+ if (!submitted || !c.account || !c.account.email)
85
+ return h.fail(c, 'account.confirm.error');
86
+ const ok = await c.cfg.accountStore.verifyCredentials(c.account.email, submitted);
87
+ if (!ok)
88
+ return h.fail(c, 'account.confirm.error');
89
+ return h.completeSudo(c, 'password');
90
+ });
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,141 @@
1
+ import type { HttpContext, Router } from '@adonisjs/core/http';
2
+ import type { ResolvedServerConfig } from '../../define_config.js';
3
+ import type { SudoContext, SudoMethod, SudoRouteHelpers } from './types.js';
4
+ /** Último método usado com sucesso — só ordena a tela, não restringe nada. */
5
+ export declare const LAST_METHOD_SESSION_KEY = "authkit_sudo_last_method";
6
+ /**
7
+ * Monta o `SudoContext` a partir do `HttpContext`.
8
+ *
9
+ * Mora AQUI, e não no controller, porque é o construtor canônico do contexto
10
+ * que `completeSudo`/`fail` recebem e que todo `SudoMethod` usa — é runtime do
11
+ * SPI, não detalhe da tela. Enquanto morava em `controllers/`, o barrel do SPI
12
+ * reexportava de lá só para esconder isso, e o ciclo
13
+ * `runtime → controller → runtime` era o que obrigava `configuredSudoMethods` a
14
+ * viver longe de `isSudoMethodEnabled` — origem do drift entre os dois lados.
15
+ */
16
+ export declare function sudoContextFrom(ctx: HttpContext): Promise<SudoContext>;
17
+ /**
18
+ * Registra a lista montada. Chamado UMA vez por `registerAuthHost`, e
19
+ * SUBSTITUI (não acumula): registrar o host de novo é redefinir o que existe,
20
+ * não somar ao que existia.
21
+ */
22
+ export declare function setMountedSudoMethods(methods: SudoMethod[]): void;
23
+ /** Um método com este id teve rotas montadas? Usado só para avisar de drift. */
24
+ export declare function isSudoMethodMounted(methodId: string): boolean;
25
+ /**
26
+ * Lista de métodos que o host configurou EXPLICITAMENTE, ou `null` quando ele
27
+ * não configurou nada (ausente ou vazio → "não restringi").
28
+ *
29
+ * Ponto ÚNICO de leitura de `config.sudo.methods`. Existe aqui (e não no
30
+ * controller) porque quem precisa dela são os dois lados — a tela, que decide o
31
+ * que OFERECER, e os handlers dos métodos, que decidem o que ACEITAR — e o
32
+ * controller já importa os métodos built-in, o que tornaria a dependência
33
+ * circular se os métodos importassem de volta o controller.
34
+ */
35
+ export declare function explicitSudoMethods(cfg: ResolvedServerConfig): SudoMethod[] | null;
36
+ /**
37
+ * O método `methodId` está habilitado para ESTE host?
38
+ *
39
+ * Toda rota registrada por um `SudoMethod` DEVE começar por aqui. Sem essa
40
+ * checagem, `config.sudo.methods` só esconderia o método da tela: o endpoint
41
+ * continuaria vivo e concedendo sudo — uma config que aparenta restringir e não
42
+ * restringe é pior que nenhuma config.
43
+ *
44
+ * Sem configuração explícita nada foi restringido: vale o que tem rota montada.
45
+ * Isso é deliberado — a lista de defaults não é a fonte de verdade do que está
46
+ * montado, e tratá-la como tal derrubaria um método customizado do host.
47
+ */
48
+ export declare function isSudoMethodEnabled(cfg: ResolvedServerConfig, methodId: string): boolean;
49
+ /**
50
+ * Lista efetiva de métodos da TELA — o irmão de `isSudoMethodEnabled`, e por
51
+ * isso mora coladinho nele.
52
+ *
53
+ * Sem config explícita cai na lista MONTADA, exatamente a mesma resposta que
54
+ * `isSudoMethodEnabled` dá do lado dos handlers ("vale o que tem rota"). Antes
55
+ * caía numa lista de defaults hardcoded, e os dois lados divergiam de verdade:
56
+ * um host que fizesse só
57
+ *
58
+ * ```ts
59
+ * registerAuthHost(router, { sudoMethods: [sudoMethods.magicLink()] })
60
+ * ```
61
+ *
62
+ * montava só magic-link, mas a tela oferecia password + passkey (ambos 404) e
63
+ * NÃO oferecia magic-link, que funcionava. Caindo na lista montada, o drift
64
+ * fica estruturalmente impossível no caso sem config: é literalmente a mesma
65
+ * lista. O aviso de flag-drift do controller passa a valer só para o caso que
66
+ * sobra — config explícita divergindo do que foi montado.
67
+ */
68
+ export declare function configuredSudoMethods(cfg: ResolvedServerConfig): SudoMethod[];
69
+ /**
70
+ * Envelopa o router entregue a `method.register` para que TODO handler que ele
71
+ * registrar seja barrado quando `config.sudo.methods` não incluir o método.
72
+ *
73
+ * POR QUE NO PONTO DE REGISTRO, e não dentro de cada handler: a barreira é a
74
+ * diferença entre uma config que restringe e uma que só esconde a opção da tela
75
+ * enquanto o endpoint segue concedendo sudo (falha Critical). Deixá-la a cargo
76
+ * de quem escreve o método significa que o PRIMEIRO método que esquecer a
77
+ * chamada reabre a falha — e nada detecta. Aqui a garantia é estrutural: o
78
+ * método não tem como registrar uma rota desguardada, porque não é ele quem
79
+ * segura o router.
80
+ *
81
+ * Os built-in CONTINUAM checando por dentro, e isso não é redundância inútil:
82
+ * cada um recusa na forma que o seu endpoint exige (o `passkey/options` é XHR e
83
+ * devolve JSON 404; um 302 para HTML quebraria o cliente). Este envelope é o
84
+ * piso genérico — recusa com `fail`, o mesmo redirect+flash de um erro comum,
85
+ * que não distingue "método desligado" de "credencial errada" e portanto não
86
+ * vaza a config do host.
87
+ *
88
+ * O QUE NÃO CABE NA BARREIRA É RECUSADO, não tolerado: um handler que não seja
89
+ * função (tupla `[Controller, 'metodo']`) e os atalhos `resource()`/
90
+ * `shallowResource()` lançam no ponto de registro. Ver
91
+ * `ROUTER_CONTROLLER_SHORTCUTS` e o `assertWrappableHandler` abaixo.
92
+ * (`on()` continua passando: ele registra redirect/render estático, sem handler
93
+ * que possa alcançar `completeSudo`.)
94
+ *
95
+ * CUSTO: resolve o config do container antes do handler. Não usa
96
+ * `contextFrom` no caminho feliz de propósito — aquilo faz `findById`, e pagar
97
+ * uma leitura de conta a mais em toda rota de sudo para uma checagem que só lê
98
+ * a config seria desperdício. O contexto completo só é montado para recusar.
99
+ */
100
+ export declare function guardSudoRoutes(router: Router, methodId: string, h: SudoRouteHelpers,
101
+ /**
102
+ * Aplica o throttle do host a CADA rota que o método registrar (no-op quando
103
+ * o rate-limit está desligado). É `registerAuthHost` quem passa isto, porque
104
+ * é lá que os throttles existem — e é aqui que a rota nasce.
105
+ *
106
+ * O throttle que chega aqui é o do bucket de SUDO, não o de login. Mesmos
107
+ * limites, contagem separada: login mede um anônimo adivinhando credenciais,
108
+ * sudo mede um usuário JÁ autenticado reprovando a própria identidade.
109
+ * Compartilhando o bucket, quem erra a senha no `/account/confirm` gastava o
110
+ * orçamento de login do próprio IP — e um ataque de credencial no login
111
+ * trancava a confirmação de quem está legitimamente logado atrás do mesmo
112
+ * NAT. Ver `ResolvedRateLimitConfig.sudo`.
113
+ *
114
+ * Fica no wrapper, e não no contrato de `SudoRouteHelpers`, porque throttle
115
+ * não é decisão do método: um método que pudesse pedir throttle poderia
116
+ * também NÃO pedir, e o `POST` que emite o magic link de sudo — que dispara
117
+ * um e-mail por chamada — voltaria a ficar sem nenhum. Aqui a cobertura é da
118
+ * mesma natureza da barreira de `config.sudo.methods`: estrutural, sem
119
+ * depender de quem escreve o método lembrar de nada.
120
+ */
121
+ applyThrottle?: (route: unknown) => void): Router;
122
+ /**
123
+ * ÚNICO ponto do pacote que concede sudo. Nenhum `SudoMethod` chama
124
+ * `markSudo` diretamente: o método decide se verificou, o runtime concede,
125
+ * audita e redireciona.
126
+ */
127
+ export declare function completeSudo(c: SudoContext, methodId: string): Promise<unknown>;
128
+ /**
129
+ * Falha de confirmação: flash + volta pro /account/confirm preservando o
130
+ * destino. Substitui a coreografia que estava duplicada cinco vezes no
131
+ * controller.
132
+ */
133
+ export declare function fail(c: SudoContext, messageKey: string): Promise<unknown>;
134
+ /**
135
+ * Filtra os métodos disponíveis para esta conta e promove o último usado.
136
+ *
137
+ * `isAvailable` que lança NÃO derruba a tela: um método quebrado não pode
138
+ * trancar o usuário fora dos outros — mesmo espírito do FAIL-SAFE de
139
+ * `requireSudo`.
140
+ */
141
+ export declare function resolveAvailableMethods(c: SudoContext, methods: SudoMethod[]): Promise<SudoMethod[]>;
@@ -0,0 +1,327 @@
1
+ import { markSudo } from '../sudo_mode.js';
2
+ import { accountHome } from '../account_home.js';
3
+ import { translate } from '../i18n.js';
4
+ import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
5
+ import { validateReturnTo } from '../controllers/account_session_controller.js';
6
+ /** Último método usado com sucesso — só ordena a tela, não restringe nada. */
7
+ export const LAST_METHOD_SESSION_KEY = 'authkit_sudo_last_method';
8
+ /**
9
+ * Monta o `SudoContext` a partir do `HttpContext`.
10
+ *
11
+ * Mora AQUI, e não no controller, porque é o construtor canônico do contexto
12
+ * que `completeSudo`/`fail` recebem e que todo `SudoMethod` usa — é runtime do
13
+ * SPI, não detalhe da tela. Enquanto morava em `controllers/`, o barrel do SPI
14
+ * reexportava de lá só para esconder isso, e o ciclo
15
+ * `runtime → controller → runtime` era o que obrigava `configuredSudoMethods` a
16
+ * viver longe de `isSudoMethodEnabled` — origem do drift entre os dois lados.
17
+ */
18
+ export async function sudoContextFrom(ctx) {
19
+ const service = await ctx.containerResolver.make('authkit.server');
20
+ const cfg = service.config;
21
+ const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
22
+ const account = await cfg.accountStore.findById(accountId);
23
+ // PRECEDÊNCIA do return_to. Num GET a query string é a única fonte real. Num
24
+ // POST o alvo do redirect vem do campo hidden do form: deixar a query string
25
+ // vencer permitiria a um link `?return_to=...` sequestrar o destino de um form
26
+ // que o usuário já preencheu — e seria uma mudança silenciosa de um alvo de
27
+ // redirect em relação ao comportamento histórico (`request.input`, que no
28
+ // Adonis já dá precedência ao corpo). `validateReturnTo` roda nos dois casos.
29
+ const fromBody = ctx.request.input?.('return_to');
30
+ const fromQuery = ctx.request.qs?.()?.return_to;
31
+ const isPost = String(ctx.request.method?.() ?? '').toUpperCase() === 'POST';
32
+ const raw = isPost ? (fromBody ?? fromQuery) : (fromQuery ?? fromBody);
33
+ return { ctx, cfg, accountId, account, returnTo: validateReturnTo(raw) };
34
+ }
35
+ /**
36
+ * Métodos cujas rotas FORAM montadas por `registerAuthHost`, na ordem em que
37
+ * ele as montou. Guarda os OBJETOS, não só os ids, porque esta lista é também
38
+ * a lista efetiva da TELA quando o host não configura `config.sudo.methods` —
39
+ * ver `configuredSudoMethods`.
40
+ */
41
+ const mountedSudoMethods = [];
42
+ /**
43
+ * Registra a lista montada. Chamado UMA vez por `registerAuthHost`, e
44
+ * SUBSTITUI (não acumula): registrar o host de novo é redefinir o que existe,
45
+ * não somar ao que existia.
46
+ */
47
+ export function setMountedSudoMethods(methods) {
48
+ mountedSudoMethods.splice(0, mountedSudoMethods.length, ...methods);
49
+ }
50
+ /** Um método com este id teve rotas montadas? Usado só para avisar de drift. */
51
+ export function isSudoMethodMounted(methodId) {
52
+ return mountedSudoMethods.some((m) => m?.id === methodId);
53
+ }
54
+ /**
55
+ * Lista de métodos que o host configurou EXPLICITAMENTE, ou `null` quando ele
56
+ * não configurou nada (ausente ou vazio → "não restringi").
57
+ *
58
+ * Ponto ÚNICO de leitura de `config.sudo.methods`. Existe aqui (e não no
59
+ * controller) porque quem precisa dela são os dois lados — a tela, que decide o
60
+ * que OFERECER, e os handlers dos métodos, que decidem o que ACEITAR — e o
61
+ * controller já importa os métodos built-in, o que tornaria a dependência
62
+ * circular se os métodos importassem de volta o controller.
63
+ */
64
+ export function explicitSudoMethods(cfg) {
65
+ const configured = cfg?.sudo?.methods;
66
+ return Array.isArray(configured) && configured.length ? configured : null;
67
+ }
68
+ /**
69
+ * O método `methodId` está habilitado para ESTE host?
70
+ *
71
+ * Toda rota registrada por um `SudoMethod` DEVE começar por aqui. Sem essa
72
+ * checagem, `config.sudo.methods` só esconderia o método da tela: o endpoint
73
+ * continuaria vivo e concedendo sudo — uma config que aparenta restringir e não
74
+ * restringe é pior que nenhuma config.
75
+ *
76
+ * Sem configuração explícita nada foi restringido: vale o que tem rota montada.
77
+ * Isso é deliberado — a lista de defaults não é a fonte de verdade do que está
78
+ * montado, e tratá-la como tal derrubaria um método customizado do host.
79
+ */
80
+ export function isSudoMethodEnabled(cfg, methodId) {
81
+ const explicit = explicitSudoMethods(cfg);
82
+ if (explicit === null)
83
+ return true;
84
+ return explicit.some((m) => m?.id === methodId);
85
+ }
86
+ /**
87
+ * Lista efetiva de métodos da TELA — o irmão de `isSudoMethodEnabled`, e por
88
+ * isso mora coladinho nele.
89
+ *
90
+ * Sem config explícita cai na lista MONTADA, exatamente a mesma resposta que
91
+ * `isSudoMethodEnabled` dá do lado dos handlers ("vale o que tem rota"). Antes
92
+ * caía numa lista de defaults hardcoded, e os dois lados divergiam de verdade:
93
+ * um host que fizesse só
94
+ *
95
+ * ```ts
96
+ * registerAuthHost(router, { sudoMethods: [sudoMethods.magicLink()] })
97
+ * ```
98
+ *
99
+ * montava só magic-link, mas a tela oferecia password + passkey (ambos 404) e
100
+ * NÃO oferecia magic-link, que funcionava. Caindo na lista montada, o drift
101
+ * fica estruturalmente impossível no caso sem config: é literalmente a mesma
102
+ * lista. O aviso de flag-drift do controller passa a valer só para o caso que
103
+ * sobra — config explícita divergindo do que foi montado.
104
+ */
105
+ export function configuredSudoMethods(cfg) {
106
+ return explicitSudoMethods(cfg) ?? mountedSudoMethods;
107
+ }
108
+ /**
109
+ * Verbos HTTP com a forma `(pattern, handler, ...)` — o handler é o SEGUNDO
110
+ * argumento.
111
+ *
112
+ * `route` NÃO entra aqui: sua assinatura é `(pattern, methods, handler)`, com o
113
+ * handler no TERCEIRO argumento, e por isso tem tratamento próprio no Proxy.
114
+ * Deixá-lo de fora da barreira seria um bypass silencioso — `router.route()`
115
+ * registra rota igual a qualquer verbo.
116
+ */
117
+ const ROUTER_VERBS = ['get', 'post', 'put', 'patch', 'delete', 'any'];
118
+ /**
119
+ * Atalhos do `Router` que registram rotas SEM receber um handler-função: eles
120
+ * expandem um CONTROLLER em N rotas por convenção. Não há função para embrulhar
121
+ * — a barreira não tem por onde entrar —, então são recusados no boot.
122
+ *
123
+ * Ver `assertWrappableHandler` para o porquê de recusar em vez de deixar passar.
124
+ */
125
+ const ROUTER_CONTROLLER_SHORTCUTS = ['resource', 'shallowResource'];
126
+ /**
127
+ * Envelopa o router entregue a `method.register` para que TODO handler que ele
128
+ * registrar seja barrado quando `config.sudo.methods` não incluir o método.
129
+ *
130
+ * POR QUE NO PONTO DE REGISTRO, e não dentro de cada handler: a barreira é a
131
+ * diferença entre uma config que restringe e uma que só esconde a opção da tela
132
+ * enquanto o endpoint segue concedendo sudo (falha Critical). Deixá-la a cargo
133
+ * de quem escreve o método significa que o PRIMEIRO método que esquecer a
134
+ * chamada reabre a falha — e nada detecta. Aqui a garantia é estrutural: o
135
+ * método não tem como registrar uma rota desguardada, porque não é ele quem
136
+ * segura o router.
137
+ *
138
+ * Os built-in CONTINUAM checando por dentro, e isso não é redundância inútil:
139
+ * cada um recusa na forma que o seu endpoint exige (o `passkey/options` é XHR e
140
+ * devolve JSON 404; um 302 para HTML quebraria o cliente). Este envelope é o
141
+ * piso genérico — recusa com `fail`, o mesmo redirect+flash de um erro comum,
142
+ * que não distingue "método desligado" de "credencial errada" e portanto não
143
+ * vaza a config do host.
144
+ *
145
+ * O QUE NÃO CABE NA BARREIRA É RECUSADO, não tolerado: um handler que não seja
146
+ * função (tupla `[Controller, 'metodo']`) e os atalhos `resource()`/
147
+ * `shallowResource()` lançam no ponto de registro. Ver
148
+ * `ROUTER_CONTROLLER_SHORTCUTS` e o `assertWrappableHandler` abaixo.
149
+ * (`on()` continua passando: ele registra redirect/render estático, sem handler
150
+ * que possa alcançar `completeSudo`.)
151
+ *
152
+ * CUSTO: resolve o config do container antes do handler. Não usa
153
+ * `contextFrom` no caminho feliz de propósito — aquilo faz `findById`, e pagar
154
+ * uma leitura de conta a mais em toda rota de sudo para uma checagem que só lê
155
+ * a config seria desperdício. O contexto completo só é montado para recusar.
156
+ */
157
+ export function guardSudoRoutes(router, methodId, h,
158
+ /**
159
+ * Aplica o throttle do host a CADA rota que o método registrar (no-op quando
160
+ * o rate-limit está desligado). É `registerAuthHost` quem passa isto, porque
161
+ * é lá que os throttles existem — e é aqui que a rota nasce.
162
+ *
163
+ * O throttle que chega aqui é o do bucket de SUDO, não o de login. Mesmos
164
+ * limites, contagem separada: login mede um anônimo adivinhando credenciais,
165
+ * sudo mede um usuário JÁ autenticado reprovando a própria identidade.
166
+ * Compartilhando o bucket, quem erra a senha no `/account/confirm` gastava o
167
+ * orçamento de login do próprio IP — e um ataque de credencial no login
168
+ * trancava a confirmação de quem está legitimamente logado atrás do mesmo
169
+ * NAT. Ver `ResolvedRateLimitConfig.sudo`.
170
+ *
171
+ * Fica no wrapper, e não no contrato de `SudoRouteHelpers`, porque throttle
172
+ * não é decisão do método: um método que pudesse pedir throttle poderia
173
+ * também NÃO pedir, e o `POST` que emite o magic link de sudo — que dispara
174
+ * um e-mail por chamada — voltaria a ficar sem nenhum. Aqui a cobertura é da
175
+ * mesma natureza da barreira de `config.sudo.methods`: estrutural, sem
176
+ * depender de quem escreve o método lembrar de nada.
177
+ */
178
+ applyThrottle) {
179
+ const wrap = (handler) => async (ctx) => {
180
+ const service = await ctx.containerResolver.make('authkit.server');
181
+ if (isSudoMethodEnabled(service.config, methodId))
182
+ return handler(ctx);
183
+ const c = await h.contextFrom(ctx);
184
+ return h.fail(c, 'account.confirm.error');
185
+ };
186
+ /**
187
+ * A barreira só sabe embrulhar handler-FUNÇÃO. Qualquer outra forma
188
+ * (`[Controller, 'method']`, string `'Controller.method'`) registra uma rota
189
+ * que `config.sudo.methods` não desabilita e que alcança o `completeSudo`
190
+ * público — um bypass silencioso, no exato ponto onde a barreira devia ser
191
+ * estrutural.
192
+ *
193
+ * Por isso LANÇA, em vez de deixar passar. Antes o argumento era "nenhum
194
+ * built-in registra assim" — mas isso é propriedade dos built-in, não da
195
+ * barreira, e o público-alvo do SPI é justamente quem não é built-in.
196
+ *
197
+ * É boot-time e é alto: o host descobre no primeiro `node ace serve`, não em
198
+ * produção com uma rota de sudo desguardada. A saída é registrar um wrapper
199
+ * de uma linha (`(ctx) => new Ctrl().metodo(ctx)`), que passa pela barreira.
200
+ */
201
+ const assertWrappableHandler = (verb, pattern, handler) => {
202
+ if (typeof handler === 'function')
203
+ return handler;
204
+ throw new Error(`authkit: o método de sudo "${methodId}" registrou "${verb} ${pattern}" com um handler ` +
205
+ `que não é função (${handler === undefined ? 'undefined' : typeof handler}). ` +
206
+ 'Métodos de sudo precisam registrar handler-função para receber a barreira de ' +
207
+ '`config.sudo.methods` — uma tupla `[Controller, \'metodo\']` registraria uma rota que ' +
208
+ 'a config não desabilita e que alcança `completeSudo`. Envolva o controller numa ' +
209
+ "função: `router.post(pattern, (ctx) => new Controller().metodo(ctx))`.");
210
+ };
211
+ const wrapIfFn = (verb, pattern, handler) => wrap(assertWrappableHandler(verb, pattern, handler));
212
+ return new Proxy(router, {
213
+ // `receiver` é DELIBERADAMENTE `target`, não o Proxy: o `Router` do Adonis é
214
+ // uma classe com campos privados (`#app`, `#globalMatchers`, `#pushToRoutes`),
215
+ // e ler/chamar um membro com `this` apontando para o Proxy lança
216
+ // `TypeError: Cannot read private member #app`.
217
+ get(target, prop) {
218
+ const original = Reflect.get(target, prop, target);
219
+ if (typeof original !== 'function')
220
+ return original;
221
+ // Throttle na rota recém-criada. O `Route` real é preservado e devolvido,
222
+ // então `.as()`/`.use()` seguem encadeando do lado de fora.
223
+ const throttled = (route) => {
224
+ applyThrottle?.(route);
225
+ return route;
226
+ };
227
+ // `route(pattern, methods, handler)` — handler no TERCEIRO argumento.
228
+ if (prop === 'route') {
229
+ return (pattern, methods, handler, ...rest) => throttled(original.call(target, pattern, methods, wrapIfFn('route', pattern, handler), ...rest));
230
+ }
231
+ if (ROUTER_VERBS.includes(prop)) {
232
+ return (pattern, handler, ...rest) => throttled(original.call(target, pattern, wrapIfFn(String(prop).toUpperCase(), pattern, handler), ...rest));
233
+ }
234
+ // `resource()`/`shallowResource()` expandem um controller em N rotas por
235
+ // convenção: não passa handler-função por lugar nenhum, e a barreira não
236
+ // teria o que embrulhar. Recusa no boot pela mesma razão da tupla — deixar
237
+ // passar seria registrar rotas de sudo que `config.sudo.methods` não
238
+ // desabilita.
239
+ if (ROUTER_CONTROLLER_SHORTCUTS.includes(prop)) {
240
+ return (pattern) => {
241
+ throw new Error(`authkit: o método de sudo "${methodId}" chamou router.${String(prop)}("${pattern}"), ` +
242
+ 'que registra rotas a partir de um controller e portanto não pode receber a ' +
243
+ 'barreira de `config.sudo.methods`. Registre as rotas do método uma a uma, com ' +
244
+ 'handler-função.');
245
+ };
246
+ }
247
+ // Todo o RESTO da API do `Router` (`group`, `on`, `where`, `use`, ...)
248
+ // segue funcionando, ligado ao router real. Sem o
249
+ // `bind`, um `router.group(() => ...)` — uso perfeitamente legítimo do
250
+ // tipo `Router` que `SudoMethod.register` declara receber — rodaria com
251
+ // `this === Proxy` e explodiria no boot da aplicação.
252
+ //
253
+ // `original.call(target, ...)` nos verbos e o `bind` aqui preservam o
254
+ // RETORNO real (`Route`/`RouteGroup`), então `.as()`/`.use()`/
255
+ // `.middleware()`/`.prefix()` continuam encadeando normalmente.
256
+ return original.bind(target);
257
+ },
258
+ });
259
+ }
260
+ /**
261
+ * ÚNICO ponto do pacote que concede sudo. Nenhum `SudoMethod` chama
262
+ * `markSudo` diretamente: o método decide se verificou, o runtime concede,
263
+ * audita e redireciona.
264
+ */
265
+ export async function completeSudo(c, methodId) {
266
+ // SEGUNDA barreira estrutural, irmã de `guardSudoRoutes`: aquela cobre
267
+ // "o método está habilitado?", esta cobre "a conta existe?".
268
+ //
269
+ // `sudoContextFrom` deixa `account: null` quando `findById` não acha nada —
270
+ // sessão viva de conta apagada ou anonimizada. Cada built-in já checa por
271
+ // dentro, mas depender disso é a mesma falha que `guardSudoRoutes` existe
272
+ // para eliminar do outro lado: o PRIMEIRO método desatento que fizer
273
+ // `contextFrom` → `completeSudo` concederia sudo sobre uma conta que não
274
+ // existe mais, e nada detectaria. A garantia tem de estar aqui, no único
275
+ // ponto de concessão.
276
+ // Falsy, não `=== null`: um contexto montado à mão que simplesmente omita
277
+ // `account` não pode escapar da barreira por um detalhe de forma.
278
+ if (!c.account)
279
+ return fail(c, 'account.confirm.error');
280
+ markSudo(c.ctx);
281
+ c.ctx.session.put(LAST_METHOD_SESSION_KEY, methodId);
282
+ await c.cfg.audit?.record({
283
+ type: 'sudo.confirmed',
284
+ accountId: c.accountId,
285
+ ip: c.ctx.request.ip?.() ?? null,
286
+ metadata: { method: methodId },
287
+ });
288
+ return c.ctx.response.redirect(c.returnTo ?? accountHome(c.cfg));
289
+ }
290
+ /**
291
+ * Falha de confirmação: flash + volta pro /account/confirm preservando o
292
+ * destino. Substitui a coreografia que estava duplicada cinco vezes no
293
+ * controller.
294
+ */
295
+ export async function fail(c, messageKey) {
296
+ c.ctx.session.flash('confirmError', translate(c.cfg.messages, messageKey));
297
+ const qs = c.returnTo ? `?return_to=${encodeURIComponent(c.returnTo)}` : '';
298
+ return c.ctx.response.redirect(`/account/confirm${qs}`);
299
+ }
300
+ /**
301
+ * Filtra os métodos disponíveis para esta conta e promove o último usado.
302
+ *
303
+ * `isAvailable` que lança NÃO derruba a tela: um método quebrado não pode
304
+ * trancar o usuário fora dos outros — mesmo espírito do FAIL-SAFE de
305
+ * `requireSudo`.
306
+ */
307
+ export async function resolveAvailableMethods(c, methods) {
308
+ const checked = await Promise.all(methods.map(async (m) => {
309
+ try {
310
+ return (await m.isAvailable(c)) ? m : null;
311
+ }
312
+ catch (error) {
313
+ // Fail-safe: um `isAvailable` quebrado não pode trancar o usuário fora
314
+ // dos outros métodos — mas precisa deixar rastro, senão um typo vira
315
+ // um método que some da tela em produção sem ninguém saber por quê.
316
+ // `?.` defensivo: em teste, `fakeSudoContext` pode não ter logger.
317
+ c.ctx.logger?.warn({ method: m.id, err: error }, `authkit: isAvailable() do método de sudo "${m.id}" lançou — método omitido da lista`);
318
+ return null;
319
+ }
320
+ }));
321
+ const available = checked.filter((m) => m !== null);
322
+ const last = c.ctx.session.get(LAST_METHOD_SESSION_KEY);
323
+ if (!last)
324
+ return available;
325
+ const preferred = available.filter((m) => m.id === last);
326
+ return preferred.length ? [...preferred, ...available.filter((m) => m.id !== last)] : available;
327
+ }
@@ -0,0 +1,93 @@
1
+ import type { HttpContext, Router } from '@adonisjs/core/http';
2
+ import type { ResolvedServerConfig } from '../../define_config.js';
3
+ /** Contexto entregue a todo método de sudo. */
4
+ export interface SudoContext {
5
+ ctx: HttpContext;
6
+ /**
7
+ * Conta logada no console — carregada com `accountStore.findById`.
8
+ *
9
+ * PODE ser null. O comentário original dizia "nunca null: o accountGuard já
10
+ * rodou", mas o `accountGuard` (register_auth_host.ts:91) só verifica que a
11
+ * CHAVE de sessão existe; ele nunca carrega a conta. Sessão viva de conta
12
+ * apagada/anonimizada → `findById` devolve null. Todo método deve tratar.
13
+ *
14
+ * `email` é `string` (não `string | null`) porque é o que o `AccountStore`
15
+ * de fato garante: `findById` devolve `AuthAccount`, cujo `email: string`
16
+ * (accounts/account_store.ts:4). A nulidade real está no objeto, não no campo.
17
+ */
18
+ account: {
19
+ id: string;
20
+ email: string;
21
+ } | null;
22
+ accountId: string;
23
+ /** Config resolvida do authkit (accountStore, messages, audit, mail...). */
24
+ cfg: ResolvedServerConfig;
25
+ /** Destino pós-confirmação, já validado — só caminhos internos. */
26
+ returnTo: string | null;
27
+ }
28
+ /** Como a tela deve renderizar o passo deste método. */
29
+ export interface SudoMethodDescriptor {
30
+ /** Chave i18n do rótulo. Ex.: 'account.confirm.method.magic_link'. */
31
+ labelKey: string;
32
+ /**
33
+ * 'form' — a tela renderiza `fields` e dá POST em `endpoint`.
34
+ * 'action' — a tela dá POST em `endpoint` sem input.
35
+ * 'redirect' — a tela manda o usuário para `endpoint` (fluxo externo).
36
+ * 'webauthn' — a tela precisa RODAR o handshake WebAuthn antes de postar:
37
+ * pede as options em `${endpoint}/options`, chama
38
+ * `navigator.credentials.get` (via `@simplewebauthn/browser`) e
39
+ * posta a assertion serializada no campo `response` de
40
+ * `endpoint`.
41
+ *
42
+ * `'webauthn'` existe como KIND próprio, e não como um caso especial do id
43
+ * `passkey`, justamente para a tela não voltar a conhecer método nenhum pelo
44
+ * nome: o endpoint de options é DERIVADO do descritor. Qualquer método do SPI
45
+ * que implemente o mesmo par `POST <endpoint>/options` + `POST <endpoint>`
46
+ * ganha a tela embutida de graça.
47
+ *
48
+ * Um método `'webauthn'` NÃO é utilizável sem JavaScript. Renderizá-lo como
49
+ * um form de submit direto manda `response` vazio e o handler recusa sempre —
50
+ * foi exatamente essa a regressão que motivou este kind.
51
+ */
52
+ kind: 'form' | 'action' | 'redirect' | 'webauthn';
53
+ endpoint: string;
54
+ fields?: Array<{
55
+ name: string;
56
+ type: 'password' | 'text';
57
+ labelKey: string;
58
+ }>;
59
+ }
60
+ /** Helpers que o runtime entrega às rotas de um método. */
61
+ export interface SudoRouteHelpers {
62
+ /** Monta o SudoContext a partir do HttpContext (resolve config, conta, returnTo). */
63
+ contextFrom(ctx: HttpContext): Promise<SudoContext>;
64
+ /** ÚNICO ponto de concessão de sudo no pacote. */
65
+ completeSudo(c: SudoContext, methodId: string): Promise<unknown>;
66
+ /** Flash de erro + volta pro /account/confirm preservando return_to. */
67
+ fail(c: SudoContext, messageKey: string): Promise<unknown>;
68
+ }
69
+ /**
70
+ * Método de confirmação de identidade (sudo mode).
71
+ *
72
+ * REGRA CENTRAL: um método NUNCA chama `markSudo`. Ele decide apenas SE
73
+ * verificou; conceder é do runtime, via `completeSudo`. `markSudo` é a
74
+ * concessão de privilégio — espalhá-la por N métodos multiplicaria por N as
75
+ * chances de alguém conceder sem ter verificado.
76
+ */
77
+ export interface SudoMethod {
78
+ /** Estável. Vai no audit (`metadata.method`) e na preferência lembrada. */
79
+ readonly id: string;
80
+ /** Disponível para ESTA conta? Ex.: passkey só se houver passkey cadastrada. */
81
+ isAvailable(c: SudoContext): Promise<boolean>;
82
+ /** O que a tela mostra para este método. */
83
+ describe(c: SudoContext): Promise<SudoMethodDescriptor>;
84
+ /**
85
+ * Endpoints próprios. Opcional: métodos puramente 'redirect' (oidcStepUp)
86
+ * não registram nada, porque o fluxo sai do pacote.
87
+ *
88
+ * Recebe o router cru (não monta por convenção a partir do `id`) porque
89
+ * `password` e `passkey` precisam manter URLs legadas que uma convenção
90
+ * não comportaria.
91
+ */
92
+ register?(router: Router, h: SudoRouteHelpers): void;
93
+ }
@@ -0,0 +1 @@
1
+ export {};