@adonis-agora/authkit-server 0.45.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.
- 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/partials/styles.edge +1 -1
- package/build/index.d.ts +5 -1
- package/build/index.js +15 -1
- package/build/src/define_config.d.ts +51 -0
- package/build/src/define_config.js +8 -0
- package/build/src/host/assets/webauthn.js +2 -0
- 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_session_controller.js +20 -4
- 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/rate_limit.d.ts +10 -0
- package/build/src/host/rate_limit.js +6 -0
- package/build/src/host/register_auth_host.d.ts +19 -0
- package/build/src/host/register_auth_host.js +72 -4
- 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,41 @@
|
|
|
1
|
+
import { password } from './methods/password.js';
|
|
2
|
+
import { passkey } from './methods/passkey.js';
|
|
3
|
+
import { oidcStepUp } from './methods/oidc_step_up.js';
|
|
4
|
+
import { magicLink } from './methods/magic_link.js';
|
|
5
|
+
/**
|
|
6
|
+
* Métodos de confirmação de identidade (sudo mode), no mesmo padrão de factory
|
|
7
|
+
* usado em `stores.*` e `retrievers.*` das libs irmãs.
|
|
8
|
+
*
|
|
9
|
+
* A lista vai em DOIS lugares, e eles precisam casar:
|
|
10
|
+
*
|
|
11
|
+
* - `config/authkit.ts` → `sudo.methods` decide o que a TELA oferece e o que os
|
|
12
|
+
* handlers ACEITAM;
|
|
13
|
+
* - `registerAuthHost(router, { sudoMethods })` decide o que tem ROTA montada.
|
|
14
|
+
*
|
|
15
|
+
* São dois porque a montagem de rotas acontece antes de o config (lazy)
|
|
16
|
+
* resolver — mesma razão de `social`/`admin`/`rateLimit`. Divergiram, a tela
|
|
17
|
+
* loga um aviso de flag-drift e o endpoint faltante dá 404.
|
|
18
|
+
*
|
|
19
|
+
* SEM `config.sudo.methods` não há como divergir: a tela cai na própria lista
|
|
20
|
+
* montada por `registerAuthHost`, a mesma que os handlers aceitam.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* defineConfig({
|
|
24
|
+
* sudo: {
|
|
25
|
+
* methods: [
|
|
26
|
+
* sudoMethods.oidcStepUp({ url: '/auth/step-up' }),
|
|
27
|
+
* sudoMethods.magicLink(),
|
|
28
|
+
* sudoMethods.passkey(),
|
|
29
|
+
* sudoMethods.password(),
|
|
30
|
+
* ],
|
|
31
|
+
* },
|
|
32
|
+
* })
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export const sudoMethods = { password, passkey, oidcStepUp, magicLink };
|
|
36
|
+
/**
|
|
37
|
+
* Montagem do `SudoContext` a partir do `HttpContext`. Reexportado aqui por
|
|
38
|
+
* simetria com `sudoMethods` e os tipos: quem escreve um método (ou a rota de
|
|
39
|
+
* callback do `oidcStepUp`) precisa dos três.
|
|
40
|
+
*/
|
|
41
|
+
export { sudoContextFrom } from './runtime.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SudoContext, SudoMethod } from '../types.js';
|
|
2
|
+
/** Token de sudo pendente, guardado na sessão que o pediu. */
|
|
3
|
+
export declare const SUDO_LINK_SESSION_KEY = "authkit_sudo_link";
|
|
4
|
+
/** Mesma janela dos magic links de login. */
|
|
5
|
+
export declare const SUDO_LINK_TTL_MS: number;
|
|
6
|
+
/**
|
|
7
|
+
* Emite um token de sudo e guarda o HASH na sessão que pediu.
|
|
8
|
+
*
|
|
9
|
+
* O TOKEN É PRÓPRIO, DE ESCOPO SUDO — nunca o token de login
|
|
10
|
+
* (`issueMagicLinkToken`/`consumeMagicLinkToken` do `AccountStore`). Aquele é
|
|
11
|
+
* credencial de AUTENTICAÇÃO: reusá-lo faria de um link de sudo vazado uma
|
|
12
|
+
* sessão completa.
|
|
13
|
+
*
|
|
14
|
+
* | propriedade | valor | razão |
|
|
15
|
+
* |---|---|---|
|
|
16
|
+
* | geração | `randomBytes(32)` hex | entropia de credencial |
|
|
17
|
+
* | armazenamento | HASH na sessão que pediu | não guarda o segredo em claro |
|
|
18
|
+
* | escopo | só marca sudo | nunca autentica |
|
|
19
|
+
* | validade | 5 min | mesma janela dos magic links de login |
|
|
20
|
+
* | uso | único (apagado no consumo) | replay |
|
|
21
|
+
* | navegador | só o mesmo (vive na sessão) | step-up é reprova de QUEM ESTÁ ALI |
|
|
22
|
+
* | conta | vinculado ao `accountId` emissor | sessão sobrevive à troca de conta |
|
|
23
|
+
*
|
|
24
|
+
* O "só mesmo navegador" é propriedade desejada aqui, diferente do magic link
|
|
25
|
+
* de login, onde é limitação conhecida.
|
|
26
|
+
*
|
|
27
|
+
* Exportada (em vez de membro `__` do método) para ser testável sem furar a
|
|
28
|
+
* API pública do `SudoMethod`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function issueSudoLinkToken(c: SudoContext): string;
|
|
31
|
+
/**
|
|
32
|
+
* Consome o token: single-use, vinculado à conta emissora, expira em 5 min,
|
|
33
|
+
* comparação em tempo constante.
|
|
34
|
+
*/
|
|
35
|
+
export declare function verifySudoLinkToken(c: SudoContext, token: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Confirmação por link enviado ao e-mail da conta.
|
|
38
|
+
*
|
|
39
|
+
* Depende do hook `mail.onSudoLink`, DISTINTO de `mail.onMagicLink` justamente
|
|
40
|
+
* para que o host não confunda um link que autentica com um que só concede
|
|
41
|
+
* sudo a quem já está logado. Sem o hook, o método fica indisponível.
|
|
42
|
+
*/
|
|
43
|
+
export declare function magicLink(): SudoMethod;
|
|
@@ -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;
|