@adonis-agora/authkit-server 0.86.0 → 0.88.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/README.md +2 -2
- package/build/host/views/signup.edge +16 -0
- package/build/index.d.ts +1 -2
- package/build/index.js +0 -1
- package/build/src/agents/config.d.ts +1 -1
- package/build/src/agents/delegation_service.js +1 -1
- package/build/src/agents/poppy/client_registry.d.ts +5 -0
- package/build/src/agents/poppy/client_registry.js +35 -1
- package/build/src/agents/poppy/config.d.ts +111 -80
- package/build/src/agents/poppy/config.js +55 -35
- package/build/src/agents/poppy/dpop.d.ts +2 -2
- package/build/src/agents/poppy/dpop.js +3 -3
- package/build/src/agents/poppy/email_code_sign_in.d.ts +4 -4
- package/build/src/agents/poppy/email_code_sign_in.js +5 -5
- package/build/src/agents/poppy/errors.d.ts +7 -2
- package/build/src/agents/poppy/errors.js +5 -2
- package/build/src/agents/poppy/resource.d.ts +30 -25
- package/build/src/agents/poppy/resource.js +24 -20
- package/build/src/agents/poppy/safe_fetch.js +8 -2
- package/build/src/agents/poppy/service.d.ts +69 -62
- package/build/src/agents/poppy/service.js +540 -546
- package/build/src/agents/poppy/session_jwt.d.ts +30 -0
- package/build/src/agents/poppy/session_jwt.js +133 -0
- package/build/src/agents/poppy/store.d.ts +17 -22
- package/build/src/agents/poppy/store.js +22 -30
- package/build/src/agents/poppy/web_session.d.ts +23 -16
- package/build/src/agents/poppy/web_session.js +42 -24
- package/build/src/define_config.d.ts +6 -4
- package/build/src/host/admin_sessions_service.js +3 -3
- package/build/src/host/controllers/account_apps_controller.js +1 -1
- package/build/src/host/controllers/interaction_controller.js +32 -4
- package/build/src/host/controllers/poppy_consent_controller.d.ts +2 -2
- package/build/src/host/controllers/poppy_consent_controller.js +2 -2
- package/build/src/host/controllers/poppy_controller.d.ts +19 -11
- package/build/src/host/controllers/poppy_controller.js +93 -68
- package/build/src/host/controllers/registration_controller.js +30 -7
- package/build/src/host/csrf.d.ts +2 -4
- package/build/src/host/csrf.js +2 -3
- package/build/src/host/register_auth_host.js +10 -5
- package/build/src/host/signup_code.d.ts +41 -0
- package/build/src/host/signup_code.js +56 -0
- package/build/src/schema/ensure.js +14 -9
- package/package.json +3 -3
|
@@ -18,6 +18,7 @@ import { authkitOrigin } from '../origin.js';
|
|
|
18
18
|
import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
|
|
19
19
|
import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
|
|
20
20
|
import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
|
|
21
|
+
import { LOGIN_EMAIL_SESSION_KEY, pendingSignupCode, verifySignupCode } from '../signup_code.js';
|
|
21
22
|
import { SOCIAL_PENDING_KEY, SOCIAL_PENDING_TTL_MS, } from '../social_pending.js';
|
|
22
23
|
import { buildTrustedDevicePayload, isTrustedDeviceValid, TRUSTED_DEVICE_COOKIE, } from '../trusted_device.js';
|
|
23
24
|
import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
|
|
@@ -58,7 +59,7 @@ function accountStatusErrorKey(reason) {
|
|
|
58
59
|
* `toLowerCase`). É o que a tela SEMPRE mostra — nunca o endereço gravado —,
|
|
59
60
|
* para que a canonicalização não vire um oráculo de "esta conta existe".
|
|
60
61
|
*/
|
|
61
|
-
const SESSION_KEY =
|
|
62
|
+
const SESSION_KEY = LOGIN_EMAIL_SESSION_KEY;
|
|
62
63
|
/** accountId aguardando o 2º fator depois da senha verificada. */
|
|
63
64
|
const MFA_PENDING_KEY = 'authkit_mfa_pending';
|
|
64
65
|
/**
|
|
@@ -1397,13 +1398,11 @@ export default class AuthInteractionController {
|
|
|
1397
1398
|
return ctx.response.redirect(`/auth/interaction/${uid}`);
|
|
1398
1399
|
}
|
|
1399
1400
|
const code = String(ctx.request.input('code', '') ?? '').trim();
|
|
1401
|
+
const pendingSignup = pendingSignupCode(ctx, uid, email);
|
|
1400
1402
|
const brand = brandFor(cfg.branding, clientId ?? undefined, undefined);
|
|
1401
1403
|
// Mantém a sub-view do seletor no re-render de erro (o form de código pode
|
|
1402
1404
|
// POSTar `channel=code`). Ausente = both (histórico).
|
|
1403
1405
|
const channel = normalizeLoginChannel(ctx.request.input('channel'));
|
|
1404
|
-
const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
|
|
1405
|
-
maxAttempts: cfg.login.otp.maxAttempts,
|
|
1406
|
-
});
|
|
1407
1406
|
// Re-render da tela "link enviado" com o campo de código + erro localizado.
|
|
1408
1407
|
// `otpEnabled` vem do spread (scoped — OTP é extensão do magic link, e a
|
|
1409
1408
|
// preferência do usuário que desligou magic link já foi barrada acima).
|
|
@@ -1419,6 +1418,35 @@ export default class AuthInteractionController {
|
|
|
1419
1418
|
magicChannel: magicChannelProp(channel),
|
|
1420
1419
|
otpError: translate(cfg.messages, messageKey),
|
|
1421
1420
|
});
|
|
1421
|
+
// Cadastro sem conta ainda: o código certo leva ao mesmo link de confirmação do e-mail.
|
|
1422
|
+
if (pendingSignup) {
|
|
1423
|
+
const signupResult = verifySignupCode(ctx, pendingSignup, code, cfg.login.otp.maxAttempts);
|
|
1424
|
+
if (signupResult === 'ok') {
|
|
1425
|
+
forgetLoginEmail(ctx);
|
|
1426
|
+
const confirmUrl = `/auth/interaction/${uid}/signup/confirm?token=${encodeURIComponent(pendingSignup.token)}`;
|
|
1427
|
+
return ctx.response.redirect().status(303).toPath(confirmUrl);
|
|
1428
|
+
}
|
|
1429
|
+
const signupAudit = {
|
|
1430
|
+
email,
|
|
1431
|
+
ip,
|
|
1432
|
+
clientId,
|
|
1433
|
+
metadata: { stage: 'signup', reason: signupResult },
|
|
1434
|
+
};
|
|
1435
|
+
if (signupResult === 'locked') {
|
|
1436
|
+
await cfg.audit?.record({ type: 'login.otp_invalidated', ...signupAudit });
|
|
1437
|
+
}
|
|
1438
|
+
else {
|
|
1439
|
+
await cfg.audit?.record({ type: 'login.otp_failed', ...signupAudit });
|
|
1440
|
+
}
|
|
1441
|
+
return renderOtpError(signupResult === 'locked'
|
|
1442
|
+
? 'login.otp_locked'
|
|
1443
|
+
: signupResult === 'expired'
|
|
1444
|
+
? 'login.otp_expired'
|
|
1445
|
+
: 'login.otp_invalid');
|
|
1446
|
+
}
|
|
1447
|
+
const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
|
|
1448
|
+
maxAttempts: cfg.login.otp.maxAttempts,
|
|
1449
|
+
});
|
|
1422
1450
|
if (result.status === 'ok') {
|
|
1423
1451
|
// E-mail não verificado (LGPD): mesmo com código válido, não materializa a
|
|
1424
1452
|
// sessão se a política exige verificação. Espelha o magicLinkConsume.
|
|
@@ -2,8 +2,8 @@ import '../augmentations.js';
|
|
|
2
2
|
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
3
|
/**
|
|
4
4
|
* Telas do usuário no Poppy: o consentimento do Direct Sign-In (o
|
|
5
|
-
* `authorization_endpoint`, §4.
|
|
6
|
-
* §4.
|
|
5
|
+
* `authorization_endpoint`, §4.2) e a do Device Sign-In (o `verification_uri`,
|
|
6
|
+
* §4.3). Montadas atrás do `accountGuard`: sem sessão, o usuário passa pelo login
|
|
7
7
|
* DESTE app e volta. Abrir o link nunca aprova sozinho — a aprovação é um POST
|
|
8
8
|
* com CSRF. Recusadas durante impersonation.
|
|
9
9
|
*/
|
|
@@ -14,8 +14,8 @@ function chosenScopes(ctx) {
|
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* Telas do usuário no Poppy: o consentimento do Direct Sign-In (o
|
|
17
|
-
* `authorization_endpoint`, §4.
|
|
18
|
-
* §4.
|
|
17
|
+
* `authorization_endpoint`, §4.2) e a do Device Sign-In (o `verification_uri`,
|
|
18
|
+
* §4.3). Montadas atrás do `accountGuard`: sem sessão, o usuário passa pelo login
|
|
19
19
|
* DESTE app e volta. Abrir o link nunca aprova sozinho — a aprovação é um POST
|
|
20
20
|
* com CSRF. Recusadas durante impersonation.
|
|
21
21
|
*/
|
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
2
|
/**
|
|
3
|
-
* Endpoints
|
|
4
|
-
* token, revogação, device authorization
|
|
5
|
-
*
|
|
3
|
+
* Endpoints do Poppy (§3, §4, §6): descoberta, protected resource metadata, metadata RFC 8414,
|
|
4
|
+
* token, revogação, device authorization e a entrada do navegador na Session. Os POSTs são isentos
|
|
5
|
+
* de CSRF — ver `authkitCsrfExceptions`.
|
|
6
6
|
*/
|
|
7
7
|
export default class PoppyController {
|
|
8
|
-
/** `GET /.well-known/poppy.json` — só no domínio da empresa
|
|
8
|
+
/** `GET /.well-known/poppy.json` — só no domínio da empresa; `www.` redireciona para ele. */
|
|
9
9
|
discovery(ctx: HttpContext): Promise<void>;
|
|
10
|
-
/** `GET /.well-known/oauth-
|
|
10
|
+
/** `GET /.well-known/oauth-protected-resource` do domínio (§3.2, RFC 9728). */
|
|
11
|
+
protectedResource(ctx: HttpContext): Promise<void>;
|
|
12
|
+
/** `GET /.well-known/oauth-authorization-server{prefix}` (RFC 8414 + `protected_resources`). */
|
|
11
13
|
metadata(ctx: HttpContext): Promise<void>;
|
|
12
14
|
token(ctx: HttpContext): Promise<void>;
|
|
13
15
|
deviceAuthorization(ctx: HttpContext): Promise<void>;
|
|
14
16
|
/** RFC 7009: 200 também para token desconhecido. */
|
|
15
17
|
revoke(ctx: HttpContext): Promise<void>;
|
|
16
|
-
/** `POST auth.mediated.endpoint` (§4.7) — com o Session Token (DPoP). */
|
|
17
|
-
mediatedStart(ctx: HttpContext): Promise<void>;
|
|
18
|
-
/** `POST {mediated}/{sign_in_id}` (§4.7). */
|
|
19
|
-
mediatedCode(ctx: HttpContext): Promise<void>;
|
|
20
18
|
/**
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
19
|
+
* `GET web.browser_session_endpoint` (§6, passo 1): a página com o código de uso único e o cookie
|
|
20
|
+
* que o prende a este navegador. Não mexe em Session nem nos outros cookies.
|
|
21
|
+
*/
|
|
22
|
+
browserSessionPage(ctx: HttpContext): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* `POST {browser_session_endpoint}/authorize` (§6, passo 2): o agente autoriza o código, com o
|
|
25
|
+
* header da Session e, se logado, o access token. 204; código inválido → 400 `invalid_request`.
|
|
26
|
+
*/
|
|
27
|
+
browserSessionAuthorize(ctx: HttpContext): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* `POST web.browser_session_endpoint` (§6, passo 3): o navegador submete o código → cookie do app
|
|
30
|
+
* para a Session, limpa o cookie do passo 1 e redireciona para o site; falha → página de erro.
|
|
23
31
|
*/
|
|
24
32
|
browserSession(ctx: HttpContext): Promise<void>;
|
|
25
33
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PoppyError } from '../../agents/poppy/errors.js';
|
|
2
|
-
import { publicRequestUrl, writePoppyAuthError } from '../../agents/poppy/resource.js';
|
|
2
|
+
import { publicRequestUrl, writePoppyAuthError, } from '../../agents/poppy/resource.js';
|
|
3
3
|
import { loginPoppyBrowser } from '../../agents/poppy/web_session.js';
|
|
4
4
|
import { personalAgentsFor } from '../../agents/runtime.js';
|
|
5
5
|
function noStore(ctx) {
|
|
@@ -15,6 +15,9 @@ function oauthError(ctx, error) {
|
|
|
15
15
|
noStore(ctx);
|
|
16
16
|
for (const [k, v] of Object.entries(error.headers))
|
|
17
17
|
ctx.response.header(k, v);
|
|
18
|
+
// Limite de taxa: 429 com `Retry-After`, sem corpo próprio (§5.6).
|
|
19
|
+
if (error.status === 429)
|
|
20
|
+
return ctx.response.status(429).send('');
|
|
18
21
|
if (error.code === 'invalid_dpop_proof' || error.code === 'use_dpop_nonce') {
|
|
19
22
|
ctx.response.header('WWW-Authenticate', `DPoP error="${error.code}"`);
|
|
20
23
|
}
|
|
@@ -31,25 +34,43 @@ function form(ctx) {
|
|
|
31
34
|
return ctx.request.all();
|
|
32
35
|
}
|
|
33
36
|
/**
|
|
34
|
-
* Endpoints
|
|
35
|
-
* token, revogação, device authorization
|
|
36
|
-
*
|
|
37
|
+
* Endpoints do Poppy (§3, §4, §6): descoberta, protected resource metadata, metadata RFC 8414,
|
|
38
|
+
* token, revogação, device authorization e a entrada do navegador na Session. Os POSTs são isentos
|
|
39
|
+
* de CSRF — ver `authkitCsrfExceptions`.
|
|
37
40
|
*/
|
|
38
41
|
export default class PoppyController {
|
|
39
|
-
/** `GET /.well-known/poppy.json` — só no domínio da empresa
|
|
42
|
+
/** `GET /.well-known/poppy.json` — só no domínio da empresa; `www.` redireciona para ele. */
|
|
40
43
|
async discovery(ctx) {
|
|
41
44
|
const poppy = await poppyOr404(ctx);
|
|
42
45
|
if (!poppy)
|
|
43
46
|
return;
|
|
44
47
|
// `host()` mantém a porta (dev em `localhost:3333`): o `browser_session_endpoint` sai dele.
|
|
45
|
-
const
|
|
48
|
+
const host = ctx.request.host() ?? ctx.request.hostname() ?? '';
|
|
49
|
+
const redirect = poppy.discoveryRedirect(host);
|
|
50
|
+
if (redirect) {
|
|
51
|
+
ctx.response.header('Location', redirect);
|
|
52
|
+
return ctx.response.status(308).send('');
|
|
53
|
+
}
|
|
54
|
+
const doc = poppy.discoveryDocument(host);
|
|
46
55
|
if (!doc)
|
|
47
56
|
return ctx.response.notFound();
|
|
48
57
|
ctx.response.header('Access-Control-Allow-Origin', '*');
|
|
49
58
|
ctx.response.header('Cache-Control', 'public, max-age=300');
|
|
50
59
|
return ctx.response.json(doc);
|
|
51
60
|
}
|
|
52
|
-
/** `GET /.well-known/oauth-
|
|
61
|
+
/** `GET /.well-known/oauth-protected-resource` do domínio (§3.2, RFC 9728). */
|
|
62
|
+
async protectedResource(ctx) {
|
|
63
|
+
const poppy = await poppyOr404(ctx);
|
|
64
|
+
if (!poppy)
|
|
65
|
+
return;
|
|
66
|
+
const doc = poppy.protectedResourceMetadata(ctx.request.host() ?? ctx.request.hostname() ?? '');
|
|
67
|
+
if (!doc)
|
|
68
|
+
return ctx.response.notFound();
|
|
69
|
+
ctx.response.header('Access-Control-Allow-Origin', '*');
|
|
70
|
+
ctx.response.header('Cache-Control', 'public, max-age=300');
|
|
71
|
+
return ctx.response.json(doc);
|
|
72
|
+
}
|
|
73
|
+
/** `GET /.well-known/oauth-authorization-server{prefix}` (RFC 8414 + `protected_resources`). */
|
|
53
74
|
async metadata(ctx) {
|
|
54
75
|
const poppy = await poppyOr404(ctx);
|
|
55
76
|
if (!poppy)
|
|
@@ -64,7 +85,7 @@ export default class PoppyController {
|
|
|
64
85
|
return;
|
|
65
86
|
withNonce(ctx, poppy);
|
|
66
87
|
try {
|
|
67
|
-
const body = await poppy.token(form(ctx), ctx.request.header('dpop'));
|
|
88
|
+
const body = await poppy.token(form(ctx), ctx.request.header('dpop'), ctx);
|
|
68
89
|
noStore(ctx);
|
|
69
90
|
return ctx.response.json(body);
|
|
70
91
|
}
|
|
@@ -105,89 +126,93 @@ export default class PoppyController {
|
|
|
105
126
|
throw error;
|
|
106
127
|
}
|
|
107
128
|
}
|
|
108
|
-
/**
|
|
109
|
-
|
|
129
|
+
/**
|
|
130
|
+
* `GET web.browser_session_endpoint` (§6, passo 1): a página com o código de uso único e o cookie
|
|
131
|
+
* que o prende a este navegador. Não mexe em Session nem nos outros cookies.
|
|
132
|
+
*/
|
|
133
|
+
async browserSessionPage(ctx) {
|
|
110
134
|
const poppy = await poppyOr404(ctx);
|
|
111
|
-
if (!poppy?.cfg.
|
|
135
|
+
if (!poppy?.cfg.web?.browserSession)
|
|
112
136
|
return poppy ? ctx.response.notFound() : undefined;
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const body = await poppy.mediatedStart(auth.principal, auth.jkt, ctx.request.body(), ctx);
|
|
124
|
-
noStore(ctx);
|
|
125
|
-
return ctx.response.json(body);
|
|
126
|
-
}
|
|
127
|
-
catch (error) {
|
|
128
|
-
if (error instanceof PoppyError)
|
|
129
|
-
return oauthError(ctx, error);
|
|
130
|
-
throw error;
|
|
131
|
-
}
|
|
137
|
+
const { code, cookie, maxAge } = await poppy.startBrowserJoin();
|
|
138
|
+
const name = joinCookieName(poppy);
|
|
139
|
+
ctx.response.append('Set-Cookie', `${name}=${cookie}; Path=/; Max-Age=${maxAge}; ${cookieSecure(poppy)}HttpOnly; SameSite=Strict`);
|
|
140
|
+
browserHeaders(ctx);
|
|
141
|
+
// `code` só tem caracteres base64url; o `action` é o path do próprio endpoint.
|
|
142
|
+
const action = new URL(poppy.urls.browserSession).pathname;
|
|
143
|
+
return ctx.response
|
|
144
|
+
.status(200)
|
|
145
|
+
.send(`<!doctype html><html><head><meta charset="utf-8"><title>Connecting</title></head><body><form method="post" action="${action}"><input type="hidden" name="code" value="${code}"><button>Continue</button></form></body></html>`);
|
|
132
146
|
}
|
|
133
|
-
/**
|
|
134
|
-
|
|
147
|
+
/**
|
|
148
|
+
* `POST {browser_session_endpoint}/authorize` (§6, passo 2): o agente autoriza o código, com o
|
|
149
|
+
* header da Session e, se logado, o access token. 204; código inválido → 400 `invalid_request`.
|
|
150
|
+
*/
|
|
151
|
+
async browserSessionAuthorize(ctx) {
|
|
135
152
|
const poppy = await poppyOr404(ctx);
|
|
136
|
-
if (!poppy?.cfg.
|
|
153
|
+
if (!poppy?.cfg.web?.browserSession)
|
|
137
154
|
return poppy ? ctx.response.notFound() : undefined;
|
|
138
|
-
|
|
139
|
-
method: ctx.request.method(),
|
|
140
|
-
url: publicRequestUrl(ctx, poppy),
|
|
141
|
-
headers: ctx.request.headers(),
|
|
142
|
-
});
|
|
143
|
-
if (!auth.ok) {
|
|
144
|
-
const { ok: _ok, ...rest } = auth;
|
|
145
|
-
return writePoppyAuthError(ctx, { ok: false, ...rest });
|
|
146
|
-
}
|
|
155
|
+
noStore(ctx);
|
|
147
156
|
try {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return ctx.response.
|
|
157
|
+
const result = await poppy.authorizeBrowserJoin({
|
|
158
|
+
method: ctx.request.method(),
|
|
159
|
+
url: publicRequestUrl(ctx, poppy),
|
|
160
|
+
headers: ctx.request.headers(),
|
|
161
|
+
}, ctx.request.body()?.code);
|
|
162
|
+
if (!result.ok)
|
|
163
|
+
return writePoppyAuthError(ctx, result);
|
|
164
|
+
return ctx.response.status(204).send('');
|
|
156
165
|
}
|
|
157
166
|
catch (error) {
|
|
158
|
-
if (error instanceof PoppyError)
|
|
159
|
-
return
|
|
167
|
+
if (error instanceof PoppyError) {
|
|
168
|
+
return ctx.response.status(400).json({ error: 'invalid_request' });
|
|
169
|
+
}
|
|
160
170
|
throw error;
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
/**
|
|
164
|
-
* `POST web.browser_session_endpoint` (§
|
|
165
|
-
* para a Session e
|
|
174
|
+
* `POST web.browser_session_endpoint` (§6, passo 3): o navegador submete o código → cookie do app
|
|
175
|
+
* para a Session, limpa o cookie do passo 1 e redireciona para o site; falha → página de erro.
|
|
166
176
|
*/
|
|
167
177
|
async browserSession(ctx) {
|
|
168
178
|
const poppy = await poppyOr404(ctx);
|
|
169
179
|
if (!poppy?.cfg.web?.browserSession)
|
|
170
180
|
return poppy ? ctx.response.notFound() : undefined;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
181
|
+
browserHeaders(ctx);
|
|
182
|
+
const name = joinCookieName(poppy);
|
|
183
|
+
const result = await poppy.completeBrowserJoin(ctx.request.input('code'), readCookie(ctx.request.header('cookie'), name));
|
|
184
|
+
if (!result)
|
|
174
185
|
return badBrowserRequest(ctx);
|
|
175
|
-
let result;
|
|
176
|
-
try {
|
|
177
|
-
result = await poppy.verifyBrowserAssertion(ctx.request.input('assertion'), new URL(publicRequestUrl(ctx, poppy)).origin);
|
|
178
|
-
}
|
|
179
|
-
catch (error) {
|
|
180
|
-
if (error instanceof PoppyError)
|
|
181
|
-
return badBrowserRequest(ctx);
|
|
182
|
-
throw error;
|
|
183
|
-
}
|
|
184
186
|
await loginPoppyBrowser(ctx, result);
|
|
185
|
-
ctx.response.
|
|
187
|
+
ctx.response.append('Set-Cookie', `${name}=; Path=/; Max-Age=0; ${cookieSecure(poppy)}HttpOnly; SameSite=Strict`);
|
|
188
|
+
ctx.response.header('Location', poppy.cfg.web.redirectTo);
|
|
186
189
|
return ctx.response.status(303).send('');
|
|
187
190
|
}
|
|
188
191
|
}
|
|
189
|
-
|
|
192
|
+
/** `__Host-` exige `Secure`; em dev com o issuer em http o cookie sai sem os dois. */
|
|
193
|
+
function joinCookieName(poppy) {
|
|
194
|
+
return new URL(poppy.urls.origin).protocol === 'https:' ? '__Host-poppy_join' : 'poppy_join';
|
|
195
|
+
}
|
|
196
|
+
function cookieSecure(poppy) {
|
|
197
|
+
return new URL(poppy.urls.origin).protocol === 'https:' ? 'Secure; ' : '';
|
|
198
|
+
}
|
|
199
|
+
function readCookie(header, name) {
|
|
200
|
+
for (const part of (header ?? '').split(';')) {
|
|
201
|
+
const eq = part.indexOf('=');
|
|
202
|
+
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
203
|
+
return part.slice(eq + 1).trim();
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
/** Sem cache, sem moldura e sem referência da página que abriu (§6, passo 1). */
|
|
208
|
+
function browserHeaders(ctx) {
|
|
209
|
+
noStore(ctx);
|
|
190
210
|
ctx.response.header('Content-Type', 'text/html; charset=utf-8');
|
|
211
|
+
ctx.response.header('Content-Security-Policy', "frame-ancestors 'none'");
|
|
212
|
+
ctx.response.header('Cross-Origin-Opener-Policy', 'same-origin');
|
|
213
|
+
ctx.response.header('Referrer-Policy', 'no-referrer');
|
|
214
|
+
}
|
|
215
|
+
function badBrowserRequest(ctx) {
|
|
191
216
|
return ctx.response
|
|
192
217
|
.status(400)
|
|
193
218
|
.send('<!doctype html><html><head><meta charset="utf-8"><title>Bad request</title></head><body><h1>Invalid browser session request</h1></body></html>');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import '../augmentations.js';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import { supportsAccountDeletion, supportsMagicLink } from '../../accounts/account_store.js';
|
|
3
|
+
import { supportsAccountDeletion, supportsMagicLink, supportsOtpLogin, } from '../../accounts/account_store.js';
|
|
4
4
|
import { PasswordPolicyError } from '../../password/password_manager.js';
|
|
5
5
|
import { AdminSessionsService } from '../admin_sessions_service.js';
|
|
6
6
|
import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
|
|
@@ -12,6 +12,7 @@ import { authkitOrigin } from '../origin.js';
|
|
|
12
12
|
import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
|
|
13
13
|
import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, } from '../runtime_toggles.js';
|
|
14
14
|
import { dispatchSecurityNotice } from '../security_notice_service.js';
|
|
15
|
+
import { LOGIN_EMAIL_SESSION_KEY, SIGNUP_CODE_SESSION_KEY, startSignupCode, } from '../signup_code.js';
|
|
15
16
|
import { issueSignupToken, readSignupToken } from '../signup_token.js';
|
|
16
17
|
import { forgotPasswordValidator, passwordlessSignupValidator, resetPasswordValidator, signupValidator, } from '../validators.js';
|
|
17
18
|
/**
|
|
@@ -221,7 +222,8 @@ export default class AuthRegistrationController {
|
|
|
221
222
|
* Cadastro passwordless: valida e-mail + nome e envia um link. Conta existente
|
|
222
223
|
* recebe um magic link de login; e-mail novo recebe o link de confirmação do
|
|
223
224
|
* cadastro (`signupConfirm`), e só então a conta é criada. Sempre responde
|
|
224
|
-
* "link enviado" (anti-enumeração), exista a conta ou não.
|
|
225
|
+
* "link enviado" (anti-enumeração), exista a conta ou não. Com `login.otp` ligado,
|
|
226
|
+
* os dois casos também levam um código de digitar (`otpVerify`).
|
|
225
227
|
*/
|
|
226
228
|
async #passwordlessSignup(ctx, deps) {
|
|
227
229
|
const { cfg, brand } = deps;
|
|
@@ -230,19 +232,30 @@ export default class AuthRegistrationController {
|
|
|
230
232
|
const uid = ctx.request.param('uid');
|
|
231
233
|
const data = await ctx.request.validateUsing(passwordlessSignupValidator);
|
|
232
234
|
const origin = authkitOrigin(cfg);
|
|
235
|
+
// Com OTP ligado, o e-mail leva também um código, para concluir neste navegador.
|
|
236
|
+
const otpEnabled = cfg.login?.otp?.enabled === true && supportsOtpLogin(accountStore);
|
|
233
237
|
const existing = await accountStore.findByEmail(data.email);
|
|
238
|
+
ctx.session.forget(SIGNUP_CODE_SESSION_KEY);
|
|
239
|
+
if (otpEnabled)
|
|
240
|
+
ctx.session.put(LOGIN_EMAIL_SESSION_KEY, data.email);
|
|
234
241
|
if (existing) {
|
|
235
242
|
// Conta já existe: o "cadastro" vira um login por magic link.
|
|
236
243
|
// `data.email` já é a forma normalizada, a mesma do login.
|
|
237
|
-
const issued =
|
|
244
|
+
const issued = otpEnabled
|
|
245
|
+
? await accountStore.issueMagicLinkWithCode(data.email, uid, {
|
|
246
|
+
digits: cfg.login.otp.digits,
|
|
247
|
+
ttlMinutes: cfg.login.otp.ttlMinutes,
|
|
248
|
+
})
|
|
249
|
+
: await accountStore.issueMagicLinkToken(data.email);
|
|
238
250
|
if (issued) {
|
|
251
|
+
const code = 'code' in issued ? issued.code : undefined;
|
|
239
252
|
const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
|
|
240
253
|
const to = issued.account.email;
|
|
241
254
|
if (cfg.mail?.onMagicLink) {
|
|
242
|
-
await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
|
|
255
|
+
await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token, code });
|
|
243
256
|
}
|
|
244
257
|
else {
|
|
245
|
-
await sendMagicLinkEmail(ctx, { email: to, magicUrl });
|
|
258
|
+
await sendMagicLinkEmail(ctx, { email: to, magicUrl, code });
|
|
246
259
|
}
|
|
247
260
|
}
|
|
248
261
|
}
|
|
@@ -253,11 +266,20 @@ export default class AuthRegistrationController {
|
|
|
253
266
|
// e-mail de outra pessoa não cria nada.
|
|
254
267
|
const token = await issueSignupToken({ email: data.email, fullName: data.fullName ?? null });
|
|
255
268
|
const magicUrl = `${origin}/auth/interaction/${uid}/signup/confirm?token=${encodeURIComponent(token)}`;
|
|
269
|
+
const code = otpEnabled
|
|
270
|
+
? startSignupCode(ctx, {
|
|
271
|
+
uid,
|
|
272
|
+
email: data.email,
|
|
273
|
+
token,
|
|
274
|
+
digits: cfg.login.otp.digits,
|
|
275
|
+
ttlMinutes: cfg.login.otp.ttlMinutes,
|
|
276
|
+
})
|
|
277
|
+
: undefined;
|
|
256
278
|
if (cfg.mail?.onMagicLink) {
|
|
257
|
-
await cfg.mail.onMagicLink({ email: data.email, magicUrl, token, kind: 'signup' });
|
|
279
|
+
await cfg.mail.onMagicLink({ email: data.email, magicUrl, token, code, kind: 'signup' });
|
|
258
280
|
}
|
|
259
281
|
else {
|
|
260
|
-
await sendMagicLinkEmail(ctx, { email: data.email, magicUrl });
|
|
282
|
+
await sendMagicLinkEmail(ctx, { email: data.email, magicUrl, code });
|
|
261
283
|
}
|
|
262
284
|
}
|
|
263
285
|
// Resposta uniforme: "enviamos um link" (não vaza existência da conta).
|
|
@@ -266,6 +288,7 @@ export default class AuthRegistrationController {
|
|
|
266
288
|
csrfToken: ctx.request.csrfToken,
|
|
267
289
|
brand,
|
|
268
290
|
magicLinkSent: true,
|
|
291
|
+
otpEnabled,
|
|
269
292
|
});
|
|
270
293
|
}
|
|
271
294
|
/**
|
package/build/src/host/csrf.d.ts
CHANGED
|
@@ -11,10 +11,8 @@ export interface AuthkitCsrfOptions {
|
|
|
11
11
|
*/
|
|
12
12
|
personalAgentsPrefix?: string | false;
|
|
13
13
|
/**
|
|
14
|
-
* Prefixo do Poppy: isenta token/revoke/device authorization
|
|
15
|
-
*
|
|
16
|
-
* Default: o do config. As telas `{prefix}/oauth/authorize` e `{prefix}/device`
|
|
17
|
-
* continuam protegidas. `false` desliga.
|
|
14
|
+
* Prefixo do Poppy: isenta token/revoke/device authorization e a entrada do navegador
|
|
15
|
+
* (o código com o cookie `SameSite=Strict` é o que protege, §6).
|
|
18
16
|
*/
|
|
19
17
|
poppyPrefix?: string | false;
|
|
20
18
|
}
|
package/build/src/host/csrf.js
CHANGED
|
@@ -44,9 +44,8 @@ export function authkitCsrfExceptions(url, options = {}) {
|
|
|
44
44
|
(path === `${poppyPrefix}/oauth/token` ||
|
|
45
45
|
path === `${poppyPrefix}/oauth/revoke` ||
|
|
46
46
|
path === `${poppyPrefix}/oauth/device` ||
|
|
47
|
-
path === `${poppyPrefix}/
|
|
48
|
-
path
|
|
49
|
-
path === `${poppyPrefix}/browser-session`);
|
|
47
|
+
path === `${poppyPrefix}/browser-session` ||
|
|
48
|
+
path === `${poppyPrefix}/browser-session/authorize`);
|
|
50
49
|
return (url.includes(mountPath) ||
|
|
51
50
|
url.includes('/authkit/pat') ||
|
|
52
51
|
(backchannel !== null && url === backchannel) ||
|
|
@@ -573,6 +573,9 @@ export function registerAuthHost(router, opts = {}) {
|
|
|
573
573
|
// Nomes explícitos: o Router nomeia pela dupla controller.método, e as duas URLs da metadata
|
|
574
574
|
// (RFC 8414 na raiz e debaixo do issuer) usam o mesmo — "poppy.metadata already exists" no boot.
|
|
575
575
|
router.get('/.well-known/poppy.json', [C.poppy, 'discovery']).as('authkit.poppy.discovery');
|
|
576
|
+
router
|
|
577
|
+
.get('/.well-known/oauth-protected-resource', [C.poppy, 'protectedResource'])
|
|
578
|
+
.as('authkit.poppy.protected_resource');
|
|
576
579
|
router
|
|
577
580
|
.get(`/.well-known/oauth-authorization-server${poppyPrefix}`, [C.poppy, 'metadata'])
|
|
578
581
|
.as('authkit.poppy.metadata');
|
|
@@ -584,14 +587,16 @@ export function registerAuthHost(router, opts = {}) {
|
|
|
584
587
|
router
|
|
585
588
|
.post(`${poppyPrefix}/oauth/device`, [C.poppy, 'deviceAuthorization'])
|
|
586
589
|
.as('authkit.poppy.device_authorization');
|
|
587
|
-
//
|
|
588
|
-
withLogin(router
|
|
589
|
-
|
|
590
|
-
.
|
|
591
|
-
.as('authkit.poppy.sign_in_code'));
|
|
590
|
+
// A página grava um pedido sem autenticação: throttle por IP.
|
|
591
|
+
withLogin(router
|
|
592
|
+
.get(`${poppyPrefix}/browser-session`, [C.poppy, 'browserSessionPage'])
|
|
593
|
+
.as('authkit.poppy.browser_session_page'));
|
|
592
594
|
router
|
|
593
595
|
.post(`${poppyPrefix}/browser-session`, [C.poppy, 'browserSession'])
|
|
594
596
|
.as('authkit.poppy.browser_session');
|
|
597
|
+
router
|
|
598
|
+
.post(`${poppyPrefix}/browser-session/authorize`, [C.poppy, 'browserSessionAuthorize'])
|
|
599
|
+
.as('authkit.poppy.browser_session_authorize');
|
|
595
600
|
router
|
|
596
601
|
.group(() => {
|
|
597
602
|
router
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
/**
|
|
3
|
+
* Código digitado do cadastro passwordless.
|
|
4
|
+
*
|
|
5
|
+
* Quem abre o link do e-mail noutro navegador termina logado longe de onde começou (um convite
|
|
6
|
+
* aberto dentro do WhatsApp, por exemplo). Com o código, a pessoa conclui no mesmo navegador e na
|
|
7
|
+
* mesma interaction. A conta ainda não existe, então não há slot no store: o pendente fica na sessão
|
|
8
|
+
* deste navegador, e o código correto só libera o mesmo link de confirmação que foi por e-mail.
|
|
9
|
+
*
|
|
10
|
+
* As tentativas ficam na sessão. Num driver de cookie dá para repetir um cookie antigo e zerá-las,
|
|
11
|
+
* e o limite que sobra é o throttle da rota `otp-verify`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SIGNUP_CODE_SESSION_KEY = "authkit_signup_code";
|
|
14
|
+
/** Onde o login guarda o e-mail da interaction: é o que o `otp-verify` lê. */
|
|
15
|
+
export declare const LOGIN_EMAIL_SESSION_KEY = "authkit_login_email";
|
|
16
|
+
export interface PendingSignupCode {
|
|
17
|
+
uid: string;
|
|
18
|
+
email: string;
|
|
19
|
+
/** O token do link de confirmação (já cifrado). */
|
|
20
|
+
token: string;
|
|
21
|
+
codeHash: string;
|
|
22
|
+
/** Epoch em ms. */
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
attempts: number;
|
|
25
|
+
}
|
|
26
|
+
export type SignupCodeResult = 'ok' | 'invalid' | 'expired' | 'locked';
|
|
27
|
+
/** Gera o código e o guarda na sessão, presos à interaction e ao token. Devolve o código. */
|
|
28
|
+
export declare function startSignupCode(ctx: HttpContext, input: {
|
|
29
|
+
uid: string;
|
|
30
|
+
email: string;
|
|
31
|
+
token: string;
|
|
32
|
+
digits: number;
|
|
33
|
+
ttlMinutes: number;
|
|
34
|
+
}): string;
|
|
35
|
+
/**
|
|
36
|
+
* O cadastro pendente desta interaction. Se a pessoa voltou e digitou outro e-mail no login, o
|
|
37
|
+
* e-mail da sessão muda e o código que vale é o do login.
|
|
38
|
+
*/
|
|
39
|
+
export declare function pendingSignupCode(ctx: HttpContext, uid: string, sessionEmail: string | undefined): PendingSignupCode | null;
|
|
40
|
+
/** Confere o código e conta a tentativa. Ao travar, nem o código certo entra; o link continua. */
|
|
41
|
+
export declare function verifySignupCode(ctx: HttpContext, pending: PendingSignupCode, code: string, maxAttempts: number): SignupCodeResult;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Código digitado do cadastro passwordless.
|
|
4
|
+
*
|
|
5
|
+
* Quem abre o link do e-mail noutro navegador termina logado longe de onde começou (um convite
|
|
6
|
+
* aberto dentro do WhatsApp, por exemplo). Com o código, a pessoa conclui no mesmo navegador e na
|
|
7
|
+
* mesma interaction. A conta ainda não existe, então não há slot no store: o pendente fica na sessão
|
|
8
|
+
* deste navegador, e o código correto só libera o mesmo link de confirmação que foi por e-mail.
|
|
9
|
+
*
|
|
10
|
+
* As tentativas ficam na sessão. Num driver de cookie dá para repetir um cookie antigo e zerá-las,
|
|
11
|
+
* e o limite que sobra é o throttle da rota `otp-verify`.
|
|
12
|
+
*/
|
|
13
|
+
export const SIGNUP_CODE_SESSION_KEY = 'authkit_signup_code';
|
|
14
|
+
/** Onde o login guarda o e-mail da interaction: é o que o `otp-verify` lê. */
|
|
15
|
+
export const LOGIN_EMAIL_SESSION_KEY = 'authkit_login_email';
|
|
16
|
+
function hashCode(uid, token, code) {
|
|
17
|
+
return createHash('sha256').update(`${uid}\u0000${token}\u0000${code}`).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
/** Gera o código e o guarda na sessão, presos à interaction e ao token. Devolve o código. */
|
|
20
|
+
export function startSignupCode(ctx, input) {
|
|
21
|
+
const code = String(randomInt(0, 10 ** input.digits)).padStart(input.digits, '0');
|
|
22
|
+
const pending = {
|
|
23
|
+
uid: input.uid,
|
|
24
|
+
email: input.email,
|
|
25
|
+
token: input.token,
|
|
26
|
+
codeHash: hashCode(input.uid, input.token, code),
|
|
27
|
+
expiresAt: Date.now() + input.ttlMinutes * 60_000,
|
|
28
|
+
attempts: 0,
|
|
29
|
+
};
|
|
30
|
+
ctx.session.put(SIGNUP_CODE_SESSION_KEY, pending);
|
|
31
|
+
return code;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* O cadastro pendente desta interaction. Se a pessoa voltou e digitou outro e-mail no login, o
|
|
35
|
+
* e-mail da sessão muda e o código que vale é o do login.
|
|
36
|
+
*/
|
|
37
|
+
export function pendingSignupCode(ctx, uid, sessionEmail) {
|
|
38
|
+
const pending = ctx.session.get(SIGNUP_CODE_SESSION_KEY);
|
|
39
|
+
return pending?.uid === uid && pending.email === sessionEmail ? pending : null;
|
|
40
|
+
}
|
|
41
|
+
/** Confere o código e conta a tentativa. Ao travar, nem o código certo entra; o link continua. */
|
|
42
|
+
export function verifySignupCode(ctx, pending, code, maxAttempts) {
|
|
43
|
+
if (pending.attempts >= maxAttempts)
|
|
44
|
+
return 'locked';
|
|
45
|
+
if (Date.now() >= pending.expiresAt)
|
|
46
|
+
return 'expired';
|
|
47
|
+
const expected = Buffer.from(pending.codeHash);
|
|
48
|
+
const given = Buffer.from(hashCode(pending.uid, pending.token, code));
|
|
49
|
+
if (expected.length === given.length && timingSafeEqual(expected, given)) {
|
|
50
|
+
ctx.session.forget(SIGNUP_CODE_SESSION_KEY);
|
|
51
|
+
return 'ok';
|
|
52
|
+
}
|
|
53
|
+
const attempts = pending.attempts + 1;
|
|
54
|
+
ctx.session.put(SIGNUP_CODE_SESSION_KEY, { ...pending, attempts });
|
|
55
|
+
return attempts >= maxAttempts ? 'locked' : 'invalid';
|
|
56
|
+
}
|