@adonis-agora/authkit-server 0.71.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.
@@ -17,8 +17,11 @@
17
17
  @end
18
18
 
19
19
  {{-- Step-up sem MFA enrolado: bloqueia o login e instrui a configurar o MFA;
20
- não renderiza o campo de código (não há 2º fator a desafiar). --}}
21
- @if(!noEnrollment)
20
+ não renderiza o campo de código (não há 2º fator a desafiar).
21
+ `totpAvailable === false` = a conta só tem passkey, sem app autenticador
22
+ confirmado: pedir um código de 6 dígitos seria pedir o impossível. Ausente
23
+ (stores que não reportam o estado) mantém o campo visível. --}}
24
+ @if(!noEnrollment && totpAvailable !== false)
22
25
  <div class="mt-6">
23
26
  <label for="code" class="mb-1 block text-sm font-medium text-gray-700">{{ t('mfa_challenge.code_label') }}</label>
24
27
  <input id="code" name="code" inputmode="numeric" autocomplete="one-time-code"
@@ -56,7 +59,9 @@
56
59
  <p id="passkey-error" class="mt-3 hidden text-sm text-red-600">{{ t('mfa_challenge.passkey_error') }}</p>
57
60
  @end
58
61
 
59
- @if(!noEnrollment)
62
+ {{-- Códigos de recuperação só existem junto com o TOTP (são gerados no
63
+ enrollment); sem ele, a seção não tem o que receber. --}}
64
+ @if(!noEnrollment && totpAvailable !== false)
60
65
  <details class="mt-6 text-sm text-gray-600">
61
66
  <summary class="cursor-pointer hover:underline">{{ t('mfa_challenge.recovery_summary') }}</summary>
62
67
  <form method="POST" action="/auth/interaction/{{ uid }}/mfa" class="mt-3">
@@ -250,10 +250,18 @@ export interface MfaCapability {
250
250
  * mecanismo de "trusted devices": um cookie de confiança emitido ANTES desse
251
251
  * instante é considerado inválido (re-enrolar MFA revoga a confiança). Pode ser
252
252
  * `null`/ausente quando o MFA não está ativo ou o store não rastreia o instante.
253
+ *
254
+ * `totp` diz se há um app autenticador CONFIRMADO (segredo + códigos de
255
+ * recuperação). É diferente de `enabled`: registrar uma passkey também liga o
256
+ * MFA, e remover a última passkey não o desliga — então `enabled` sozinho não
257
+ * responde "existe um segundo fator que esta pessoa consegue apresentar?".
258
+ * Quem decide mostrar (ou não) o desafio precisa de `totp`; stores antigos que
259
+ * o omitem continuam funcionando, só não distinguem os dois casos.
253
260
  */
254
261
  getMfaState(accountId: string): Promise<{
255
262
  enabled: boolean;
256
263
  enabledAt?: number | null;
264
+ totp?: boolean;
257
265
  }>;
258
266
  /**
259
267
  * Inicia o enrollment TOTP: gera um segredo PENDENTE (mfaEnabledAt continua
@@ -25,7 +25,15 @@ export function buildMfa(ctx) {
25
25
  const state = await repo.read(accountId);
26
26
  // `enabledAt` (epoch ms) habilita o trusted-device check: um cookie de
27
27
  // confiança emitido ANTES deste instante é inválido (re-enrolar revoga).
28
- return { enabled: !!state?.mfaEnabledAt, enabledAt: state?.mfaEnabledAt ?? null };
28
+ // `totp` é o fator REALMENTE utilizável: segredo confirmado + códigos de
29
+ // recuperação gravados. `enabled` também liga ao registrar passkey (ver
30
+ // `webauthn.ts`) e não desliga ao remover a última — sozinho, não diz se
31
+ // sobrou algum fator para desafiar.
32
+ return {
33
+ enabled: !!state?.mfaEnabledAt,
34
+ enabledAt: state?.mfaEnabledAt ?? null,
35
+ totp: !!(state?.mfaEnabledAt && state?.totpSecret && state?.recoveryCodes),
36
+ };
29
37
  },
30
38
  async startTotpEnrollment(accountId) {
31
39
  // O email/QR vem do model principal; só o ESTADO de MFA vive em auth_mfa.
@@ -41,6 +41,34 @@ export default class AuthInteractionController {
41
41
  * Retorna `undefined` quando não há step-up — completeLogin usa o default.
42
42
  */
43
43
  private stepUpExtra;
44
+ /**
45
+ * Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
46
+ * mágico, código por e-mail e troca forçada de senha. Antes disto só o login
47
+ * por senha passava pelo MFA — link e código completavam a interaction direto,
48
+ * então uma conta com TOTP ou passkey entrava apresentando só o e-mail e o
49
+ * segundo fator virava enfeite.
50
+ *
51
+ * A pergunta que o gate responde é "existe um fator que esta pessoa consegue
52
+ * apresentar AGORA?", e não `mfa.enabled` — que liga ao registrar uma passkey
53
+ * e não desliga ao remover a última. Fator utilizável = TOTP confirmado
54
+ * (`mfa.totp`) OU ao menos uma passkey registrada. Pela regra antiga, quem
55
+ * registrava uma passkey e depois a removia ficava preso num desafio TOTP que
56
+ * não tinha como responder.
57
+ *
58
+ * - sem fator e sem step-up → `none`, o login segue;
59
+ * - step-up (acr) exigindo MFA numa conta sem fator → `challenge` com a
60
+ * instrução de enrolar (não há o que desafiar);
61
+ * - fator presente, trusted-device válido e sem step-up → `trusted`; quem
62
+ * chamou decide como finalizar, porque o amr muda conforme o caminho;
63
+ * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY`.
64
+ */
65
+ private secondFactorGate;
66
+ /**
67
+ * true se a conta tem um app autenticador CONFIRMADO. Stores que não reportam
68
+ * `totp` (a chave é opcional em {@link MfaCapability}) caem no `enabled`, o
69
+ * comportamento antigo.
70
+ */
71
+ private hasTotp;
44
72
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
45
73
  private hasPasskeys;
46
74
  /**
@@ -405,55 +405,24 @@ export default class AuthInteractionController {
405
405
  }
406
406
  // Admin: prossegue normalmente (sem bloqueio).
407
407
  }
408
- // Step-up auth (acr_values): o client pode EXIGIR MFA nesta requisição
409
- // solicitando o `mfaAcr` em acr_values, mesmo que a conta tenha MFA opcional.
410
- const mfaRequired = this.acrRequiresMfa(cfg, details);
411
- // MFA gate: força o 2º fator se a conta tem TOTP ativo OU se o client exige MFA
412
- // via acr. Não finaliza a interaction agora — guarda o accountId pendente.
413
- const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
414
- if (mfa.enabled || mfaRequired) {
415
- if (mfaRequired && !mfa.enabled) {
416
- // Client exige MFA mas a conta não tem MFA enrolado: bloqueia este login
417
- // com a instrução de configurar MFA no console (não há 2º fator a desafiar).
418
- return render(ctx, 'mfa-challenge', {
419
- uid: ctx.request.param('uid'),
420
- csrfToken: ctx.request.csrfToken,
421
- brand,
422
- passkeyAvailable: false,
423
- error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
424
- noEnrollment: true,
425
- });
426
- }
427
- // Trusted device: se o mecanismo está ligado, a conta JÁ tem MFA enrolado e
428
- // o request NÃO é um step-up (que sempre força o MFA), um cookie de confiança
429
- // válido para ESTA conta pula o 2º fator. amr fica `['pwd']` (sem acr de MFA).
430
- if (cfg.trustedDevices.enabled && mfa.enabled && !mfaRequired) {
431
- const trusted = await this.checkTrustedDevice(ctx, acc.id, mfa.enabledAt ?? null);
432
- if (trusted) {
433
- await service.interactions.completeLogin(ctx, acc.id, { amr: ['pwd'] });
434
- await notifyLoginSuccess(ctx, cfg, {
435
- accountId: acc.id,
436
- email,
437
- ip,
438
- clientId,
439
- trustedDevice: true,
440
- });
441
- forgetLoginEmail(ctx);
442
- return;
443
- }
444
- }
445
- ctx.session.put(MFA_PENDING_KEY, acc.id);
446
- // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
447
- // tem ao menos uma credencial registrada.
448
- const passkeyAvailable = await this.hasPasskeys(cfg, acc.id);
449
- return render(ctx, 'mfa-challenge', {
450
- uid: ctx.request.param('uid'),
451
- csrfToken: ctx.request.csrfToken,
452
- brand,
453
- passkeyAvailable,
454
- trustedDevicesEnabled: cfg.trustedDevices.enabled,
455
- 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,
456
423
  });
424
+ forgetLoginEmail(ctx);
425
+ return;
457
426
  }
458
427
  // Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
459
428
  // Resolve session_policy para remember-me e single-session.
@@ -531,6 +500,7 @@ export default class AuthInteractionController {
531
500
  error: translate(cfg.messages, 'errors.otp_locked'),
532
501
  brand,
533
502
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
503
+ totpAvailable: await this.hasTotp(cfg, accountId),
534
504
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
535
505
  trustedDeviceDays: cfg.trustedDevices.days,
536
506
  otpLocked: true,
@@ -564,6 +534,7 @@ export default class AuthInteractionController {
564
534
  error: translate(cfg.messages, 'errors.otp_locked'),
565
535
  brand,
566
536
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
537
+ totpAvailable: await this.hasTotp(cfg, accountId),
567
538
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
568
539
  trustedDeviceDays: cfg.trustedDevices.days,
569
540
  otpLocked: true,
@@ -575,6 +546,7 @@ export default class AuthInteractionController {
575
546
  error: translate(cfg.messages, 'errors.invalid_code'),
576
547
  brand,
577
548
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
549
+ totpAvailable: await this.hasTotp(cfg, accountId),
578
550
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
579
551
  trustedDeviceDays: cfg.trustedDevices.days,
580
552
  });
@@ -619,6 +591,84 @@ export default class AuthInteractionController {
619
591
  return undefined;
620
592
  return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
621
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
+ }
622
672
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
623
673
  async hasPasskeys(cfg, accountId) {
624
674
  if (!supportsPasskeys(cfg.accountStore))
@@ -920,12 +970,21 @@ export default class AuthInteractionController {
920
970
  error: translate(cfg.messages, accountStatusErrorKey(magicLinkStatusGate.reason)),
921
971
  });
922
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;
923
977
  await notifyLoginSuccess(ctx, cfg, {
924
978
  accountId: acc.id,
925
979
  email: acc.email,
926
980
  ip,
927
981
  clientId: clientId ?? null,
928
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',
929
988
  });
930
989
  forgetLoginEmail(ctx);
931
990
  await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
@@ -1033,12 +1092,17 @@ export default class AuthInteractionController {
1033
1092
  ip,
1034
1093
  clientId,
1035
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;
1036
1099
  await notifyLoginSuccess(ctx, cfg, {
1037
1100
  accountId: result.account.id,
1038
1101
  email: result.account.email,
1039
1102
  ip,
1040
1103
  clientId: clientId ?? null,
1041
1104
  metadata: { method: 'otp' },
1105
+ trustedDevice: otpGate.kind === 'trusted',
1042
1106
  });
1043
1107
  forgetLoginEmail(ctx);
1044
1108
  return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
@@ -1225,6 +1289,7 @@ export default class AuthInteractionController {
1225
1289
  error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
1226
1290
  brand,
1227
1291
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1292
+ totpAvailable: await this.hasTotp(cfg, accountId),
1228
1293
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
1229
1294
  trustedDeviceDays: cfg.trustedDevices.days,
1230
1295
  });
@@ -1247,6 +1312,7 @@ export default class AuthInteractionController {
1247
1312
  error: translate(cfg.messages, 'errors.email_unverified'),
1248
1313
  brand,
1249
1314
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1315
+ totpAvailable: await this.hasTotp(cfg, accountId),
1250
1316
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
1251
1317
  trustedDeviceDays: cfg.trustedDevices.days,
1252
1318
  });
@@ -1277,6 +1343,7 @@ export default class AuthInteractionController {
1277
1343
  error: translate(cfg.messages, accountStatusErrorKey(passkeyStatusGate.reason)),
1278
1344
  brand,
1279
1345
  passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1346
+ totpAvailable: await this.hasTotp(cfg, accountId),
1280
1347
  trustedDevicesEnabled: cfg.trustedDevices.enabled,
1281
1348
  trustedDeviceDays: cfg.trustedDevices.days,
1282
1349
  });
@@ -1373,20 +1440,10 @@ export default class AuthInteractionController {
1373
1440
  });
1374
1441
  // Senha trocada: limpa o step e finaliza o login.
1375
1442
  ctx.session.forget(PASSWORD_EXPIRED_KEY);
1376
- // Verifica se precisa de MFA mesmo após a troca.
1377
- const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
1378
- if (mfa.enabled) {
1379
- ctx.session.put(MFA_PENDING_KEY, accountId);
1380
- const passkeyAvailable = await this.hasPasskeys(cfg, accountId);
1381
- return render(ctx, 'mfa-challenge', {
1382
- uid: ctx.request.param('uid'),
1383
- csrfToken: ctx.request.csrfToken,
1384
- brand,
1385
- passkeyAvailable,
1386
- trustedDevicesEnabled: cfg.trustedDevices.enabled,
1387
- trustedDeviceDays: cfg.trustedDevices.days,
1388
- });
1389
- }
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;
1390
1447
  await service.interactions.completeLogin(ctx, accountId);
1391
1448
  const account = await cfg.accountStore.findById(accountId);
1392
1449
  await notifyLoginSuccess(ctx, cfg, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",