@adonis-agora/authkit-server 0.70.0 → 0.71.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.
@@ -3,7 +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
+ import { normalizeEmailIdentifier } from '../email_identifier.js';
7
7
  import { translate } from '../i18n.js';
8
8
  import { endBridgedIdpSession } from '../idp_session_bridge.js';
9
9
  import { attemptPasswordLogin } from '../login_attempt.js';
@@ -68,18 +68,13 @@ export default class AccountSessionController {
68
68
  // Verificação + lockout + auditoria de falha centralizados (sem clientId no console).
69
69
  // M1: passa `settings` p/ o lockout (e verified-email/expiração) runtime valerem aqui também.
70
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
71
+ // L6: normaliza (trim + lowercase, a MESMA função do cadastro e do passo de
72
+ // identificador do login OIDC) para que o lookup, o lockout (keyed por
74
73
  // 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
+ // digitados. A tela de erro é a mesma (credencial inválida), ache conta ou não.
75
+ const email = normalizeEmailIdentifier(rawEmail);
81
76
  const result = await attemptPasswordLogin(cfg, {
82
- email: lookupEmail,
77
+ email,
83
78
  password,
84
79
  ip,
85
80
  logger: ctx.logger,
@@ -12,11 +12,11 @@ export default class AuthInteractionController {
12
12
  * `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
13
13
  * no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
14
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.
15
+ * À PROVA DE ENUMERAÇÃO: o passo NÃO toca no account store — só normaliza e
16
+ * guarda. A resposta é o MESMO redirect incondicional, exista a conta ou não,
17
+ * e não há busca alguma aqui para diferenciar os dois casos nem no tempo de
18
+ * resposta. Quem decide se a conta existe é o passo 2, que renderiza a mesma
19
+ * tela nos dois casos.
20
20
  */
21
21
  identifier(ctx: HttpContext): Promise<void>;
22
22
  /**
@@ -4,7 +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
+ import { normalizeEmailIdentifier } from '../email_identifier.js';
8
8
  import { translate } from '../i18n.js';
9
9
  import { assertLoginAllowed, attemptPasswordLogin, isEmailUnverifiedBlock, } from '../login_attempt.js';
10
10
  import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
@@ -43,30 +43,19 @@ function accountStatusErrorKey(reason) {
43
43
  * para que a canonicalização não vire um oráculo de "esta conta existe".
44
44
  */
45
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';
53
46
  /** accountId aguardando o 2º fator depois da senha verificada. */
54
47
  const MFA_PENDING_KEY = 'authkit_mfa_pending';
55
48
  /** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
56
49
  const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
57
50
  /**
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.
51
+ * Esquece o e-mail do login.
52
+ *
53
+ * Helper de UMA linha de propósito: os fluxos que encerram o passo de login
54
+ * (sucesso, "usar outro e-mail", expiração) chamam ISTO em vez de `forget` da
55
+ * chave crua, para que nenhum caminho deixe o e-mail pendurado na sessão.
61
56
  */
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
57
  function forgetLoginEmail(ctx) {
68
58
  ctx.session.forget(SESSION_KEY);
69
- ctx.session.forget(SESSION_LOOKUP_KEY);
70
59
  }
71
60
  export default class AuthInteractionController {
72
61
  /**
@@ -188,8 +177,6 @@ export default class AuthInteractionController {
188
177
  });
189
178
  }
190
179
  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);
193
180
  // Métodos de login efetivos (magic link, OTP, social) — account-independent
194
181
  // neste ponto (passkey-first depende da conta, resolvido no step 2). Reusa o
195
182
  // helper compartilhado por TODOS os renders do passo login, para que
@@ -199,7 +186,7 @@ export default class AuthInteractionController {
199
186
  // Com email na sessão (passo 2+), aplica a preferência POR USUÁRIO — os
200
187
  // métodos que o dono da conta desligou não aparecem nem aqui nem nos POSTs.
201
188
  const loginMethods = email
202
- ? await this.#userScopedLoginMethods(ctx, cfg, storeEmail, runtimeSettingsForMaintenance)
189
+ ? await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettingsForMaintenance)
203
190
  : await this.#loginMethods(ctx, cfg, runtimeSettingsForMaintenance);
204
191
  const authMethods = loginMethods.authMethods;
205
192
  if (!email) {
@@ -216,7 +203,7 @@ export default class AuthInteractionController {
216
203
  });
217
204
  }
218
205
  // Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
219
- const acc = await cfg.accountStore.findByEmail(storeEmail ?? email);
206
+ const acc = await cfg.accountStore.findByEmail(email);
220
207
  const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
221
208
  // Passwordless: magic link disponível vem do helper (`loginMethods`).
222
209
  // Passkey-first disponível se ligado, o store suporta, a conta tem passkeys E auth_methods permite.
@@ -249,24 +236,17 @@ export default class AuthInteractionController {
249
236
  * `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
250
237
  * no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
251
238
  *
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.
239
+ * À PROVA DE ENUMERAÇÃO: o passo NÃO toca no account store — só normaliza e
240
+ * guarda. A resposta é o MESMO redirect incondicional, exista a conta ou não,
241
+ * e não há busca alguma aqui para diferenciar os dois casos nem no tempo de
242
+ * resposta. Quem decide se a conta existe é o passo 2, que renderiza a mesma
243
+ * tela nos dois casos.
257
244
  */
258
245
  async identifier(ctx) {
259
- const service = await ctx.containerResolver.make('authkit.server');
260
- const cfg = service.config;
261
246
  const { email } = ctx.request.only(['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);
247
+ // `request.only` não valida tipo (`email[]=x` chega array): a normalização
248
+ // devolve string vazia para não-string, e o passo segue com o redirect.
249
+ ctx.session.put(SESSION_KEY, normalizeEmailIdentifier(email));
270
250
  return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
271
251
  }
272
252
  /**
@@ -284,8 +264,6 @@ export default class AuthInteractionController {
284
264
  // Session expired or tampered — send back to step 1
285
265
  return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
286
266
  }
287
- // `email` é o digitado (o que a tela mostra); `storeEmail` é o que busca a conta.
288
- const storeEmail = lookupEmail(ctx) ?? email;
289
267
  const { password } = ctx.request.only(['password']);
290
268
  const ip = ctx.request.ip?.() ?? null;
291
269
  const clientId = details.params.client_id ?? null;
@@ -302,15 +280,15 @@ export default class AuthInteractionController {
302
280
  // We use a cfg-compatible object: pass effectiveBotLogin merged into cfg.
303
281
  const cfgWithEffectiveBot = effectiveBotLogin !== cfg.botProtection ? { ...cfg, botProtection: effectiveBotLogin } : cfg;
304
282
  if (!(await guardBotProtection(ctx, cfgWithEffectiveBot, 'login', {
305
- email: storeEmail,
283
+ email,
306
284
  clientId,
307
285
  }))) {
308
- const found = await cfg.accountStore.findByEmail(storeEmail);
286
+ const found = await cfg.accountStore.findByEmail(email);
309
287
  const account = found
310
288
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
311
289
  : null;
312
290
  return render(ctx, 'login', {
313
- ...(await this.#userScopedLoginMethods(ctx, cfg, storeEmail, runtimeSettings)),
291
+ ...(await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettings)),
314
292
  uid: ctx.request.param('uid'),
315
293
  csrfToken: ctx.request.csrfToken,
316
294
  step: 'password',
@@ -324,9 +302,9 @@ export default class AuthInteractionController {
324
302
  // Preferência por usuário: senha desligada para ESTA conta → recusa ANTES de
325
303
  // verificar credenciais (mesma tela/erro de credencial inválida — não vaza
326
304
  // se a conta existe nem qual preferência está gravada).
327
- const userMethodsForLogin = await this.#userScopedLoginMethods(ctx, cfg, storeEmail, runtimeSettings);
305
+ const userMethodsForLogin = await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettings);
328
306
  if (!userMethodsForLogin.authMethods.password) {
329
- const found = await cfg.accountStore.findByEmail(storeEmail);
307
+ const found = await cfg.accountStore.findByEmail(email);
330
308
  const account = found
331
309
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
332
310
  : null;
@@ -355,7 +333,7 @@ export default class AuthInteractionController {
355
333
  // ainda. A sequência verificação + lockout + auditoria de falha é centralizada
356
334
  // em attemptPasswordLogin; a renderização (lookup p/ personalização) fica aqui.
357
335
  const result = await attemptPasswordLogin(cfg, {
358
- email: storeEmail,
336
+ email,
359
337
  password,
360
338
  ip,
361
339
  clientId,
@@ -379,7 +357,7 @@ export default class AuthInteractionController {
379
357
  botProtection: undefined,
380
358
  });
381
359
  }
382
- const found = await cfg.accountStore.findByEmail(storeEmail);
360
+ const found = await cfg.accountStore.findByEmail(email);
383
361
  const account = found
384
362
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
385
363
  : null;
@@ -728,7 +706,7 @@ export default class AuthInteractionController {
728
706
  return pending;
729
707
  if (!cfg.passwordless?.passkeyFirst)
730
708
  return undefined;
731
- const email = lookupEmail(ctx);
709
+ const email = ctx.session.get(SESSION_KEY);
732
710
  if (!email)
733
711
  return undefined;
734
712
  const acc = await cfg.accountStore.findByEmail(email);
@@ -749,9 +727,6 @@ export default class AuthInteractionController {
749
727
  const details = await service.interactions.details(ctx);
750
728
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
751
729
  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);
755
730
  const uid = ctx.request.param('uid');
756
731
  // Login por OTP: liga o campo de código na tela "link enviado" quando a config
757
732
  // está ligada E o store suporta a capacidade.
@@ -763,7 +738,7 @@ export default class AuthInteractionController {
763
738
  // Preferência por usuário: magic link desligado para ESTA conta → não emite
764
739
  // token (resposta uniforme "enviado", sem vazar a preferência).
765
740
  const userScopedMethods = email && cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore)
766
- ? await this.#userScopedLoginMethods(ctx, cfg, storeEmail)
741
+ ? await this.#userScopedLoginMethods(ctx, cfg, email)
767
742
  : null;
768
743
  if (userScopedMethods && !userScopedMethods.authMethods.magicLink) {
769
744
  return render(ctx, 'login', {
@@ -778,17 +753,17 @@ export default class AuthInteractionController {
778
753
  magicChannel: 'none',
779
754
  });
780
755
  }
781
- if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && storeEmail) {
756
+ if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
782
757
  const ip = ctx.request.ip?.() ?? null;
783
758
  const clientId = details.params.client_id ?? null;
784
759
  // Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
785
760
  // senão, o magic link puro de sempre.
786
761
  const issued = otpEnabled
787
- ? await cfg.accountStore.issueMagicLinkWithCode(storeEmail, uid, {
762
+ ? await cfg.accountStore.issueMagicLinkWithCode(email, uid, {
788
763
  digits: cfg.login.otp.digits,
789
764
  ttlMinutes: cfg.login.otp.ttlMinutes,
790
765
  })
791
- : await cfg.accountStore.issueMagicLinkToken(storeEmail);
766
+ : await cfg.accountStore.issueMagicLinkToken(email);
792
767
  if (issued) {
793
768
  const code = 'code' in issued ? issued.code : undefined;
794
769
  await cfg.audit?.record({
@@ -823,7 +798,7 @@ export default class AuthInteractionController {
823
798
  // local usado acima para decidir a emissão) — não re-declarado aqui.
824
799
  return render(ctx, 'login', {
825
800
  ...(email
826
- ? await this.#userScopedLoginMethods(ctx, cfg, storeEmail)
801
+ ? await this.#userScopedLoginMethods(ctx, cfg, email)
827
802
  : await this.#loginMethods(ctx, cfg)),
828
803
  uid,
829
804
  csrfToken: ctx.request.csrfToken,
@@ -973,11 +948,9 @@ export default class AuthInteractionController {
973
948
  const ip = ctx.request.ip?.() ?? null;
974
949
  const clientId = (await service.interactions.details(ctx)).params.client_id;
975
950
  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);
978
951
  // Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
979
952
  const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
980
- if (!otpEnabled || !email || !storeEmail) {
953
+ if (!otpEnabled || !email) {
981
954
  return ctx.response.redirect(`/auth/interaction/${uid}`);
982
955
  }
983
956
  const code = String(ctx.request.input('code', '') ?? '').trim();
@@ -985,14 +958,14 @@ export default class AuthInteractionController {
985
958
  // Mantém a sub-view do seletor no re-render de erro (o form de código pode
986
959
  // POSTar `channel=code`). Ausente = both (histórico).
987
960
  const channel = normalizeLoginChannel(ctx.request.input('channel'));
988
- const result = await cfg.accountStore.verifyLoginCode(storeEmail, uid, code, {
961
+ const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
989
962
  maxAttempts: cfg.login.otp.maxAttempts,
990
963
  });
991
964
  // Re-render da tela "link enviado" com o campo de código + erro localizado.
992
965
  // `otpEnabled` vem do spread (scoped — OTP é extensão do magic link, e a
993
966
  // preferência do usuário que desligou magic link já foi barrada acima).
994
967
  const renderOtpError = async (messageKey) => render(ctx, 'login', {
995
- ...(await this.#userScopedLoginMethods(ctx, cfg, storeEmail)),
968
+ ...(await this.#userScopedLoginMethods(ctx, cfg, email)),
996
969
  uid,
997
970
  csrfToken: ctx.request.csrfToken,
998
971
  step: 'password',
@@ -1072,13 +1045,13 @@ export default class AuthInteractionController {
1072
1045
  }
1073
1046
  if (result.status === 'locked') {
1074
1047
  // 5ª falha (ou já travado): código invalidado, o LINK continua válido.
1075
- await cfg.audit?.record({ type: 'login.otp_invalidated', email: storeEmail, ip, clientId });
1048
+ await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
1076
1049
  return renderOtpError('login.otp_locked');
1077
1050
  }
1078
1051
  if (result.status === 'expired') {
1079
1052
  await cfg.audit?.record({
1080
1053
  type: 'login.otp_failed',
1081
- email: storeEmail,
1054
+ email,
1082
1055
  ip,
1083
1056
  clientId,
1084
1057
  metadata: { reason: 'expired' },
@@ -1088,7 +1061,7 @@ export default class AuthInteractionController {
1088
1061
  // 'invalid' (tentativa contabilizada) ou 'no_code'.
1089
1062
  await cfg.audit?.record({
1090
1063
  type: 'login.otp_failed',
1091
- email: storeEmail,
1064
+ email,
1092
1065
  ip,
1093
1066
  clientId,
1094
1067
  metadata: { reason: result.status },
@@ -5,7 +5,6 @@ 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';
9
8
  import { translate } from '../i18n.js';
10
9
  import { authkitOrigin } from '../origin.js';
11
10
  import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
@@ -119,13 +118,9 @@ export default class AuthRegistrationController {
119
118
  }
120
119
  const data = await ctx.request.validateUsing(signupValidator);
121
120
  const accountStore = cfg.accountStore;
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;
121
+ // Duplicado pela MESMA forma que o login busca (`data.email` já saiu
122
+ // normalizado do validator).
123
+ const existing = await accountStore.findByEmail(data.email);
129
124
  if (existing) {
130
125
  return render(ctx, 'signup', {
131
126
  uid: ctx.request.param('uid'),
@@ -224,10 +219,7 @@ export default class AuthRegistrationController {
224
219
  const data = await ctx.request.validateUsing(passwordlessSignupValidator);
225
220
  // Cria a conta se ainda não existe. Senha random inutilizável: o login é 100%
226
221
  // passwordless (mesmo precedente das contas criadas por identidade social).
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;
222
+ const existing = await accountStore.findByEmail(data.email);
231
223
  if (!existing) {
232
224
  const created = await accountStore.create({
233
225
  email: data.email,
@@ -243,10 +235,9 @@ export default class AuthRegistrationController {
243
235
  });
244
236
  }
245
237
  // Emite + envia o magic link (mesma construção do login por magic link).
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);
238
+ // `data.email` já é a forma normalizada — a mesma sob a qual a conta foi
239
+ // encontrada acima ou acabou de ser criada.
240
+ const issued = await accountStore.issueMagicLinkToken(data.email);
250
241
  if (issued) {
251
242
  const origin = authkitOrigin(cfg);
252
243
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
@@ -336,17 +327,10 @@ export default class AuthRegistrationController {
336
327
  }
337
328
  const { email } = await ctx.request.validateUsing(forgotPasswordValidator);
338
329
  const accountStore = cfg.accountStore;
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
- }
330
+ // `email` já saiu normalizado do validator (a MESMA normalização do
331
+ // cadastro e do login). Resposta uniforme: a tela abaixo é a mesma, ache
332
+ // conta ou não.
333
+ const result = await accountStore.issuePasswordResetToken(email);
350
334
  if (result) {
351
335
  await cfg.audit?.record({
352
336
  type: 'password_reset.issued',
@@ -1,7 +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
+ import { normalizeEmailIdentifier } from '../email_identifier.js';
5
5
  import { assertLoginAllowed } from '../login_attempt.js';
6
6
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
7
7
  import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
@@ -71,12 +71,10 @@ export default class AuthSocialController {
71
71
  // 3. Senão → cria conta nova e liga a identidade.
72
72
  let user = await store.findByProviderIdentity(provider, profile.id);
73
73
  if (!user && 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;
74
+ // Busca pela MESMA forma normalizada que o login usa. Uma conta gravada
75
+ // com outra grafia não é encontrada aqui e a pessoa ganharia uma SEGUNDA
76
+ // conta — por isso `authkit:users:normalize-emails` é passo de upgrade.
77
+ const byEmail = await store.findByEmail(email);
80
78
  if (byEmail) {
81
79
  await store.linkProviderIdentity({
82
80
  accountId: byEmail.id,
@@ -1,4 +1,3 @@
1
- import type { AuthAccount } from '../accounts/account_store.js';
2
1
  /**
3
2
  * Normalização ÚNICA e CONSERVADORA do e-mail usado como identidade da conta.
4
3
  *
@@ -19,74 +18,12 @@ import type { AuthAccount } from '../accounts/account_store.js';
19
18
  * Use em TODO ponto que grava ou busca a identidade: cadastro (com e sem senha),
20
19
  * "esqueci a senha", troca de e-mail, criação por admin, convite de organização,
21
20
  * 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
21
  *
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 `@`.
22
+ * As contas GRAVADAS antes disto (com o endereço mutilado, ou com maiúsculas
23
+ * vindas de import/convite/provider social) ficam inalcançáveis pelo login, que
24
+ * busca a forma normalizada. Elas pedem MIGRAÇÃO do endereço gravado — é o que o
25
+ * comando `authkit:users:normalize-emails` faz (ver `commands/normalize_emails.ts`).
26
+ * A lib não tenta adivinhar grafias no caminho do login: cada tentativa extra é
27
+ * uma query a mais por e-mail desconhecido, que é justamente o caminho de ataque.
36
28
  */
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>;
29
+ export declare function normalizeEmailIdentifier(raw: unknown): string;