@adonis-agora/authkit-server 0.69.0 → 0.70.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 (45) hide show
  1. package/build/commands/import_users.js +6 -1
  2. package/build/index.d.ts +3 -1
  3. package/build/index.js +5 -1
  4. package/build/src/accounts/account_store.d.ts +40 -0
  5. package/build/src/accounts/account_store.js +20 -0
  6. package/build/src/accounts/lucid_store/mfa.js +22 -0
  7. package/build/src/audit/audit_sink.d.ts +1 -1
  8. package/build/src/audit/audit_sink.js +4 -0
  9. package/build/src/commands/import_users.d.ts +7 -0
  10. package/build/src/commands/import_users.js +15 -3
  11. package/build/src/define_config.d.ts +16 -0
  12. package/build/src/define_config.js +1 -0
  13. package/build/src/host/account_api/account_api_controller.d.ts +2 -0
  14. package/build/src/host/account_api/account_api_controller.js +22 -3
  15. package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
  16. package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
  17. package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
  18. package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
  19. package/build/src/host/account_lockout.js +7 -2
  20. package/build/src/host/admin_api/admin_users_service.js +13 -3
  21. package/build/src/host/admin_api/dto.d.ts +1 -1
  22. package/build/src/host/admin_validators.d.ts +2 -2
  23. package/build/src/host/admin_validators.js +3 -2
  24. package/build/src/host/controllers/account_mfa_controller.js +1 -2
  25. package/build/src/host/controllers/account_orgs_controller.js +4 -18
  26. package/build/src/host/controllers/account_security_controller.js +3 -1
  27. package/build/src/host/controllers/account_session_controller.js +13 -6
  28. package/build/src/host/controllers/interaction_controller.d.ts +10 -0
  29. package/build/src/host/controllers/interaction_controller.js +93 -34
  30. package/build/src/host/controllers/registration_controller.js +35 -9
  31. package/build/src/host/controllers/social_controller.js +11 -2
  32. package/build/src/host/email_identifier.d.ts +92 -0
  33. package/build/src/host/email_identifier.js +234 -0
  34. package/build/src/host/org_policy.d.ts +26 -0
  35. package/build/src/host/org_policy.js +35 -0
  36. package/build/src/host/passkey_registration_challenge.d.ts +12 -0
  37. package/build/src/host/passkey_registration_challenge.js +12 -0
  38. package/build/src/host/register_auth_host.js +51 -0
  39. package/build/src/host/sudo_mode.d.ts +17 -0
  40. package/build/src/host/sudo_mode.js +31 -12
  41. package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
  42. package/build/src/host/ui-dist/index.html +1 -1
  43. package/build/src/host/validators.d.ts +5 -5
  44. package/build/src/host/validators.js +17 -5
  45. package/package.json +2 -2
@@ -3,6 +3,7 @@ import { accountHome } from '../account_home.js';
3
3
  import { getAccountLoginUrl } from '../account_login_url.js';
4
4
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
5
5
  import { syncAdonisAuthLogin, syncAdonisAuthLogout } from '../adonis_auth_sync.js';
6
+ import { resolveEmailIdentifier } from '../email_identifier.js';
6
7
  import { translate } from '../i18n.js';
7
8
  import { endBridgedIdpSession } from '../idp_session_bridge.js';
8
9
  import { attemptPasswordLogin } from '../login_attempt.js';
@@ -60,10 +61,6 @@ export default class AccountSessionController {
60
61
  const cfg = service.config;
61
62
  const render = cfg.render;
62
63
  const { email: rawEmail, password } = ctx.request.only(['email', 'password']);
63
- // L6: normaliza o e-mail (trim + lowercase) ANTES de usar — garante que o
64
- // lookup, o lockout (keyed por email) e a auditoria usem a forma canônica,
65
- // independente do casing/espaços digitados.
66
- const email = typeof rawEmail === 'string' ? rawEmail.trim().toLowerCase() : rawEmail;
67
64
  const ip = ctx.request.ip?.() ?? null;
68
65
  // Lê e valida o return_to do corpo do formulário (hidden input) — nunca confiar sem revalidar.
69
66
  const rawReturnTo = ctx.request.input?.('return_to');
@@ -71,8 +68,18 @@ export default class AccountSessionController {
71
68
  // Verificação + lockout + auditoria de falha centralizados (sem clientId no console).
72
69
  // M1: passa `settings` p/ o lockout (e verified-email/expiração) runtime valerem aqui também.
73
70
  const settings = await resolveRuntimeSettings(ctx);
71
+ // L6: `resolveEmailIdentifier` normaliza (trim + lowercase, a MESMA função do
72
+ // cadastro e do passo de identificador do login OIDC) e devolve o endereço sob
73
+ // o qual a conta está gravada — de modo que o lookup, o lockout (keyed por
74
+ // e-mail) e a auditoria usem a forma canônica, independente do casing/espaços
75
+ // digitados. A ponte legada faz a conta que nasceu com o endereço mutilado
76
+ // entrar no console pelo endereço REAL, como no login OIDC. A tela de erro é a
77
+ // mesma (credencial inválida), ache conta ou não.
78
+ const { lookupEmail } = await resolveEmailIdentifier(cfg.accountStore, rawEmail, {
79
+ legacyFallback: cfg.login?.legacyEmailFallback ?? true,
80
+ });
74
81
  const result = await attemptPasswordLogin(cfg, {
75
- email,
82
+ email: lookupEmail,
76
83
  password,
77
84
  ip,
78
85
  logger: ctx.logger,
@@ -106,7 +113,7 @@ export default class AccountSessionController {
106
113
  // Opt-in: sincroniza ctx.auth.use(cfg.adonisAuth.guard) com a mesma conta —
107
114
  // no-op sem `adonisAuth` configurado ou sem @adonisjs/auth inicializado.
108
115
  await syncAdonisAuthLogin(ctx, cfg, acc);
109
- await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip });
116
+ await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email: acc.email, ip });
110
117
  // Redireciona pro destino original (validado), ou cai no accountHome configurado.
111
118
  return ctx.response.redirect(returnTo ?? accountHome(cfg));
112
119
  }
@@ -7,6 +7,16 @@ export default class AuthInteractionController {
7
7
  * POST /auth/interaction/:uid/identifier
8
8
  * Step 1: receive email, store in session, redirect to step 2.
9
9
  * ENUMERATION-SAFE: always advances regardless of whether the email exists.
10
+ *
11
+ * Normaliza o e-mail com a MESMA regra do cadastro (`normalizeEmailIdentifier`:
12
+ * `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
13
+ * no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
14
+ *
15
+ * A resolução acontece AQUI (uma vez, onde o valor digitado ainda existe) e
16
+ * não a cada request do passo 2. Guarda DUAS coisas: o digitado (o que a tela
17
+ * mostra) e, se a ponte de compatibilidade precisou de outra forma, o endereço
18
+ * sob o qual a conta está gravada (o que vai para o store). A resposta continua
19
+ * sendo o mesmo redirect incondicional, ache conta ou não.
10
20
  */
11
21
  identifier(ctx: HttpContext): Promise<void>;
12
22
  /**
@@ -4,6 +4,7 @@ import { AdminSessionsService } from '../admin_sessions_service.js';
4
4
  import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
5
5
  import { brandFor, isFirstParty } from '../branding.js';
6
6
  import { sendMagicLinkEmail, sendOtpUnlockEmail } from '../default_mailer.js';
7
+ import { resolveEmailIdentifier } from '../email_identifier.js';
7
8
  import { translate } from '../i18n.js';
8
9
  import { assertLoginAllowed, attemptPasswordLogin, isEmailUnverifiedBlock, } from '../login_attempt.js';
9
10
  import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
@@ -36,11 +37,37 @@ function accountStatusErrorKey(reason) {
36
37
  return 'login.password_expired_intro';
37
38
  }
38
39
  }
40
+ /**
41
+ * E-mail DIGITADO no passo de identificador, já normalizado (`trim` +
42
+ * `toLowerCase`). É o que a tela SEMPRE mostra — nunca o endereço gravado —,
43
+ * para que a canonicalização não vire um oráculo de "esta conta existe".
44
+ */
39
45
  const SESSION_KEY = 'authkit_login_email';
46
+ /**
47
+ * E-mail sob o qual a conta está GRAVADA, quando a ponte de compatibilidade
48
+ * precisou de uma forma diferente da digitada ({@link resolveEmailIdentifier}).
49
+ * É o e-mail que vai para o account store em TODA busca/emissão de token.
50
+ * Ausente = igual ao digitado.
51
+ */
52
+ const SESSION_LOOKUP_KEY = 'authkit_login_email_lookup';
40
53
  /** accountId aguardando o 2º fator depois da senha verificada. */
41
54
  const MFA_PENDING_KEY = 'authkit_mfa_pending';
42
55
  /** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
43
56
  const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
57
+ /**
58
+ * E-mail para BUSCAR a conta no store. Cai no e-mail digitado quando não há
59
+ * forma de compatibilidade gravada — inclusive nas sessões que já estavam
60
+ * abertas antes deste deploy.
61
+ */
62
+ function lookupEmail(ctx) {
63
+ return (ctx.session.get(SESSION_LOOKUP_KEY) ??
64
+ ctx.session.get(SESSION_KEY));
65
+ }
66
+ /** Esquece o e-mail do login (digitado + forma de busca) de uma vez só. */
67
+ function forgetLoginEmail(ctx) {
68
+ ctx.session.forget(SESSION_KEY);
69
+ ctx.session.forget(SESSION_LOOKUP_KEY);
70
+ }
44
71
  export default class AuthInteractionController {
45
72
  /**
46
73
  * Métodos de login efetivos (com os pins do `cfg.authMethods`) para QUALQUER render do
@@ -161,6 +188,8 @@ export default class AuthInteractionController {
161
188
  });
162
189
  }
163
190
  const email = ctx.session.get(SESSION_KEY);
191
+ // O que a tela mostra é `email` (o digitado); o que busca a conta é este.
192
+ const storeEmail = lookupEmail(ctx);
164
193
  // Métodos de login efetivos (magic link, OTP, social) — account-independent
165
194
  // neste ponto (passkey-first depende da conta, resolvido no step 2). Reusa o
166
195
  // helper compartilhado por TODOS os renders do passo login, para que
@@ -170,7 +199,7 @@ export default class AuthInteractionController {
170
199
  // Com email na sessão (passo 2+), aplica a preferência POR USUÁRIO — os
171
200
  // métodos que o dono da conta desligou não aparecem nem aqui nem nos POSTs.
172
201
  const loginMethods = email
173
- ? await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettingsForMaintenance)
202
+ ? await this.#userScopedLoginMethods(ctx, cfg, storeEmail, runtimeSettingsForMaintenance)
174
203
  : await this.#loginMethods(ctx, cfg, runtimeSettingsForMaintenance);
175
204
  const authMethods = loginMethods.authMethods;
176
205
  if (!email) {
@@ -187,7 +216,7 @@ export default class AuthInteractionController {
187
216
  });
188
217
  }
189
218
  // Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
190
- const acc = await cfg.accountStore.findByEmail(email);
219
+ const acc = await cfg.accountStore.findByEmail(storeEmail ?? email);
191
220
  const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
192
221
  // Passwordless: magic link disponível vem do helper (`loginMethods`).
193
222
  // Passkey-first disponível se ligado, o store suporta, a conta tem passkeys E auth_methods permite.
@@ -215,10 +244,29 @@ export default class AuthInteractionController {
215
244
  * POST /auth/interaction/:uid/identifier
216
245
  * Step 1: receive email, store in session, redirect to step 2.
217
246
  * ENUMERATION-SAFE: always advances regardless of whether the email exists.
247
+ *
248
+ * Normaliza o e-mail com a MESMA regra do cadastro (`normalizeEmailIdentifier`:
249
+ * `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
250
+ * no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
251
+ *
252
+ * A resolução acontece AQUI (uma vez, onde o valor digitado ainda existe) e
253
+ * não a cada request do passo 2. Guarda DUAS coisas: o digitado (o que a tela
254
+ * mostra) e, se a ponte de compatibilidade precisou de outra forma, o endereço
255
+ * sob o qual a conta está gravada (o que vai para o store). A resposta continua
256
+ * sendo o mesmo redirect incondicional, ache conta ou não.
218
257
  */
219
258
  async identifier(ctx) {
259
+ const service = await ctx.containerResolver.make('authkit.server');
260
+ const cfg = service.config;
220
261
  const { email } = ctx.request.only(['email']);
221
- ctx.session.put(SESSION_KEY, email);
262
+ const resolved = await resolveEmailIdentifier(cfg.accountStore, email, {
263
+ legacyFallback: cfg.login?.legacyEmailFallback ?? true,
264
+ });
265
+ ctx.session.put(SESSION_KEY, resolved.email);
266
+ if (resolved.viaLegacyFallback)
267
+ ctx.session.put(SESSION_LOOKUP_KEY, resolved.lookupEmail);
268
+ else
269
+ ctx.session.forget(SESSION_LOOKUP_KEY);
222
270
  return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
223
271
  }
224
272
  /**
@@ -236,6 +284,8 @@ export default class AuthInteractionController {
236
284
  // Session expired or tampered — send back to step 1
237
285
  return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
238
286
  }
287
+ // `email` é o digitado (o que a tela mostra); `storeEmail` é o que busca a conta.
288
+ const storeEmail = lookupEmail(ctx) ?? email;
239
289
  const { password } = ctx.request.only(['password']);
240
290
  const ip = ctx.request.ip?.() ?? null;
241
291
  const clientId = details.params.client_id ?? null;
@@ -251,13 +301,16 @@ export default class AuthInteractionController {
251
301
  // credenciais. Fail-safe: erro/timeout no verify do host PERMITE o fluxo.
252
302
  // We use a cfg-compatible object: pass effectiveBotLogin merged into cfg.
253
303
  const cfgWithEffectiveBot = effectiveBotLogin !== cfg.botProtection ? { ...cfg, botProtection: effectiveBotLogin } : cfg;
254
- if (!(await guardBotProtection(ctx, cfgWithEffectiveBot, 'login', { email, clientId }))) {
255
- const found = await cfg.accountStore.findByEmail(email);
304
+ if (!(await guardBotProtection(ctx, cfgWithEffectiveBot, 'login', {
305
+ email: storeEmail,
306
+ clientId,
307
+ }))) {
308
+ const found = await cfg.accountStore.findByEmail(storeEmail);
256
309
  const account = found
257
310
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
258
311
  : null;
259
312
  return render(ctx, 'login', {
260
- ...(await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettings)),
313
+ ...(await this.#userScopedLoginMethods(ctx, cfg, storeEmail, runtimeSettings)),
261
314
  uid: ctx.request.param('uid'),
262
315
  csrfToken: ctx.request.csrfToken,
263
316
  step: 'password',
@@ -271,9 +324,9 @@ export default class AuthInteractionController {
271
324
  // Preferência por usuário: senha desligada para ESTA conta → recusa ANTES de
272
325
  // verificar credenciais (mesma tela/erro de credencial inválida — não vaza
273
326
  // se a conta existe nem qual preferência está gravada).
274
- const userMethodsForLogin = await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettings);
327
+ const userMethodsForLogin = await this.#userScopedLoginMethods(ctx, cfg, storeEmail, runtimeSettings);
275
328
  if (!userMethodsForLogin.authMethods.password) {
276
- const found = await cfg.accountStore.findByEmail(email);
329
+ const found = await cfg.accountStore.findByEmail(storeEmail);
277
330
  const account = found
278
331
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
279
332
  : null;
@@ -302,7 +355,7 @@ export default class AuthInteractionController {
302
355
  // ainda. A sequência verificação + lockout + auditoria de falha é centralizada
303
356
  // em attemptPasswordLogin; a renderização (lookup p/ personalização) fica aqui.
304
357
  const result = await attemptPasswordLogin(cfg, {
305
- email,
358
+ email: storeEmail,
306
359
  password,
307
360
  ip,
308
361
  clientId,
@@ -326,7 +379,7 @@ export default class AuthInteractionController {
326
379
  botProtection: undefined,
327
380
  });
328
381
  }
329
- const found = await cfg.accountStore.findByEmail(email);
382
+ const found = await cfg.accountStore.findByEmail(storeEmail);
330
383
  const account = found
331
384
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
332
385
  : null;
@@ -407,7 +460,7 @@ export default class AuthInteractionController {
407
460
  clientId,
408
461
  trustedDevice: true,
409
462
  });
410
- ctx.session.forget(SESSION_KEY);
463
+ forgetLoginEmail(ctx);
411
464
  return;
412
465
  }
413
466
  }
@@ -447,7 +500,7 @@ export default class AuthInteractionController {
447
500
  }
448
501
  }
449
502
  await service.interactions.completeLogin(ctx, acc.id, { remember });
450
- await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
503
+ await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email: acc.email, ip, clientId });
451
504
  // single-session: após o completeLogin, revoga sessões que existiam ANTES.
452
505
  // A sessão nova foi criada pelo provider no resume — ela NÃO está em prevSessionIds.
453
506
  if (sessionPolicy.singleSession && prevSessionIds.size > 0) {
@@ -467,7 +520,7 @@ export default class AuthInteractionController {
467
520
  }
468
521
  }
469
522
  // Clean up the session key after a successful login.
470
- ctx.session.forget(SESSION_KEY);
523
+ forgetLoginEmail(ctx);
471
524
  }
472
525
  /**
473
526
  * POST /auth/interaction/:uid/mfa
@@ -554,7 +607,7 @@ export default class AuthInteractionController {
554
607
  await this.maybeTrustDevice(ctx, cfg, accountId);
555
608
  // Finaliza a interaction para o accountId pendente.
556
609
  ctx.session.forget(MFA_PENDING_KEY);
557
- ctx.session.forget(SESSION_KEY);
610
+ forgetLoginEmail(ctx);
558
611
  await notifyLoginSuccess(ctx, cfg, {
559
612
  accountId,
560
613
  ip,
@@ -675,7 +728,7 @@ export default class AuthInteractionController {
675
728
  return pending;
676
729
  if (!cfg.passwordless?.passkeyFirst)
677
730
  return undefined;
678
- const email = ctx.session.get(SESSION_KEY);
731
+ const email = lookupEmail(ctx);
679
732
  if (!email)
680
733
  return undefined;
681
734
  const acc = await cfg.accountStore.findByEmail(email);
@@ -696,6 +749,9 @@ export default class AuthInteractionController {
696
749
  const details = await service.interactions.details(ctx);
697
750
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
698
751
  const email = ctx.session.get(SESSION_KEY);
752
+ // `email` é o digitado (o que a tela mostra); `storeEmail` emite e ENDEREÇA o
753
+ // magic link — precisa ser a caixa postal sob a qual a conta está gravada.
754
+ const storeEmail = lookupEmail(ctx);
699
755
  const uid = ctx.request.param('uid');
700
756
  // Login por OTP: liga o campo de código na tela "link enviado" quando a config
701
757
  // está ligada E o store suporta a capacidade.
@@ -707,7 +763,7 @@ export default class AuthInteractionController {
707
763
  // Preferência por usuário: magic link desligado para ESTA conta → não emite
708
764
  // token (resposta uniforme "enviado", sem vazar a preferência).
709
765
  const userScopedMethods = email && cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore)
710
- ? await this.#userScopedLoginMethods(ctx, cfg, email)
766
+ ? await this.#userScopedLoginMethods(ctx, cfg, storeEmail)
711
767
  : null;
712
768
  if (userScopedMethods && !userScopedMethods.authMethods.magicLink) {
713
769
  return render(ctx, 'login', {
@@ -722,23 +778,23 @@ export default class AuthInteractionController {
722
778
  magicChannel: 'none',
723
779
  });
724
780
  }
725
- if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
781
+ if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && storeEmail) {
726
782
  const ip = ctx.request.ip?.() ?? null;
727
783
  const clientId = details.params.client_id ?? null;
728
784
  // Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
729
785
  // senão, o magic link puro de sempre.
730
786
  const issued = otpEnabled
731
- ? await cfg.accountStore.issueMagicLinkWithCode(email, uid, {
787
+ ? await cfg.accountStore.issueMagicLinkWithCode(storeEmail, uid, {
732
788
  digits: cfg.login.otp.digits,
733
789
  ttlMinutes: cfg.login.otp.ttlMinutes,
734
790
  })
735
- : await cfg.accountStore.issueMagicLinkToken(email);
791
+ : await cfg.accountStore.issueMagicLinkToken(storeEmail);
736
792
  if (issued) {
737
793
  const code = 'code' in issued ? issued.code : undefined;
738
794
  await cfg.audit?.record({
739
795
  type: 'login.magic_link_sent',
740
796
  accountId: issued.account.id,
741
- email,
797
+ email: issued.account.email,
742
798
  ip,
743
799
  clientId,
744
800
  });
@@ -746,18 +802,19 @@ export default class AuthInteractionController {
746
802
  await cfg.audit?.record({
747
803
  type: 'login.otp_sent',
748
804
  accountId: issued.account.id,
749
- email,
805
+ email: issued.account.email,
750
806
  ip,
751
807
  clientId,
752
808
  });
753
809
  }
754
810
  const origin = authkitOrigin(cfg);
755
811
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
812
+ const to = issued.account.email;
756
813
  if (cfg.mail?.onMagicLink) {
757
- await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code, channel });
814
+ await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token, code, channel });
758
815
  }
759
816
  else {
760
- await sendMagicLinkEmail(ctx, { email, magicUrl, code, channel });
817
+ await sendMagicLinkEmail(ctx, { email: to, magicUrl, code, channel });
761
818
  }
762
819
  }
763
820
  }
@@ -766,7 +823,7 @@ export default class AuthInteractionController {
766
823
  // local usado acima para decidir a emissão) — não re-declarado aqui.
767
824
  return render(ctx, 'login', {
768
825
  ...(email
769
- ? await this.#userScopedLoginMethods(ctx, cfg, email)
826
+ ? await this.#userScopedLoginMethods(ctx, cfg, storeEmail)
770
827
  : await this.#loginMethods(ctx, cfg)),
771
828
  uid,
772
829
  csrfToken: ctx.request.csrfToken,
@@ -895,7 +952,7 @@ export default class AuthInteractionController {
895
952
  clientId: clientId ?? null,
896
953
  metadata: { method: 'magic_link' },
897
954
  });
898
- ctx.session.forget(SESSION_KEY);
955
+ forgetLoginEmail(ctx);
899
956
  await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
900
957
  }
901
958
  /**
@@ -916,9 +973,11 @@ export default class AuthInteractionController {
916
973
  const ip = ctx.request.ip?.() ?? null;
917
974
  const clientId = (await service.interactions.details(ctx)).params.client_id;
918
975
  const email = ctx.session.get(SESSION_KEY);
976
+ // `email` é o digitado (o que a tela mostra); `storeEmail` verifica o código.
977
+ const storeEmail = lookupEmail(ctx);
919
978
  // Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
920
979
  const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
921
- if (!otpEnabled || !email) {
980
+ if (!otpEnabled || !email || !storeEmail) {
922
981
  return ctx.response.redirect(`/auth/interaction/${uid}`);
923
982
  }
924
983
  const code = String(ctx.request.input('code', '') ?? '').trim();
@@ -926,14 +985,14 @@ export default class AuthInteractionController {
926
985
  // Mantém a sub-view do seletor no re-render de erro (o form de código pode
927
986
  // POSTar `channel=code`). Ausente = both (histórico).
928
987
  const channel = normalizeLoginChannel(ctx.request.input('channel'));
929
- const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
988
+ const result = await cfg.accountStore.verifyLoginCode(storeEmail, uid, code, {
930
989
  maxAttempts: cfg.login.otp.maxAttempts,
931
990
  });
932
991
  // Re-render da tela "link enviado" com o campo de código + erro localizado.
933
992
  // `otpEnabled` vem do spread (scoped — OTP é extensão do magic link, e a
934
993
  // preferência do usuário que desligou magic link já foi barrada acima).
935
994
  const renderOtpError = async (messageKey) => render(ctx, 'login', {
936
- ...(await this.#userScopedLoginMethods(ctx, cfg, email)),
995
+ ...(await this.#userScopedLoginMethods(ctx, cfg, storeEmail)),
937
996
  uid,
938
997
  csrfToken: ctx.request.csrfToken,
939
998
  step: 'password',
@@ -1008,18 +1067,18 @@ export default class AuthInteractionController {
1008
1067
  clientId: clientId ?? null,
1009
1068
  metadata: { method: 'otp' },
1010
1069
  });
1011
- ctx.session.forget(SESSION_KEY);
1070
+ forgetLoginEmail(ctx);
1012
1071
  return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
1013
1072
  }
1014
1073
  if (result.status === 'locked') {
1015
1074
  // 5ª falha (ou já travado): código invalidado, o LINK continua válido.
1016
- await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
1075
+ await cfg.audit?.record({ type: 'login.otp_invalidated', email: storeEmail, ip, clientId });
1017
1076
  return renderOtpError('login.otp_locked');
1018
1077
  }
1019
1078
  if (result.status === 'expired') {
1020
1079
  await cfg.audit?.record({
1021
1080
  type: 'login.otp_failed',
1022
- email,
1081
+ email: storeEmail,
1023
1082
  ip,
1024
1083
  clientId,
1025
1084
  metadata: { reason: 'expired' },
@@ -1029,7 +1088,7 @@ export default class AuthInteractionController {
1029
1088
  // 'invalid' (tentativa contabilizada) ou 'no_code'.
1030
1089
  await cfg.audit?.record({
1031
1090
  type: 'login.otp_failed',
1032
- email,
1091
+ email: storeEmail,
1033
1092
  ip,
1034
1093
  clientId,
1035
1094
  metadata: { reason: result.status },
@@ -1252,7 +1311,7 @@ export default class AuthInteractionController {
1252
1311
  // Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
1253
1312
  await this.maybeTrustDevice(ctx, cfg, accountId);
1254
1313
  ctx.session.forget(MFA_PENDING_KEY);
1255
- ctx.session.forget(SESSION_KEY);
1314
+ forgetLoginEmail(ctx);
1256
1315
  await notifyLoginSuccess(ctx, cfg, {
1257
1316
  accountId,
1258
1317
  ip,
@@ -1268,7 +1327,7 @@ export default class AuthInteractionController {
1268
1327
  * Clears the stored email and redirects back to step 1.
1269
1328
  */
1270
1329
  async switchIdentifier(ctx) {
1271
- ctx.session.forget(SESSION_KEY);
1330
+ forgetLoginEmail(ctx);
1272
1331
  return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
1273
1332
  }
1274
1333
  /**
@@ -5,6 +5,7 @@ import { AdminSessionsService } from '../admin_sessions_service.js';
5
5
  import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
6
6
  import { brandFor } from '../branding.js';
7
7
  import { sendEmailVerificationEmail, sendMagicLinkEmail, sendPasswordResetEmail, } from '../default_mailer.js';
8
+ import { resolveEmailIdentifier } from '../email_identifier.js';
8
9
  import { translate } from '../i18n.js';
9
10
  import { authkitOrigin } from '../origin.js';
10
11
  import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
@@ -118,7 +119,13 @@ export default class AuthRegistrationController {
118
119
  }
119
120
  const data = await ctx.request.validateUsing(signupValidator);
120
121
  const accountStore = cfg.accountStore;
121
- const existing = await accountStore.findByEmail(data.email);
122
+ // Duplicado: além do endereço normalizado, enxerga a conta que o cadastro
123
+ // antigo gravou mutilada — senão a mesma pessoa ganharia uma SEGUNDA conta.
124
+ // Usa o valor CRU do formulário (o validator já normalizou `data.email`, e a
125
+ // ponte precisa da grafia original entre as candidatas).
126
+ const existing = (await resolveEmailIdentifier(accountStore, ctx.request.input('email'), {
127
+ legacyFallback: cfg.login?.legacyEmailFallback ?? true,
128
+ })).account;
122
129
  if (existing) {
123
130
  return render(ctx, 'signup', {
124
131
  uid: ctx.request.param('uid'),
@@ -217,7 +224,10 @@ export default class AuthRegistrationController {
217
224
  const data = await ctx.request.validateUsing(passwordlessSignupValidator);
218
225
  // Cria a conta se ainda não existe. Senha random inutilizável: o login é 100%
219
226
  // passwordless (mesmo precedente das contas criadas por identidade social).
220
- const existing = await accountStore.findByEmail(data.email);
227
+ // Valor CRU do formulário pelo mesmo motivo do cadastro com senha.
228
+ const existing = (await resolveEmailIdentifier(accountStore, ctx.request.input('email'), {
229
+ legacyFallback: cfg.login?.legacyEmailFallback ?? true,
230
+ })).account;
221
231
  if (!existing) {
222
232
  const created = await accountStore.create({
223
233
  email: data.email,
@@ -233,15 +243,19 @@ export default class AuthRegistrationController {
233
243
  });
234
244
  }
235
245
  // Emite + envia o magic link (mesma construção do login por magic link).
236
- const issued = await accountStore.issueMagicLinkToken(data.email);
246
+ // Conta já existente entra pelo endereço sob o qual está GRAVADA (pode ser a
247
+ // forma mutilada pelo cadastro antigo); conta nova, pelo endereço digitado.
248
+ const issueFor = existing?.email ?? data.email;
249
+ const issued = await accountStore.issueMagicLinkToken(issueFor);
237
250
  if (issued) {
238
251
  const origin = authkitOrigin(cfg);
239
252
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
253
+ const to = issued.account.email;
240
254
  if (cfg.mail?.onMagicLink) {
241
- await cfg.mail.onMagicLink({ email: data.email, magicUrl, token: issued.token });
255
+ await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
242
256
  }
243
257
  else {
244
- await sendMagicLinkEmail(ctx, { email: data.email, magicUrl });
258
+ await sendMagicLinkEmail(ctx, { email: to, magicUrl });
245
259
  }
246
260
  }
247
261
  // Resposta uniforme: "enviamos um link" (não vaza existência da conta).
@@ -322,21 +336,33 @@ export default class AuthRegistrationController {
322
336
  }
323
337
  const { email } = await ctx.request.validateUsing(forgotPasswordValidator);
324
338
  const accountStore = cfg.accountStore;
325
- const result = await accountStore.issuePasswordResetToken(email);
339
+ let result = await accountStore.issuePasswordResetToken(email);
340
+ // Ponte legada: quem teve o endereço mutilado pelo cadastro antigo precisa
341
+ // conseguir resetar a senha digitando o endereço REAL. Só entra quando o
342
+ // endereço normalizado não achou nada — o caminho feliz segue com UMA
343
+ // chamada só. Resposta uniforme (a tela abaixo é a mesma, ache ou não).
344
+ if (!result && (cfg.login?.legacyEmailFallback ?? true)) {
345
+ const resolved = await resolveEmailIdentifier(accountStore, ctx.request.input('email'));
346
+ if (resolved.viaLegacyFallback) {
347
+ result = await accountStore.issuePasswordResetToken(resolved.lookupEmail);
348
+ }
349
+ }
326
350
  if (result) {
327
351
  await cfg.audit?.record({
328
352
  type: 'password_reset.issued',
329
- email,
353
+ email: result.account.email,
330
354
  ip: ctx.request.ip?.() ?? null,
331
355
  });
332
356
  const origin = authkitOrigin(cfg);
333
357
  const url = `${origin}/auth/reset-password?token=${result.token}`;
334
358
  // Hook do config tem prioridade (override); senão usa o mailer default do host.
359
+ // O link vai para a caixa postal sob a qual a conta está gravada.
360
+ const to = result.account.email;
335
361
  if (cfg.mail?.onPasswordReset) {
336
- await cfg.mail.onPasswordReset({ email, resetUrl: url, token: result.token });
362
+ await cfg.mail.onPasswordReset({ email: to, resetUrl: url, token: result.token });
337
363
  }
338
364
  else {
339
- await sendPasswordResetEmail(ctx, { email, resetUrl: url });
365
+ await sendPasswordResetEmail(ctx, { email: to, resetUrl: url });
340
366
  }
341
367
  }
342
368
  return render(ctx, 'forgot', {
@@ -1,6 +1,7 @@
1
1
  import '../augmentations.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { supportsLoginMethodsPreference, supportsMagicLink, supportsPasskeys, supportsProviderIdentity, } from '../../accounts/account_store.js';
4
+ import { normalizeEmailIdentifier, resolveEmailIdentifier } from '../email_identifier.js';
4
5
  import { assertLoginAllowed } from '../login_attempt.js';
5
6
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
6
7
  import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
@@ -54,7 +55,10 @@ export default class AuthSocialController {
54
55
  const service = await ctx.containerResolver.make('authkit.server');
55
56
  const cfg = service.config;
56
57
  const store = cfg.accountStore;
57
- const email = profile.email ?? undefined;
58
+ // Normaliza o e-mail do provider com a MESMA regra do cadastro/login: sem
59
+ // isto, um provider que devolve o endereço com maiúsculas criava uma conta
60
+ // que o login (normalizado) não encontrava mais.
61
+ const email = profile.email ? normalizeEmailIdentifier(profile.email) : undefined;
58
62
  // Account linking exige a capacidade de provider-identity (model wired no store).
59
63
  // Ausente → não há como ligar a identidade; volta ao login em vez de quebrar.
60
64
  if (!supportsProviderIdentity(store)) {
@@ -67,7 +71,12 @@ export default class AuthSocialController {
67
71
  // 3. Senão → cria conta nova e liga a identidade.
68
72
  let user = await store.findByProviderIdentity(provider, profile.id);
69
73
  if (!user && email) {
70
- const byEmail = await store.findByEmail(email);
74
+ // Ponte legada: sem ela, quem tem a conta gravada com o endereço mutilado
75
+ // pelo cadastro antigo ganharia uma SEGUNDA conta ao "Continuar com o
76
+ // Google" em vez de ligar a identidade à conta que já tem.
77
+ const byEmail = (await resolveEmailIdentifier(store, profile.email, {
78
+ legacyFallback: cfg.login?.legacyEmailFallback ?? true,
79
+ })).account;
71
80
  if (byEmail) {
72
81
  await store.linkProviderIdentity({
73
82
  accountId: byEmail.id,
@@ -0,0 +1,92 @@
1
+ import type { AuthAccount } from '../accounts/account_store.js';
2
+ /**
3
+ * Normalização ÚNICA e CONSERVADORA do e-mail usado como identidade da conta.
4
+ *
5
+ * Faz `trim()` + `toLowerCase()` e MAIS NADA. O endereço que a pessoa digitou é a
6
+ * identidade dela: a lib NÃO decide que `a.b@gmail.com` e `ab@gmail.com` são a
7
+ * mesma pessoa, nem descarta o sub-endereço (`+tag`) que ela escolheu usar.
8
+ *
9
+ * POR QUE ISTO EXISTE: até a v0.68 o cadastro validava o e-mail com
10
+ * `.normalizeEmail()` do VineJS (os defaults do validator.js), que para o gmail
11
+ * REMOVE os pontos e o `+tag` do local part. A conta nascia com um endereço
12
+ * DIFERENTE do digitado (`davi.carvalho96@gmail.com` → `davicarvalho96@gmail.com`)
13
+ * enquanto o passo de identificador do login não normalizava NADA e buscava por
14
+ * igualdade exata — resultado: quem tinha ponto ou `+tag` no gmail ficava
15
+ * trancado do lado de fora, e, por o login ser à prova de enumeração, sem
16
+ * nenhuma mensagem de erro. Uma normalização só, usada nos DOIS lados, fecha a
17
+ * assimetria.
18
+ *
19
+ * Use em TODO ponto que grava ou busca a identidade: cadastro (com e sem senha),
20
+ * "esqueci a senha", troca de e-mail, criação por admin, convite de organização,
21
+ * import de usuários, cadastro social e o passo de identificador do login.
22
+ */
23
+ export declare function normalizeEmailIdentifier(raw: unknown): string;
24
+ /**
25
+ * Réplica da normalização LEGADA — `normalizeEmail()` do validator.js com os
26
+ * defaults, que é o que o `.normalizeEmail()` do VineJS aplicava no cadastro até
27
+ * a v0.68. Existe SÓ para reencontrar as contas que nasceram com o endereço
28
+ * mutilado; nada novo deve ser gravado com ela.
29
+ *
30
+ * É uma PONTE TEMPORÁRIA: quando as contas antigas tiverem sido migradas para o
31
+ * endereço real (ou o suficiente delas), esta função, a opção
32
+ * `login.legacyEmailFallback` e {@link resolveEmailIdentifier} podem sair.
33
+ *
34
+ * Retorna `null` para entradas que o validator.js também recusaria (local part
35
+ * vazio depois do colapso) ou que não são um endereço com `@`.
36
+ */
37
+ export declare function legacyNormalizeEmailIdentifier(raw: unknown): string | null;
38
+ /** Só o que {@link resolveEmailIdentifier} precisa do account store. */
39
+ export interface EmailIdentifierLookup {
40
+ findByEmail(email: string): Promise<AuthAccount | null>;
41
+ }
42
+ export interface ResolvedEmailIdentifier {
43
+ /** O que a pessoa digitou, normalizado. É o que a tela SEMPRE mostra. */
44
+ email: string;
45
+ /**
46
+ * E-mail sob o qual a conta está GRAVADA — o que deve ir para o store em
47
+ * qualquer busca/emissão de token. Igual a {@link email} quando a conta foi
48
+ * achada direto (ou quando não foi achada nenhuma).
49
+ */
50
+ lookupEmail: string;
51
+ /** A conta, quando alguma foi encontrada. */
52
+ account: AuthAccount | null;
53
+ /** `true` quando a conta só foi alcançada pela ponte legada. */
54
+ viaLegacyFallback: boolean;
55
+ }
56
+ /**
57
+ * Resolve o e-mail digitado na conta correspondente.
58
+ *
59
+ * Passe o valor **CRU** (como veio do formulário, do provider ou do arquivo): a
60
+ * normalização acontece aqui dentro, e a forma crua é uma das candidatas da
61
+ * ponte. Passar um valor já normalizado apaga essa candidata e reduz o alcance
62
+ * da ponte às contas gravadas com o endereço mutilado.
63
+ *
64
+ * 1. Busca pela forma normalizada NOVA (trim + lowercase) — o caminho de sempre,
65
+ * uma única query por igualdade (indexada).
66
+ * 2. Não achando, e com a ponte ligada (`login.legacyEmailFallback`, default
67
+ * `true`), tenta as formas de compatibilidade:
68
+ * - o endereço EXATAMENTE como digitado (só com `trim`), para as contas que
69
+ * foram gravadas com maiúsculas antes desta normalização existir (import,
70
+ * convite, criação por admin, provider social);
71
+ * - a {@link legacyNormalizeEmailIdentifier normalização legada}, para as
72
+ * contas que nasceram com o endereço mutilado.
73
+ * O resultado só é aceito quando as formas de compatibilidade apontam para
74
+ * EXATAMENTE UMA conta. Duas contas distintas (ex.: `Davi.C@Gmail.com` criada
75
+ * pelo social E `davic@gmail.com` criada pelo cadastro legado) são um empate:
76
+ * a lib não adivinha qual é a pessoa e trata como "não achei".
77
+ *
78
+ * LIMITE CONHECIDO: a ponte só alcança grafias que dá para derivar do que foi
79
+ * digitado. Uma conta gravada `Davi@Acme.com` é alcançada por quem digita
80
+ * `Davi@Acme.com` (a forma crua), mas NÃO por quem digita `davi@acme.com` — aí as
81
+ * três formas coincidem e só uma busca case-insensitive no store resolveria, o
82
+ * que exigiria varrer a tabela a cada login com e-mail desconhecido (justamente o
83
+ * caminho de ataque). Essas contas pedem migração do endereço gravado, não ponte.
84
+ *
85
+ * À PROVA DE ENUMERAÇÃO: nunca lança, nunca sinaliza nada para fora — quem chama
86
+ * segue com `account: null` exatamente como seguia antes. As buscas extras só
87
+ * acontecem quando as formas de compatibilidade DIFEREM da normalizada (e-mail
88
+ * digitado em minúsculas e sem ponto/tag → uma query só, como hoje).
89
+ */
90
+ export declare function resolveEmailIdentifier(store: EmailIdentifierLookup, raw: unknown, options?: {
91
+ legacyFallback?: boolean;
92
+ }): Promise<ResolvedEmailIdentifier>;