@adonis-agora/authkit-server 0.45.0 → 0.47.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 (64) 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/otp-unlock.edge +2 -2
  7. package/build/host/views/partials/styles.edge +1 -1
  8. package/build/index.d.ts +5 -1
  9. package/build/index.js +15 -1
  10. package/build/providers/authkit_server_provider.js +7 -0
  11. package/build/services/booted_app.d.ts +8 -0
  12. package/build/services/booted_app.js +27 -0
  13. package/build/services/main.d.ts +7 -0
  14. package/build/services/main.js +9 -1
  15. package/build/src/define_config.d.ts +51 -0
  16. package/build/src/define_config.js +8 -0
  17. package/build/src/host/account_login_url.d.ts +41 -0
  18. package/build/src/host/account_login_url.js +50 -0
  19. package/build/src/host/admin_sessions_service.d.ts +3 -1
  20. package/build/src/host/admin_sessions_service.js +35 -3
  21. package/build/src/host/assets/webauthn.js +2 -0
  22. package/build/src/host/console_session.d.ts +4 -0
  23. package/build/src/host/console_session.js +9 -2
  24. package/build/src/host/controllers/account_confirm_controller.d.ts +11 -14
  25. package/build/src/host/controllers/account_confirm_controller.js +42 -130
  26. package/build/src/host/controllers/account_orgs_controller.js +7 -4
  27. package/build/src/host/controllers/account_security_controller.js +3 -2
  28. package/build/src/host/controllers/account_session_controller.js +23 -5
  29. package/build/src/host/controllers/account_tokens_controller.js +11 -0
  30. package/build/src/host/controllers/pat_introspection_controller.js +10 -0
  31. package/build/src/host/controllers/webauthn_asset_controller.d.ts +22 -0
  32. package/build/src/host/controllers/webauthn_asset_controller.js +66 -0
  33. package/build/src/host/i18n.d.ts +14 -0
  34. package/build/src/host/i18n.js +16 -0
  35. package/build/src/host/impersonation_session.js +15 -0
  36. package/build/src/host/middleware/account_auth.js +3 -1
  37. package/build/src/host/rate_limit.d.ts +10 -0
  38. package/build/src/host/rate_limit.js +6 -0
  39. package/build/src/host/register_auth_host.d.ts +85 -2
  40. package/build/src/host/register_auth_host.js +188 -61
  41. package/build/src/host/renderers/edge_renderer.js +6 -1
  42. package/build/src/host/renderers/inertia_renderer.d.ts +55 -0
  43. package/build/src/host/renderers/inertia_renderer.js +55 -0
  44. package/build/src/host/sudo/index.d.ts +47 -0
  45. package/build/src/host/sudo/index.js +41 -0
  46. package/build/src/host/sudo/methods/magic_link.d.ts +43 -0
  47. package/build/src/host/sudo/methods/magic_link.js +174 -0
  48. package/build/src/host/sudo/methods/oidc_step_up.d.ts +68 -0
  49. package/build/src/host/sudo/methods/oidc_step_up.js +78 -0
  50. package/build/src/host/sudo/methods/passkey.d.ts +28 -0
  51. package/build/src/host/sudo/methods/passkey.js +139 -0
  52. package/build/src/host/sudo/methods/password.d.ts +19 -0
  53. package/build/src/host/sudo/methods/password.js +93 -0
  54. package/build/src/host/sudo/runtime.d.ts +141 -0
  55. package/build/src/host/sudo/runtime.js +327 -0
  56. package/build/src/host/sudo/types.d.ts +93 -0
  57. package/build/src/host/sudo/types.js +1 -0
  58. package/build/src/host/sudo_mode.d.ts +116 -6
  59. package/build/src/host/sudo_mode.js +133 -7
  60. package/package.json +6 -2
  61. package/build/stubs/ui/edge/views/consent.edge +0 -13
  62. package/build/stubs/ui/edge/views/login.edge +0 -19
  63. package/stubs/ui/edge/views/consent.edge +0 -13
  64. package/stubs/ui/edge/views/login.edge +0 -19
@@ -0,0 +1,174 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { translate } from '../../i18n.js';
3
+ import { isSudoMethodEnabled } from '../runtime.js';
4
+ /** Token de sudo pendente, guardado na sessão que o pediu. */
5
+ export const SUDO_LINK_SESSION_KEY = 'authkit_sudo_link';
6
+ /** Mesma janela dos magic links de login. */
7
+ export const SUDO_LINK_TTL_MS = 5 * 60 * 1000;
8
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
9
+ /**
10
+ * Emite um token de sudo e guarda o HASH na sessão que pediu.
11
+ *
12
+ * O TOKEN É PRÓPRIO, DE ESCOPO SUDO — nunca o token de login
13
+ * (`issueMagicLinkToken`/`consumeMagicLinkToken` do `AccountStore`). Aquele é
14
+ * credencial de AUTENTICAÇÃO: reusá-lo faria de um link de sudo vazado uma
15
+ * sessão completa.
16
+ *
17
+ * | propriedade | valor | razão |
18
+ * |---|---|---|
19
+ * | geração | `randomBytes(32)` hex | entropia de credencial |
20
+ * | armazenamento | HASH na sessão que pediu | não guarda o segredo em claro |
21
+ * | escopo | só marca sudo | nunca autentica |
22
+ * | validade | 5 min | mesma janela dos magic links de login |
23
+ * | uso | único (apagado no consumo) | replay |
24
+ * | navegador | só o mesmo (vive na sessão) | step-up é reprova de QUEM ESTÁ ALI |
25
+ * | conta | vinculado ao `accountId` emissor | sessão sobrevive à troca de conta |
26
+ *
27
+ * O "só mesmo navegador" é propriedade desejada aqui, diferente do magic link
28
+ * de login, onde é limitação conhecida.
29
+ *
30
+ * Exportada (em vez de membro `__` do método) para ser testável sem furar a
31
+ * API pública do `SudoMethod`.
32
+ */
33
+ export function issueSudoLinkToken(c) {
34
+ const token = randomBytes(32).toString('hex');
35
+ const pending = {
36
+ hash: sha256(token),
37
+ expiresAt: Date.now() + SUDO_LINK_TTL_MS,
38
+ accountId: c.accountId,
39
+ };
40
+ c.ctx.session.put(SUDO_LINK_SESSION_KEY, pending);
41
+ return token;
42
+ }
43
+ /**
44
+ * Consome o token: single-use, vinculado à conta emissora, expira em 5 min,
45
+ * comparação em tempo constante.
46
+ */
47
+ export function verifySudoLinkToken(c, token) {
48
+ const pending = c.ctx.session.get(SUDO_LINK_SESSION_KEY);
49
+ // GUARD DE FORMA, antes de qualquer uso dos campos. A sessão é um saco de
50
+ // JSON: um valor com outra forma (versão antiga do pacote, host que escreveu
51
+ // na chave, store corrompido) tinha duas consequências ruins —
52
+ // 1. `Buffer.from(undefined, 'hex')` → TypeError → 500;
53
+ // 2. `Date.now() > undefined` é `false` → FAIL-OPEN: o token nunca expira.
54
+ // Forma errada é recusa, não exceção e muito menos passe livre.
55
+ if (typeof pending?.hash !== 'string' ||
56
+ typeof pending?.expiresAt !== 'number' ||
57
+ typeof pending?.accountId !== 'string') {
58
+ // Lixo na chave não pode ficar lá bloqueando/confundindo a próxima emissão.
59
+ c.ctx.session.forget(SUDO_LINK_SESSION_KEY);
60
+ return false;
61
+ }
62
+ // Single-use: some na primeira tentativa, certa ou errada.
63
+ c.ctx.session.forget(SUDO_LINK_SESSION_KEY);
64
+ // VINCULAÇÃO À CONTA: quem consome tem de ser quem pediu. A sessão sobrevive
65
+ // à troca de conta (o `regenerate()` do logout MIGRA os dados), então "está
66
+ // pendente nesta sessão" não implica "é desta conta". Ver `PendingLink.accountId`.
67
+ if (pending.accountId !== c.accountId)
68
+ return false;
69
+ if (Date.now() > pending.expiresAt)
70
+ return false;
71
+ const a = Buffer.from(sha256(token), 'hex');
72
+ const b = Buffer.from(pending.hash, 'hex');
73
+ return a.length === b.length && timingSafeEqual(a, b);
74
+ }
75
+ /**
76
+ * Origem absoluta desta requisição, ou `null` se o request não a expõe.
77
+ *
78
+ * O link vai por E-MAIL: um caminho relativo (`/account/confirm/...`) não é
79
+ * clicável fora do navegador. É a mesma montagem do `onMagicLink` de login
80
+ * (interaction_controller.ts:706). O `null` é fallback defensivo — se o host
81
+ * usar um request sem `protocol()`/`host()`, cai para o caminho relativo em vez
82
+ * de mandar `undefined://undefined/...`.
83
+ */
84
+ function requestOrigin(ctx) {
85
+ const protocol = ctx?.request?.protocol?.();
86
+ const host = ctx?.request?.host?.();
87
+ return protocol && host ? `${protocol}://${host}` : null;
88
+ }
89
+ /**
90
+ * Confirmação por link enviado ao e-mail da conta.
91
+ *
92
+ * Depende do hook `mail.onSudoLink`, DISTINTO de `mail.onMagicLink` justamente
93
+ * para que o host não confunda um link que autentica com um que só concede
94
+ * sudo a quem já está logado. Sem o hook, o método fica indisponível.
95
+ */
96
+ export function magicLink() {
97
+ return {
98
+ id: 'magic-link',
99
+ async isAvailable(c) {
100
+ if (!c.account?.email)
101
+ return false;
102
+ return typeof c.cfg?.mail?.onSudoLink === 'function';
103
+ },
104
+ async describe() {
105
+ return {
106
+ labelKey: 'account.confirm.method.magic_link',
107
+ kind: 'action',
108
+ endpoint: '/account/confirm/magic-link',
109
+ };
110
+ },
111
+ register(router, h) {
112
+ router.post('/account/confirm/magic-link', async (ctx) => {
113
+ const c = await h.contextFrom(ctx);
114
+ // ANTES de qualquer coisa: o host desligou este método? A rota é montada
115
+ // incondicionalmente, então só o handler faz `config.sudo.methods` valer.
116
+ // Responde `fail` (o mesmo redirect+flash de um erro comum) em vez de
117
+ // 404 para não vazar a config do host.
118
+ if (!isSudoMethodEnabled(c.cfg, 'magic-link'))
119
+ return h.fail(c, 'account.confirm.error');
120
+ // `c.account` é nullable (sessão viva de conta apagada → findById null)
121
+ // e sem e-mail não há para onde mandar o link.
122
+ if (!c.account?.email)
123
+ return h.fail(c, 'account.confirm.error');
124
+ // Checado ANTES de emitir: um token emitido sem ninguém para entregá-lo
125
+ // é lixo na sessão, e a `isAvailable` já prometeu que sem hook o método
126
+ // não existe.
127
+ const onSudoLink = c.cfg?.mail?.onSudoLink;
128
+ if (typeof onSudoLink !== 'function')
129
+ return h.fail(c, 'account.confirm.error');
130
+ const qs = c.returnTo ? `?return_to=${encodeURIComponent(c.returnTo)}` : '';
131
+ const token = issueSudoLinkToken(c);
132
+ const path = `/account/confirm/magic-link/${token}${qs}`;
133
+ const origin = requestOrigin(ctx);
134
+ try {
135
+ await onSudoLink({ email: c.account.email, sudoUrl: origin ? `${origin}${path}` : path });
136
+ }
137
+ catch {
138
+ // O envio falhou: apaga o pendente. Não é risco de segurança (o
139
+ // segredo não chegou a lugar nenhum), mas deixá-lo lá invalidaria
140
+ // silenciosamente um token anterior ainda válido do usuário.
141
+ c.ctx.session.forget(SUDO_LINK_SESSION_KEY);
142
+ return h.fail(c, 'account.confirm.error');
143
+ }
144
+ // TRADUZIDO, não a chave crua: o `fail()` do runtime flasha
145
+ // `translate(...)` em `confirmError`, e a tela leria dois formatos
146
+ // diferentes se este aqui mandasse a chave.
147
+ ctx.session.flash('confirmNotice', translate(c.cfg.messages, 'account.confirm.magic_link_sent'));
148
+ return ctx.response.redirect(`/account/confirm${qs}`);
149
+ });
150
+ router.get('/account/confirm/magic-link/:token', async (ctx) => {
151
+ const c = await h.contextFrom(ctx);
152
+ if (!isSudoMethodEnabled(c.cfg, 'magic-link'))
153
+ return h.fail(c, 'account.confirm.error');
154
+ // Sem conta resolvida não há a quem conceder sudo. O token vive na
155
+ // sessão, mas quem o consome precisa continuar sendo uma conta viva.
156
+ //
157
+ // NÃO INVERTA ESTA ORDEM. O `!c.account` vem ANTES do
158
+ // `verifySudoLinkToken` de propósito: o `verify` é DESTRUTIVO (queima o
159
+ // pendente na primeira tentativa, certa ou errada), e scanners
160
+ // corporativos de e-mail — Safe Links do Microsoft 365, proxies de
161
+ // antivírus — fazem prefetch da URL SEM o cookie de sessão. Nesse
162
+ // prefetch não há conta resolvida: com esta ordem ele é recusado antes
163
+ // de tocar no pendente, e o link continua válido para o usuário. Trocar
164
+ // as duas linhas faria todo link chegar já consumido.
165
+ if (!c.account)
166
+ return h.fail(c, 'account.confirm.error');
167
+ const token = ctx.params?.token;
168
+ if (!token || !verifySudoLinkToken(c, token))
169
+ return h.fail(c, 'account.confirm.error');
170
+ return h.completeSudo(c, 'magic-link');
171
+ });
172
+ },
173
+ };
174
+ }
@@ -0,0 +1,68 @@
1
+ import type { SudoMethod } from '../types.js';
2
+ export interface OidcStepUpOptions {
3
+ /** Rota do HOST que inicia a reautenticação. Ex.: '/auth/step-up'. */
4
+ url: string;
5
+ }
6
+ /**
7
+ * Confirmação por reautenticação OIDC (step-up), o mecanismo padrão do próprio
8
+ * protocolo para provar identidade recente (`prompt=login` / `max_age`).
9
+ *
10
+ * SEMPRE disponível: é o único método que não exige nada previamente
11
+ * cadastrado, e por isso é o que quebra o deadlock de hosts passwordless —
12
+ * onde o usuário não tem senha e cadastrar passkey também exigiria sudo.
13
+ *
14
+ * NÃO registra rotas: o fluxo sai do pacote. Quem chama `completeSudo` é o
15
+ * host, no seu callback, DEPOIS de validar o grant.
16
+ *
17
+ * Fluxo esperado do host:
18
+ *
19
+ * ```
20
+ * POST /account/security/export
21
+ * requireSudo() → sem marca → redirect para /account/confirm
22
+ * GET /account/confirm
23
+ * lista os métodos disponíveis; este aparece como 'redirect' e leva o
24
+ * usuário para a URL abaixo
25
+ * GET /auth/step-up
26
+ * grava flag de step-up NA SESSÃO; inicia Authorization Code + PKCE
27
+ * com prompt=login
28
+ * GET /auth/callback
29
+ * valida state/PKCE/nonce; consome a flag; chama completeSudo()
30
+ * ```
31
+ *
32
+ * Quatro regras que o host PRECISA seguir:
33
+ *
34
+ * 1. A flag de step-up vive NA SESSÃO, nunca na querystring. Se trafegasse
35
+ * pela URL, qualquer um forjaria um callback que concede sudo.
36
+ * 2. `completeSudo` só DEPOIS da validação completa do grant. É o
37
+ * `prompt=login` que garante que o provider forçou reautenticação, em vez
38
+ * de reaproveitar a sessão existente.
39
+ * 3. A flag é CONSUMIDA (lida e apagada) logo no início do callback, antes de
40
+ * qualquer ramo que possa falhar — não só no caminho de sucesso. Se ela
41
+ * sobreviver a um callback que deu errado, o próximo login comum daquele
42
+ * usuário será interpretado como step-up e concederá sudo sem que ninguém
43
+ * tenha pedido reautenticação. Trate-a como token de uso único.
44
+ * 4. A flag CARREGA O `accountId` de quem iniciou o step-up, e o callback
45
+ * RECUSA quando esse id não bate com a conta logada no momento do retorno.
46
+ * Um booleano solto (`session.put('step_up', true)`) NÃO serve.
47
+ *
48
+ * O porquê: a sessão sobrevive à troca de usuário. O `regenerate()` do
49
+ * `@adonisjs/session`, que o logout chama, troca o ID do cookie mas MIGRA
50
+ * os dados — ele não os descarta. Então, num navegador compartilhado, A
51
+ * inicia o step-up, abandona no provider e faz logout; B loga na mesma
52
+ * máquina; a flag booleana de A ainda está lá, e o primeiro callback que
53
+ * chegar promove B a sudo sem que B jamais tenha reautenticado. A regra 3
54
+ * (uso único) não cobre isso: ela limita a flag a UM uso, não a UMA conta,
55
+ * e esse único uso pode muito bem ser o da conta errada.
56
+ *
57
+ * Na prática: grave `{ accountId, ... }` em vez de `true`, e no callback
58
+ * consuma a flag (regra 3) e compare `flag.accountId` com a conta da
59
+ * sessão ANTES de chamar `completeSudo`. Divergiu, recuse — não é o
60
+ * usuário que pediu a reautenticação. Ausência de flag também é recusa:
61
+ * tolerar `undefined` devolve o mesmo furo por outro caminho.
62
+ *
63
+ * Esta é a única barreira possível aqui: quem grava a flag é o HOST, no
64
+ * seu `/auth/step-up`, e o pacote nunca a vê — não há como o AuthKit
65
+ * impor a vinculação por você. O equivalente interno (challenge de passkey
66
+ * e pendente do magic link) é vinculado à conta emissora pelo pacote.
67
+ */
68
+ export declare function oidcStepUp(opts: OidcStepUpOptions): SudoMethod;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Confirmação por reautenticação OIDC (step-up), o mecanismo padrão do próprio
3
+ * protocolo para provar identidade recente (`prompt=login` / `max_age`).
4
+ *
5
+ * SEMPRE disponível: é o único método que não exige nada previamente
6
+ * cadastrado, e por isso é o que quebra o deadlock de hosts passwordless —
7
+ * onde o usuário não tem senha e cadastrar passkey também exigiria sudo.
8
+ *
9
+ * NÃO registra rotas: o fluxo sai do pacote. Quem chama `completeSudo` é o
10
+ * host, no seu callback, DEPOIS de validar o grant.
11
+ *
12
+ * Fluxo esperado do host:
13
+ *
14
+ * ```
15
+ * POST /account/security/export
16
+ * requireSudo() → sem marca → redirect para /account/confirm
17
+ * GET /account/confirm
18
+ * lista os métodos disponíveis; este aparece como 'redirect' e leva o
19
+ * usuário para a URL abaixo
20
+ * GET /auth/step-up
21
+ * grava flag de step-up NA SESSÃO; inicia Authorization Code + PKCE
22
+ * com prompt=login
23
+ * GET /auth/callback
24
+ * valida state/PKCE/nonce; consome a flag; chama completeSudo()
25
+ * ```
26
+ *
27
+ * Quatro regras que o host PRECISA seguir:
28
+ *
29
+ * 1. A flag de step-up vive NA SESSÃO, nunca na querystring. Se trafegasse
30
+ * pela URL, qualquer um forjaria um callback que concede sudo.
31
+ * 2. `completeSudo` só DEPOIS da validação completa do grant. É o
32
+ * `prompt=login` que garante que o provider forçou reautenticação, em vez
33
+ * de reaproveitar a sessão existente.
34
+ * 3. A flag é CONSUMIDA (lida e apagada) logo no início do callback, antes de
35
+ * qualquer ramo que possa falhar — não só no caminho de sucesso. Se ela
36
+ * sobreviver a um callback que deu errado, o próximo login comum daquele
37
+ * usuário será interpretado como step-up e concederá sudo sem que ninguém
38
+ * tenha pedido reautenticação. Trate-a como token de uso único.
39
+ * 4. A flag CARREGA O `accountId` de quem iniciou o step-up, e o callback
40
+ * RECUSA quando esse id não bate com a conta logada no momento do retorno.
41
+ * Um booleano solto (`session.put('step_up', true)`) NÃO serve.
42
+ *
43
+ * O porquê: a sessão sobrevive à troca de usuário. O `regenerate()` do
44
+ * `@adonisjs/session`, que o logout chama, troca o ID do cookie mas MIGRA
45
+ * os dados — ele não os descarta. Então, num navegador compartilhado, A
46
+ * inicia o step-up, abandona no provider e faz logout; B loga na mesma
47
+ * máquina; a flag booleana de A ainda está lá, e o primeiro callback que
48
+ * chegar promove B a sudo sem que B jamais tenha reautenticado. A regra 3
49
+ * (uso único) não cobre isso: ela limita a flag a UM uso, não a UMA conta,
50
+ * e esse único uso pode muito bem ser o da conta errada.
51
+ *
52
+ * Na prática: grave `{ accountId, ... }` em vez de `true`, e no callback
53
+ * consuma a flag (regra 3) e compare `flag.accountId` com a conta da
54
+ * sessão ANTES de chamar `completeSudo`. Divergiu, recuse — não é o
55
+ * usuário que pediu a reautenticação. Ausência de flag também é recusa:
56
+ * tolerar `undefined` devolve o mesmo furo por outro caminho.
57
+ *
58
+ * Esta é a única barreira possível aqui: quem grava a flag é o HOST, no
59
+ * seu `/auth/step-up`, e o pacote nunca a vê — não há como o AuthKit
60
+ * impor a vinculação por você. O equivalente interno (challenge de passkey
61
+ * e pendente do magic link) é vinculado à conta emissora pelo pacote.
62
+ */
63
+ export function oidcStepUp(opts) {
64
+ return {
65
+ id: 'oidc-step-up',
66
+ async isAvailable() {
67
+ return true;
68
+ },
69
+ async describe(c) {
70
+ const qs = c.returnTo ? `?return_to=${encodeURIComponent(c.returnTo)}` : '';
71
+ return {
72
+ labelKey: 'account.confirm.method.oidc_step_up',
73
+ kind: 'redirect',
74
+ endpoint: `${opts.url}${qs}`,
75
+ };
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,28 @@
1
+ import type { SudoMethod } from '../types.js';
2
+ /** Chave de sessão do challenge — preservada do controller original. */
3
+ export declare const CONFIRM_PASSKEY_CHALLENGE_KEY = "authkit_confirm_passkey_challenge";
4
+ /**
5
+ * Conta que PEDIU o challenge acima.
6
+ *
7
+ * Mesma exposição do magic link de sudo: a sessão sobrevive à troca de conta —
8
+ * o `regenerate()` do logout só troca o id do cookie e MIGRA os dados. Num
9
+ * navegador compartilhado, A pede o challenge, faz logout, B loga, e o
10
+ * challenge de A continua lá; sem esta vinculação a assertion de A seria
11
+ * verificada CONTRA A CONTA DE B (relevante sobretudo em `AccountStore`
12
+ * discoverable/usernameless, que resolve a credencial pelo `rawId`).
13
+ *
14
+ * Chave SEPARADA de propósito: o valor de `CONFIRM_PASSKEY_CHALLENGE_KEY` é
15
+ * uma string e isso é contratual (pinado em
16
+ * `tests/host/account_confirm_controller.spec.ts`), então a vinculação é
17
+ * aditiva em vez de mudar a forma do valor pinado.
18
+ */
19
+ export declare const CONFIRM_PASSKEY_CHALLENGE_ACCOUNT_KEY = "authkit_confirm_passkey_challenge_account";
20
+ /**
21
+ * Confirmação por passkey (WebAuthn).
22
+ *
23
+ * URLs LEGADAS preservadas: `/account/confirm/passkey/options` e
24
+ * `/account/confirm/passkey`. Hosts externos e telas customizadas (fora deste
25
+ * pacote) dependem desses paths — trocá-los quebraria essas integrações sem
26
+ * que o pacote tenha como saber ou migrar por elas.
27
+ */
28
+ export declare function passkey(): SudoMethod;
@@ -0,0 +1,139 @@
1
+ import { supportsPasskeys } from '../../../accounts/account_store.js';
2
+ import { translate } from '../../i18n.js';
3
+ import { isSudoMethodEnabled } from '../runtime.js';
4
+ /** Chave de sessão do challenge — preservada do controller original. */
5
+ export const CONFIRM_PASSKEY_CHALLENGE_KEY = 'authkit_confirm_passkey_challenge';
6
+ /**
7
+ * Conta que PEDIU o challenge acima.
8
+ *
9
+ * Mesma exposição do magic link de sudo: a sessão sobrevive à troca de conta —
10
+ * o `regenerate()` do logout só troca o id do cookie e MIGRA os dados. Num
11
+ * navegador compartilhado, A pede o challenge, faz logout, B loga, e o
12
+ * challenge de A continua lá; sem esta vinculação a assertion de A seria
13
+ * verificada CONTRA A CONTA DE B (relevante sobretudo em `AccountStore`
14
+ * discoverable/usernameless, que resolve a credencial pelo `rawId`).
15
+ *
16
+ * Chave SEPARADA de propósito: o valor de `CONFIRM_PASSKEY_CHALLENGE_KEY` é
17
+ * uma string e isso é contratual (pinado em
18
+ * `tests/host/account_confirm_controller.spec.ts`), então a vinculação é
19
+ * aditiva em vez de mudar a forma do valor pinado.
20
+ */
21
+ export const CONFIRM_PASSKEY_CHALLENGE_ACCOUNT_KEY = 'authkit_confirm_passkey_challenge_account';
22
+ /**
23
+ * Confirmação por passkey (WebAuthn).
24
+ *
25
+ * URLs LEGADAS preservadas: `/account/confirm/passkey/options` e
26
+ * `/account/confirm/passkey`. Hosts externos e telas customizadas (fora deste
27
+ * pacote) dependem desses paths — trocá-los quebraria essas integrações sem
28
+ * que o pacote tenha como saber ou migrar por elas.
29
+ */
30
+ export function passkey() {
31
+ return {
32
+ id: 'passkey',
33
+ async isAvailable(c) {
34
+ if (!supportsPasskeys(c.cfg.accountStore))
35
+ return false;
36
+ const list = await c.cfg.accountStore.listPasskeys(c.accountId);
37
+ return list.length > 0;
38
+ },
39
+ async describe() {
40
+ return {
41
+ labelKey: 'account.confirm.method.passkey',
42
+ // 'webauthn', NÃO 'action': o navegador precisa assinar o challenge
43
+ // (`navigator.credentials.get`) ANTES do POST. Um form de submit direto
44
+ // manda `response` vazio e o handler abaixo recusa sempre. O kind é o
45
+ // que faz a tela rodar o handshake — e o endpoint de options ela deriva
46
+ // daqui (`${endpoint}/options`), sem conhecer o id 'passkey'.
47
+ kind: 'webauthn',
48
+ endpoint: '/account/confirm/passkey',
49
+ };
50
+ },
51
+ register(router, h) {
52
+ router.post('/account/confirm/passkey/options', async (ctx) => {
53
+ const c = await h.contextFrom(ctx);
54
+ // i18n: mesma chave usada pelo controller original — hosts com catálogo
55
+ // customizado (ex.: pt-BR) não podem perder a mensagem localizada aqui.
56
+ // Reusada nas TRÊS recusas abaixo de propósito: "método desligado",
57
+ // "conta inexistente" e "sem passkey" ficam indistinguíveis para o
58
+ // chamador.
59
+ const deny = () => ctx.response.notFound({ message: translate(c.cfg.messages, 'errors.no_passkey_registered') });
60
+ // ANTES de qualquer coisa: o host desligou este método? A rota é montada
61
+ // incondicionalmente, então só o handler faz `config.sudo.methods` valer.
62
+ if (!isSudoMethodEnabled(c.cfg, 'passkey'))
63
+ return deny();
64
+ // Sem conta resolvida não há a quem emitir challenge. Aqui NÃO usamos
65
+ // `h.fail` (que redireciona) porque este endpoint é XHR e devolve JSON —
66
+ // um 302 para HTML quebraria o cliente. 404 em vez de 401 porque o 401
67
+ // afirmaria "você não está autenticado", informação a mais que o
68
+ // endpoint não precisa dar e incoerente com o resto do console, onde a
69
+ // ausência de sessão é tratada por redirect no `accountGuard`; o 404 é
70
+ // literalmente a resposta que este mesmo handler já dá quando não há
71
+ // passkey, então os casos não se distinguem.
72
+ if (!c.account)
73
+ return deny();
74
+ const generated = await c.cfg.accountStore.generatePasskeyAuthenticationOptions?.(c.accountId);
75
+ if (!generated)
76
+ return deny();
77
+ ctx.session.put(CONFIRM_PASSKEY_CHALLENGE_KEY, generated.challenge);
78
+ // Vinculação à conta emissora — ver CONFIRM_PASSKEY_CHALLENGE_ACCOUNT_KEY.
79
+ ctx.session.put(CONFIRM_PASSKEY_CHALLENGE_ACCOUNT_KEY, c.accountId);
80
+ return generated.options;
81
+ });
82
+ router.post('/account/confirm/passkey', async (ctx) => {
83
+ const c = await h.contextFrom(ctx);
84
+ // Método desligado pelo host → mesma resposta de uma assertion inválida
85
+ // (redirect + flash), para não vazar a config.
86
+ if (!isSudoMethodEnabled(c.cfg, 'passkey'))
87
+ return h.fail(c, 'account.confirm.passkey_error');
88
+ // A conta PRECISA existir. Sem esta linha, as únicas barreiras seriam o
89
+ // challenge na sessão e `verifyPasskeyAuthentication(c.accountId, ...)`:
90
+ // um `AccountStore` que resolva a credencial pelo `rawId` (fluxo
91
+ // discoverable/usernameless, comum em WebAuthn) aceitaria uma assertion
92
+ // com `accountId: undefined` e chegaria a `completeSudo` sem conta.
93
+ if (!c.account)
94
+ return h.fail(c, 'account.confirm.passkey_error');
95
+ // Apaga challenge E vinculação juntos: são um par, e meio par na sessão
96
+ // é o começo de um estado que ninguém sabe interpretar depois.
97
+ const clearChallenge = () => {
98
+ ctx.session.forget(CONFIRM_PASSKEY_CHALLENGE_KEY);
99
+ ctx.session.forget(CONFIRM_PASSKEY_CHALLENGE_ACCOUNT_KEY);
100
+ };
101
+ const challenge = ctx.session.get(CONFIRM_PASSKEY_CHALLENGE_KEY);
102
+ if (!challenge) {
103
+ clearChallenge();
104
+ return h.fail(c, 'account.confirm.passkey_error');
105
+ }
106
+ // VINCULAÇÃO À CONTA: quem consome o challenge tem de ser quem o pediu.
107
+ // "Está pendente nesta sessão" NÃO implica "é desta conta" — a sessão
108
+ // sobrevive ao logout+login de outra conta no mesmo navegador.
109
+ //
110
+ // ESTRITO (fail-closed): challenge SEM vinculação também é recusado. O
111
+ // emissor deste pacote sempre grava o par, então um challenge solto na
112
+ // sessão só pode vir de uma sessão anterior (outra versão do pacote, ou
113
+ // escrita por fora) — e é exatamente essa a forma que o atacante do
114
+ // navegador compartilhado consegue deixar para trás. Aceitar `undefined`
115
+ // seria fail-open: bastaria apagar a vinculação para anular a barreira.
116
+ const boundTo = ctx.session.get(CONFIRM_PASSKEY_CHALLENGE_ACCOUNT_KEY);
117
+ if (boundTo === undefined || boundTo !== c.accountId) {
118
+ clearChallenge();
119
+ return h.fail(c, 'account.confirm.passkey_error');
120
+ }
121
+ const raw = ctx.request.input('response');
122
+ let parsed = null;
123
+ try {
124
+ parsed = raw ? JSON.parse(raw) : null;
125
+ }
126
+ catch {
127
+ parsed = null;
128
+ }
129
+ const ok = parsed
130
+ ? ((await c.cfg.accountStore.verifyPasskeyAuthentication?.(c.accountId, parsed, challenge)) ?? false)
131
+ : false;
132
+ clearChallenge();
133
+ if (!ok)
134
+ return h.fail(c, 'account.confirm.passkey_error');
135
+ return h.completeSudo(c, 'passkey');
136
+ });
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,19 @@
1
+ import type { SudoMethod } from '../types.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 declare function password(): SudoMethod;
@@ -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
+ }