@adonis-agora/authkit-server 0.70.0 → 0.72.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.
@@ -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;
@@ -427,55 +405,24 @@ export default class AuthInteractionController {
427
405
  }
428
406
  // Admin: prossegue normalmente (sem bloqueio).
429
407
  }
430
- // Step-up auth (acr_values): o client pode EXIGIR MFA nesta requisição
431
- // solicitando o `mfaAcr` em acr_values, mesmo que a conta tenha MFA opcional.
432
- const mfaRequired = this.acrRequiresMfa(cfg, details);
433
- // MFA gate: força o 2º fator se a conta tem TOTP ativo OU se o client exige MFA
434
- // via acr. Não finaliza a interaction agora — guarda o accountId pendente.
435
- const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
436
- if (mfa.enabled || mfaRequired) {
437
- if (mfaRequired && !mfa.enabled) {
438
- // Client exige MFA mas a conta não tem MFA enrolado: bloqueia este login
439
- // com a instrução de configurar MFA no console (não há 2º fator a desafiar).
440
- return render(ctx, 'mfa-challenge', {
441
- uid: ctx.request.param('uid'),
442
- csrfToken: ctx.request.csrfToken,
443
- brand,
444
- passkeyAvailable: false,
445
- error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
446
- noEnrollment: true,
447
- });
448
- }
449
- // Trusted device: se o mecanismo está ligado, a conta JÁ tem MFA enrolado e
450
- // o request NÃO é um step-up (que sempre força o MFA), um cookie de confiança
451
- // válido para ESTA conta pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
452
- if (cfg.trustedDevices.enabled && mfa.enabled && !mfaRequired) {
453
- const trusted = await this.checkTrustedDevice(ctx, acc.id, mfa.enabledAt ?? null);
454
- if (trusted) {
455
- await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
456
- await notifyLoginSuccess(ctx, cfg, {
457
- accountId: acc.id,
458
- email,
459
- ip,
460
- clientId,
461
- trustedDevice: true,
462
- });
463
- forgetLoginEmail(ctx);
464
- return;
465
- }
466
- }
467
- ctx.session.put(MFA_PENDING_KEY, acc.id);
468
- // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
469
- // tem ao menos uma credencial registrada.
470
- const passkeyAvailable = await this.hasPasskeys(cfg, acc.id);
471
- return render(ctx, 'mfa-challenge', {
472
- uid: ctx.request.param('uid'),
473
- csrfToken: ctx.request.csrfToken,
474
- brand,
475
- passkeyAvailable,
476
- trustedDevicesEnabled: cfg.trustedDevices.enabled,
477
- trustedDeviceDays: cfg.trustedDevices.days,
408
+ // Gate do 2º fator — a MESMA regra dos três caminhos de login (ver
409
+ // `secondFactorGate`). Quando desafia, não finaliza a interaction: guarda o
410
+ // accountId pendente e devolve a tela.
411
+ const gate = await this.secondFactorGate(ctx, cfg, acc.id, details);
412
+ if (gate.kind === 'challenge')
413
+ return gate.response;
414
+ if (gate.kind === 'trusted') {
415
+ // Dispositivo confiável: pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
416
+ await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
417
+ await notifyLoginSuccess(ctx, cfg, {
418
+ accountId: acc.id,
419
+ email,
420
+ ip,
421
+ clientId,
422
+ trustedDevice: true,
478
423
  });
424
+ forgetLoginEmail(ctx);
425
+ return;
479
426
  }
480
427
  // Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
481
428
  // Resolve session_policy para remember-me e single-session.
@@ -553,6 +500,7 @@ export default class AuthInteractionController {
553
500
  error: translate(cfg.messages, 'errors.otp_locked'),
554
501
  brand,
555
502
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
503
+ totpAvailable: await this.hasTotp(cfg, accountId),
556
504
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
557
505
  trustedDeviceDays: cfg.trustedDevices.days,
558
506
  otpLocked: true,
@@ -586,6 +534,7 @@ export default class AuthInteractionController {
586
534
  error: translate(cfg.messages, 'errors.otp_locked'),
587
535
  brand,
588
536
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
537
+ totpAvailable: await this.hasTotp(cfg, accountId),
589
538
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
590
539
  trustedDeviceDays: cfg.trustedDevices.days,
591
540
  otpLocked: true,
@@ -597,6 +546,7 @@ export default class AuthInteractionController {
597
546
  error: translate(cfg.messages, 'errors.invalid_code'),
598
547
  brand,
599
548
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
549
+ totpAvailable: await this.hasTotp(cfg, accountId),
600
550
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
601
551
  trustedDeviceDays: cfg.trustedDevices.days,
602
552
  });
@@ -641,6 +591,84 @@ export default class AuthInteractionController {
641
591
  return undefined;
642
592
  return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
643
593
  }
594
+ /**
595
+ * Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
596
+ * mágico, código por e-mail e troca forçada de senha. Antes disto só o login
597
+ * por senha passava pelo MFA — link e código completavam a interaction direto,
598
+ * então uma conta com TOTP ou passkey entrava apresentando só o e-mail e o
599
+ * segundo fator virava enfeite.
600
+ *
601
+ * A pergunta que o gate responde é "existe um fator que esta pessoa consegue
602
+ * apresentar AGORA?", e não `mfa.enabled` — que liga ao registrar uma passkey
603
+ * e não desliga ao remover a última. Fator utilizável = TOTP confirmado
604
+ * (`mfa.totp`) OU ao menos uma passkey registrada. Pela regra antiga, quem
605
+ * registrava uma passkey e depois a removia ficava preso num desafio TOTP que
606
+ * não tinha como responder.
607
+ *
608
+ * - sem fator e sem step-up → `none`, o login segue;
609
+ * - step-up (acr) exigindo MFA numa conta sem fator → `challenge` com a
610
+ * instrução de enrolar (não há o que desafiar);
611
+ * - fator presente, trusted-device válido e sem step-up → `trusted`; quem
612
+ * chamou decide como finalizar, porque o amr muda conforme o caminho;
613
+ * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY`.
614
+ */
615
+ async secondFactorGate(ctx, cfg, accountId, details) {
616
+ const mfaRequired = this.acrRequiresMfa(cfg, details);
617
+ const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
618
+ // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
619
+ // tem ao menos uma credencial registrada.
620
+ const passkeyAvailable = await this.hasPasskeys(cfg, accountId);
621
+ // Store que não reporta `totp` (a chave é opcional) cai no `enabled` — o
622
+ // comportamento antigo. Nunca o contrário: assumir "sem TOTP" por omissão
623
+ // deixaria de desafiar quem tem o fator.
624
+ const totpAvailable = mfa.totp ?? !!mfa.enabled;
625
+ const hasFactor = passkeyAvailable || totpAvailable;
626
+ if (!hasFactor && !mfaRequired)
627
+ return { kind: 'none' };
628
+ const render = cfg.render;
629
+ const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
630
+ const uid = ctx.request.param('uid');
631
+ if (!hasFactor) {
632
+ return {
633
+ kind: 'challenge',
634
+ response: await render(ctx, 'mfa-challenge', {
635
+ uid,
636
+ csrfToken: ctx.request.csrfToken,
637
+ brand,
638
+ passkeyAvailable: false,
639
+ error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
640
+ noEnrollment: true,
641
+ }),
642
+ };
643
+ }
644
+ if (cfg.trustedDevices.enabled && !mfaRequired) {
645
+ const trusted = await this.checkTrustedDevice(ctx, accountId, mfa.enabledAt ?? null);
646
+ if (trusted)
647
+ return { kind: 'trusted' };
648
+ }
649
+ ctx.session.put(MFA_PENDING_KEY, accountId);
650
+ return {
651
+ kind: 'challenge',
652
+ response: await render(ctx, 'mfa-challenge', {
653
+ uid,
654
+ csrfToken: ctx.request.csrfToken,
655
+ brand,
656
+ passkeyAvailable,
657
+ totpAvailable,
658
+ trustedDevicesEnabled: cfg.trustedDevices.enabled,
659
+ trustedDeviceDays: cfg.trustedDevices.days,
660
+ }),
661
+ };
662
+ }
663
+ /**
664
+ * true se a conta tem um app autenticador CONFIRMADO. Stores que não reportam
665
+ * `totp` (a chave é opcional em {@link MfaCapability}) caem no `enabled`, o
666
+ * comportamento antigo.
667
+ */
668
+ async hasTotp(cfg, accountId) {
669
+ const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
670
+ return mfa.totp ?? !!mfa.enabled;
671
+ }
644
672
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
645
673
  async hasPasskeys(cfg, accountId) {
646
674
  if (!supportsPasskeys(cfg.accountStore))
@@ -728,7 +756,7 @@ export default class AuthInteractionController {
728
756
  return pending;
729
757
  if (!cfg.passwordless?.passkeyFirst)
730
758
  return undefined;
731
- const email = lookupEmail(ctx);
759
+ const email = ctx.session.get(SESSION_KEY);
732
760
  if (!email)
733
761
  return undefined;
734
762
  const acc = await cfg.accountStore.findByEmail(email);
@@ -749,9 +777,6 @@ export default class AuthInteractionController {
749
777
  const details = await service.interactions.details(ctx);
750
778
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
751
779
  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
780
  const uid = ctx.request.param('uid');
756
781
  // Login por OTP: liga o campo de código na tela "link enviado" quando a config
757
782
  // está ligada E o store suporta a capacidade.
@@ -763,7 +788,7 @@ export default class AuthInteractionController {
763
788
  // Preferência por usuário: magic link desligado para ESTA conta → não emite
764
789
  // token (resposta uniforme "enviado", sem vazar a preferência).
765
790
  const userScopedMethods = email && cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore)
766
- ? await this.#userScopedLoginMethods(ctx, cfg, storeEmail)
791
+ ? await this.#userScopedLoginMethods(ctx, cfg, email)
767
792
  : null;
768
793
  if (userScopedMethods && !userScopedMethods.authMethods.magicLink) {
769
794
  return render(ctx, 'login', {
@@ -778,17 +803,17 @@ export default class AuthInteractionController {
778
803
  magicChannel: 'none',
779
804
  });
780
805
  }
781
- if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && storeEmail) {
806
+ if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
782
807
  const ip = ctx.request.ip?.() ?? null;
783
808
  const clientId = details.params.client_id ?? null;
784
809
  // Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
785
810
  // senão, o magic link puro de sempre.
786
811
  const issued = otpEnabled
787
- ? await cfg.accountStore.issueMagicLinkWithCode(storeEmail, uid, {
812
+ ? await cfg.accountStore.issueMagicLinkWithCode(email, uid, {
788
813
  digits: cfg.login.otp.digits,
789
814
  ttlMinutes: cfg.login.otp.ttlMinutes,
790
815
  })
791
- : await cfg.accountStore.issueMagicLinkToken(storeEmail);
816
+ : await cfg.accountStore.issueMagicLinkToken(email);
792
817
  if (issued) {
793
818
  const code = 'code' in issued ? issued.code : undefined;
794
819
  await cfg.audit?.record({
@@ -823,7 +848,7 @@ export default class AuthInteractionController {
823
848
  // local usado acima para decidir a emissão) — não re-declarado aqui.
824
849
  return render(ctx, 'login', {
825
850
  ...(email
826
- ? await this.#userScopedLoginMethods(ctx, cfg, storeEmail)
851
+ ? await this.#userScopedLoginMethods(ctx, cfg, email)
827
852
  : await this.#loginMethods(ctx, cfg)),
828
853
  uid,
829
854
  csrfToken: ctx.request.csrfToken,
@@ -945,12 +970,21 @@ export default class AuthInteractionController {
945
970
  error: translate(cfg.messages, accountStatusErrorKey(magicLinkStatusGate.reason)),
946
971
  });
947
972
  }
973
+ // Conta com segundo fator não termina o login só com o e-mail.
974
+ const magicGate = await this.secondFactorGate(ctx, cfg, acc.id, await service.interactions.details(ctx));
975
+ if (magicGate.kind === 'challenge')
976
+ return magicGate.response;
948
977
  await notifyLoginSuccess(ctx, cfg, {
949
978
  accountId: acc.id,
950
979
  email: acc.email,
951
980
  ip,
952
981
  clientId: clientId ?? null,
953
982
  metadata: { method: 'magic_link' },
983
+ // `trusted` = o gate já validou o cookie de confiança nesta request; dizer
984
+ // isso aqui poupa `notifyLoginSuccess` de reler o mesmo cookie para decidir
985
+ // se é dispositivo novo. O amr continua `['email']`: o fator primário foi o
986
+ // e-mail, a confiança só dispensou o segundo.
987
+ trustedDevice: magicGate.kind === 'trusted',
954
988
  });
955
989
  forgetLoginEmail(ctx);
956
990
  await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
@@ -973,11 +1007,9 @@ export default class AuthInteractionController {
973
1007
  const ip = ctx.request.ip?.() ?? null;
974
1008
  const clientId = (await service.interactions.details(ctx)).params.client_id;
975
1009
  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
1010
  // Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
979
1011
  const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
980
- if (!otpEnabled || !email || !storeEmail) {
1012
+ if (!otpEnabled || !email) {
981
1013
  return ctx.response.redirect(`/auth/interaction/${uid}`);
982
1014
  }
983
1015
  const code = String(ctx.request.input('code', '') ?? '').trim();
@@ -985,14 +1017,14 @@ export default class AuthInteractionController {
985
1017
  // Mantém a sub-view do seletor no re-render de erro (o form de código pode
986
1018
  // POSTar `channel=code`). Ausente = both (histórico).
987
1019
  const channel = normalizeLoginChannel(ctx.request.input('channel'));
988
- const result = await cfg.accountStore.verifyLoginCode(storeEmail, uid, code, {
1020
+ const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
989
1021
  maxAttempts: cfg.login.otp.maxAttempts,
990
1022
  });
991
1023
  // Re-render da tela "link enviado" com o campo de código + erro localizado.
992
1024
  // `otpEnabled` vem do spread (scoped — OTP é extensão do magic link, e a
993
1025
  // preferência do usuário que desligou magic link já foi barrada acima).
994
1026
  const renderOtpError = async (messageKey) => render(ctx, 'login', {
995
- ...(await this.#userScopedLoginMethods(ctx, cfg, storeEmail)),
1027
+ ...(await this.#userScopedLoginMethods(ctx, cfg, email)),
996
1028
  uid,
997
1029
  csrfToken: ctx.request.csrfToken,
998
1030
  step: 'password',
@@ -1060,25 +1092,30 @@ export default class AuthInteractionController {
1060
1092
  ip,
1061
1093
  clientId,
1062
1094
  });
1095
+ // Conta com segundo fator não termina o login só com o código do e-mail.
1096
+ const otpGate = await this.secondFactorGate(ctx, cfg, result.account.id, await service.interactions.details(ctx));
1097
+ if (otpGate.kind === 'challenge')
1098
+ return otpGate.response;
1063
1099
  await notifyLoginSuccess(ctx, cfg, {
1064
1100
  accountId: result.account.id,
1065
1101
  email: result.account.email,
1066
1102
  ip,
1067
1103
  clientId: clientId ?? null,
1068
1104
  metadata: { method: 'otp' },
1105
+ trustedDevice: otpGate.kind === 'trusted',
1069
1106
  });
1070
1107
  forgetLoginEmail(ctx);
1071
1108
  return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
1072
1109
  }
1073
1110
  if (result.status === 'locked') {
1074
1111
  // 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 });
1112
+ await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
1076
1113
  return renderOtpError('login.otp_locked');
1077
1114
  }
1078
1115
  if (result.status === 'expired') {
1079
1116
  await cfg.audit?.record({
1080
1117
  type: 'login.otp_failed',
1081
- email: storeEmail,
1118
+ email,
1082
1119
  ip,
1083
1120
  clientId,
1084
1121
  metadata: { reason: 'expired' },
@@ -1088,7 +1125,7 @@ export default class AuthInteractionController {
1088
1125
  // 'invalid' (tentativa contabilizada) ou 'no_code'.
1089
1126
  await cfg.audit?.record({
1090
1127
  type: 'login.otp_failed',
1091
- email: storeEmail,
1128
+ email,
1092
1129
  ip,
1093
1130
  clientId,
1094
1131
  metadata: { reason: result.status },
@@ -1252,6 +1289,7 @@ export default class AuthInteractionController {
1252
1289
  error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
1253
1290
  brand,
1254
1291
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1292
+ totpAvailable: await this.hasTotp(cfg, accountId),
1255
1293
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
1256
1294
  trustedDeviceDays: cfg.trustedDevices.days,
1257
1295
  });
@@ -1274,6 +1312,7 @@ export default class AuthInteractionController {
1274
1312
  error: translate(cfg.messages, 'errors.email_unverified'),
1275
1313
  brand,
1276
1314
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1315
+ totpAvailable: await this.hasTotp(cfg, accountId),
1277
1316
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
1278
1317
  trustedDeviceDays: cfg.trustedDevices.days,
1279
1318
  });
@@ -1304,6 +1343,7 @@ export default class AuthInteractionController {
1304
1343
  error: translate(cfg.messages, accountStatusErrorKey(passkeyStatusGate.reason)),
1305
1344
  brand,
1306
1345
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1346
+ totpAvailable: await this.hasTotp(cfg, accountId),
1307
1347
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
1308
1348
  trustedDeviceDays: cfg.trustedDevices.days,
1309
1349
  });
@@ -1400,20 +1440,10 @@ export default class AuthInteractionController {
1400
1440
  });
1401
1441
  // Senha trocada: limpa o step e finaliza o login.
1402
1442
  ctx.session.forget(PASSWORD_EXPIRED_KEY);
1403
- // Verifica se precisa de MFA mesmo após a troca.
1404
- const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
1405
- if (mfa.enabled) {
1406
- ctx.session.put(MFA_PENDING_KEY, accountId);
1407
- const passkeyAvailable = await this.hasPasskeys(cfg, accountId);
1408
- return render(ctx, 'mfa-challenge', {
1409
- uid: ctx.request.param('uid'),
1410
- csrfToken: ctx.request.csrfToken,
1411
- brand,
1412
- passkeyAvailable,
1413
- trustedDevicesEnabled: cfg.trustedDevices.enabled,
1414
- trustedDeviceDays: cfg.trustedDevices.days,
1415
- });
1416
- }
1443
+ // Verifica se precisa de MFA mesmo após a troca — mesma regra do resto.
1444
+ const expiredGate = await this.secondFactorGate(ctx, cfg, accountId, details);
1445
+ if (expiredGate.kind === 'challenge')
1446
+ return expiredGate.response;
1417
1447
  await service.interactions.completeLogin(ctx, accountId);
1418
1448
  const account = await cfg.accountStore.findById(accountId);
1419
1449
  await notifyLoginSuccess(ctx, cfg, {
@@ -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,