@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.
- package/build/commands/ui_preset.js +15 -1
- package/build/host/views/account/confirm.edge +118 -52
- package/build/host/views/account/mfa.edge +1 -1
- package/build/host/views/login.edge +2 -4
- package/build/host/views/mfa-challenge.edge +1 -1
- package/build/host/views/otp-unlock.edge +2 -2
- package/build/host/views/partials/styles.edge +1 -1
- package/build/index.d.ts +5 -1
- package/build/index.js +15 -1
- package/build/providers/authkit_server_provider.js +7 -0
- package/build/services/booted_app.d.ts +8 -0
- package/build/services/booted_app.js +27 -0
- package/build/services/main.d.ts +7 -0
- package/build/services/main.js +9 -1
- package/build/src/define_config.d.ts +51 -0
- package/build/src/define_config.js +8 -0
- package/build/src/host/account_login_url.d.ts +41 -0
- package/build/src/host/account_login_url.js +50 -0
- package/build/src/host/admin_sessions_service.d.ts +3 -1
- package/build/src/host/admin_sessions_service.js +35 -3
- package/build/src/host/assets/webauthn.js +2 -0
- package/build/src/host/console_session.d.ts +4 -0
- package/build/src/host/console_session.js +9 -2
- package/build/src/host/controllers/account_confirm_controller.d.ts +11 -14
- package/build/src/host/controllers/account_confirm_controller.js +42 -130
- package/build/src/host/controllers/account_orgs_controller.js +7 -4
- package/build/src/host/controllers/account_security_controller.js +3 -2
- package/build/src/host/controllers/account_session_controller.js +23 -5
- package/build/src/host/controllers/account_tokens_controller.js +11 -0
- package/build/src/host/controllers/pat_introspection_controller.js +10 -0
- package/build/src/host/controllers/webauthn_asset_controller.d.ts +22 -0
- package/build/src/host/controllers/webauthn_asset_controller.js +66 -0
- package/build/src/host/i18n.d.ts +14 -0
- package/build/src/host/i18n.js +16 -0
- package/build/src/host/impersonation_session.js +15 -0
- package/build/src/host/middleware/account_auth.js +3 -1
- package/build/src/host/rate_limit.d.ts +10 -0
- package/build/src/host/rate_limit.js +6 -0
- package/build/src/host/register_auth_host.d.ts +85 -2
- package/build/src/host/register_auth_host.js +188 -61
- package/build/src/host/renderers/edge_renderer.js +6 -1
- package/build/src/host/renderers/inertia_renderer.d.ts +55 -0
- package/build/src/host/renderers/inertia_renderer.js +55 -0
- package/build/src/host/sudo/index.d.ts +47 -0
- package/build/src/host/sudo/index.js +41 -0
- package/build/src/host/sudo/methods/magic_link.d.ts +43 -0
- package/build/src/host/sudo/methods/magic_link.js +174 -0
- package/build/src/host/sudo/methods/oidc_step_up.d.ts +68 -0
- package/build/src/host/sudo/methods/oidc_step_up.js +78 -0
- package/build/src/host/sudo/methods/passkey.d.ts +28 -0
- package/build/src/host/sudo/methods/passkey.js +139 -0
- package/build/src/host/sudo/methods/password.d.ts +19 -0
- package/build/src/host/sudo/methods/password.js +93 -0
- package/build/src/host/sudo/runtime.d.ts +141 -0
- package/build/src/host/sudo/runtime.js +327 -0
- package/build/src/host/sudo/types.d.ts +93 -0
- package/build/src/host/sudo/types.js +1 -0
- package/build/src/host/sudo_mode.d.ts +116 -6
- package/build/src/host/sudo_mode.js +133 -7
- package/package.json +6 -2
- package/build/stubs/ui/edge/views/consent.edge +0 -13
- package/build/stubs/ui/edge/views/login.edge +0 -19
- package/stubs/ui/edge/views/consent.edge +0 -13
- 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
|
+
}
|