@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.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/build/host/views/signup.edge +16 -0
  3. package/build/index.d.ts +1 -2
  4. package/build/index.js +0 -1
  5. package/build/src/agents/config.d.ts +1 -1
  6. package/build/src/agents/delegation_service.js +1 -1
  7. package/build/src/agents/poppy/client_registry.d.ts +5 -0
  8. package/build/src/agents/poppy/client_registry.js +35 -1
  9. package/build/src/agents/poppy/config.d.ts +111 -80
  10. package/build/src/agents/poppy/config.js +55 -35
  11. package/build/src/agents/poppy/dpop.d.ts +2 -2
  12. package/build/src/agents/poppy/dpop.js +3 -3
  13. package/build/src/agents/poppy/email_code_sign_in.d.ts +4 -4
  14. package/build/src/agents/poppy/email_code_sign_in.js +5 -5
  15. package/build/src/agents/poppy/errors.d.ts +7 -2
  16. package/build/src/agents/poppy/errors.js +5 -2
  17. package/build/src/agents/poppy/resource.d.ts +30 -25
  18. package/build/src/agents/poppy/resource.js +24 -20
  19. package/build/src/agents/poppy/safe_fetch.js +8 -2
  20. package/build/src/agents/poppy/service.d.ts +69 -62
  21. package/build/src/agents/poppy/service.js +540 -546
  22. package/build/src/agents/poppy/session_jwt.d.ts +30 -0
  23. package/build/src/agents/poppy/session_jwt.js +133 -0
  24. package/build/src/agents/poppy/store.d.ts +17 -22
  25. package/build/src/agents/poppy/store.js +22 -30
  26. package/build/src/agents/poppy/web_session.d.ts +23 -16
  27. package/build/src/agents/poppy/web_session.js +42 -24
  28. package/build/src/define_config.d.ts +6 -4
  29. package/build/src/host/admin_sessions_service.js +3 -3
  30. package/build/src/host/controllers/account_apps_controller.js +1 -1
  31. package/build/src/host/controllers/interaction_controller.js +32 -4
  32. package/build/src/host/controllers/poppy_consent_controller.d.ts +2 -2
  33. package/build/src/host/controllers/poppy_consent_controller.js +2 -2
  34. package/build/src/host/controllers/poppy_controller.d.ts +19 -11
  35. package/build/src/host/controllers/poppy_controller.js +93 -68
  36. package/build/src/host/controllers/registration_controller.js +30 -7
  37. package/build/src/host/csrf.d.ts +2 -4
  38. package/build/src/host/csrf.js +2 -3
  39. package/build/src/host/register_auth_host.js +10 -5
  40. package/build/src/host/signup_code.d.ts +41 -0
  41. package/build/src/host/signup_code.js +56 -0
  42. package/build/src/schema/ensure.js +14 -9
  43. 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 = 'authkit_login_email';
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.5) e a do Device Sign-In (o `verification_uri`,
6
- * §4.6). Montadas atrás do `accountGuard`: sem sessão, o usuário passa pelo login
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.5) e a do Device Sign-In (o `verification_uri`,
18
- * §4.6). Montadas atrás do `accountGuard`: sem sessão, o usuário passa pelo login
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 server-to-server do Poppy (§3, §4): descoberta, metadata RFC 8414,
4
- * token, revogação, device authorization, Mediated Sign-In e a sessão de
5
- * navegador (§5). Todos isentos de CSRF — ver `authkitCsrfExceptions`.
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 (ignorando `www.`). */
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-authorization-server{prefix}` (RFC 8414 + `poppy_domains`). */
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
- * `POST web.browser_session_endpoint` (§5): asserção no corpo → cookie do app
22
- * para a Session e 303 para `return_to`; qualquer falha → 400, sem cookie.
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 server-to-server do Poppy (§3, §4): descoberta, metadata RFC 8414,
35
- * token, revogação, device authorization, Mediated Sign-In e a sessão de
36
- * navegador (§5). Todos isentos de CSRF — ver `authkitCsrfExceptions`.
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 (ignorando `www.`). */
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 doc = poppy.discoveryDocument(ctx.request.host() ?? ctx.request.hostname() ?? '');
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-authorization-server{prefix}` (RFC 8414 + `poppy_domains`). */
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
- /** `POST auth.mediated.endpoint` (§4.7) — com o Session Token (DPoP). */
109
- async mediatedStart(ctx) {
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.signIn.mediated)
135
+ if (!poppy?.cfg.web?.browserSession)
112
136
  return poppy ? ctx.response.notFound() : undefined;
113
- const auth = await poppy.verifyAccess({
114
- method: ctx.request.method(),
115
- url: publicRequestUrl(ctx, poppy),
116
- headers: ctx.request.headers(),
117
- });
118
- if (!auth.ok) {
119
- const { ok: _ok, ...rest } = auth;
120
- return writePoppyAuthError(ctx, { ok: false, ...rest });
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
- /** `POST {mediated}/{sign_in_id}` (§4.7). */
134
- async mediatedCode(ctx) {
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.signIn.mediated)
153
+ if (!poppy?.cfg.web?.browserSession)
137
154
  return poppy ? ctx.response.notFound() : undefined;
138
- const auth = await poppy.verifyAccess({
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 body = await poppy.mediatedCode(auth.principal, auth.jkt, String(ctx.request.param('id') ?? ''), ctx.request.body());
149
- noStore(ctx);
150
- if (!body) {
151
- return ctx.response
152
- .status(404)
153
- .json({ error: 'invalid_request', error_description: 'Unknown sign_in_id' });
154
- }
155
- return ctx.response.json(body);
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 oauthError(ctx, error);
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` (§5): asserção no corpo → cookie do app
165
- * para a Session e 303 para `return_to`; qualquer falha → 400, sem cookie.
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
- noStore(ctx);
172
- // A asserção vai NO CORPO, nunca na URL (§5).
173
- if (ctx.request.qs().assertion !== undefined)
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.header('Location', result.returnTo);
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
- function badBrowserRequest(ctx) {
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 = await accountStore.issueMagicLinkToken(data.email);
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
  /**
@@ -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, o Mediated
15
- * Sign-In e a sessão de navegador (POST cross-site autenticado pela asserção).
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
  }
@@ -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}/sign-in` ||
48
- path.startsWith(`${poppyPrefix}/sign-in/`) ||
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
- // Credenciais e códigos de uso único: throttle por IP (§4.7 exige rate limit).
588
- withLogin(router.post(`${poppyPrefix}/sign-in`, [C.poppy, 'mediatedStart']).as('authkit.poppy.sign_in'));
589
- withOtpLogin(router
590
- .post(`${poppyPrefix}/sign-in/:id`, [C.poppy, 'mediatedCode'])
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
+ }