@adonis-agora/authkit-server 0.76.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +145 -0
  2. package/build/host/views/mfa-challenge.edge +42 -3
  3. package/build/index.d.ts +9 -1
  4. package/build/index.js +5 -1
  5. package/build/src/accounts/account_store.d.ts +2 -0
  6. package/build/src/accounts/lucid_account_store.js +2 -0
  7. package/build/src/adapters/adapter_contract.d.ts +2 -0
  8. package/build/src/adapters/database_adapter.d.ts +1 -0
  9. package/build/src/adapters/database_adapter.js +26 -0
  10. package/build/src/adapters/redis_adapter.d.ts +1 -0
  11. package/build/src/adapters/redis_adapter.js +30 -0
  12. package/build/src/define_config.d.ts +24 -0
  13. package/build/src/define_config.js +8 -0
  14. package/build/src/host/controllers/account_security_controller.js +8 -4
  15. package/build/src/host/controllers/interaction_controller.d.ts +13 -1
  16. package/build/src/host/controllers/interaction_controller.js +352 -24
  17. package/build/src/host/custom_login.d.ts +36 -0
  18. package/build/src/host/custom_login.js +76 -0
  19. package/build/src/host/custom_login_completion.d.ts +4 -0
  20. package/build/src/host/custom_login_completion.js +33 -0
  21. package/build/src/host/custom_mfa.d.ts +77 -0
  22. package/build/src/host/custom_mfa.js +268 -0
  23. package/build/src/host/i18n.d.ts +4 -0
  24. package/build/src/host/i18n.js +10 -6
  25. package/build/src/host/idp_session_bridge.d.ts +5 -3
  26. package/build/src/host/idp_session_bridge.js +5 -3
  27. package/build/src/host/oidc_rp_guard.d.ts +5 -1
  28. package/build/src/host/oidc_rp_guard.js +15 -2
  29. package/build/src/host/persistent_rp_session.d.ts +8 -0
  30. package/build/src/host/persistent_rp_session.js +74 -0
  31. package/build/src/host/register_auth_host.js +12 -0
  32. package/build/src/host/whatsapp_code_sender.d.ts +20 -0
  33. package/build/src/host/whatsapp_code_sender.js +9 -0
  34. package/build/src/host/whatsapp_senders/http.d.ts +5 -0
  35. package/build/src/host/whatsapp_senders/http.js +33 -0
  36. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
  37. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
  38. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
  39. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
  40. package/build/src/provider/oidc_service.js +6 -2
  41. package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
  42. package/package.json +2 -1
  43. package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
@@ -3,6 +3,9 @@ import { supportsLoginMethodsPreference, supportsMagicLink, supportsOtpLogin, su
3
3
  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
+ import { isCustomLoginMethodId } from '../custom_login.js';
7
+ import { completeCustomLoginSession } from '../custom_login_completion.js';
8
+ import { beginCustomMfa, clearMfaFlow, customMfaViewProps, getMfaFlow, initializeMfaFlow, recordVerifiedMfaFactor, resolveCustomMfaMethods, verifyCustomMfa, } from '../custom_mfa.js';
6
9
  import { sendMagicLinkEmail, sendOtpUnlockEmail } from '../default_mailer.js';
7
10
  import { normalizeEmailIdentifier } from '../email_identifier.js';
8
11
  import { translate } from '../i18n.js';
@@ -51,6 +54,8 @@ const MFA_PENDING_KEY = 'authkit_mfa_pending';
51
54
  * começou e o `amr` do id_token perdia o primeiro fator (ver `mfaCompletion`).
52
55
  */
53
56
  const MFA_PRIMARY_KEY = 'authkit_mfa_primary';
57
+ /** Written only after trusted custom authentication reaches the MFA gate. */
58
+ const MFA_CUSTOM_PRIMARY_KEY = 'authkit_mfa_custom_primary';
54
59
  /** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
55
60
  const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
56
61
  /**
@@ -64,6 +69,72 @@ function forgetLoginEmail(ctx) {
64
69
  ctx.session.forget(SESSION_KEY);
65
70
  }
66
71
  export default class AuthInteractionController {
72
+ /** Shared completion for trusted host authentication; primary proof is the host's responsibility. */
73
+ async completeCustomLogin(ctx, input) {
74
+ if (!isCustomLoginMethodId(input.method))
75
+ throw new Error('Invalid custom login method');
76
+ const service = await ctx.containerResolver.make('authkit.server');
77
+ const cfg = service.config;
78
+ const render = cfg.render;
79
+ if (!render)
80
+ throw new Error('AuthKit login renderer is not configured');
81
+ const details = await service.interactions.details(ctx);
82
+ if (details.uid !== ctx.request.param('uid') || details.prompt.name !== 'login') {
83
+ throw new Error('Invalid login interaction');
84
+ }
85
+ const account = await cfg.accountStore.findById(input.accountId);
86
+ if (!account)
87
+ throw new Error('Unknown account');
88
+ const settings = await resolveRuntimeSettingsOrNoop(ctx);
89
+ const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
90
+ const fail = (key) => render(ctx, 'login', {
91
+ uid: details.uid,
92
+ csrfToken: ctx.request.csrfToken,
93
+ brand,
94
+ error: translate(cfg.messages, key),
95
+ });
96
+ if (await isEmailUnverifiedBlock(cfg, account.id, settings))
97
+ return fail('errors.email_unverified');
98
+ const status = await assertLoginAllowed(cfg, account.id, {
99
+ email: account.email,
100
+ ip: ctx.request.ip(),
101
+ clientId: details.params.client_id,
102
+ settings,
103
+ passwordless: input.passwordless === true,
104
+ });
105
+ if (!status.allowed)
106
+ return fail(accountStatusErrorKey(status.reason));
107
+ const maintenance = await resolveEffectiveMaintenanceMode(settings);
108
+ if (maintenance.enabled &&
109
+ !(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
110
+ return render(ctx, 'maintenance', {
111
+ uid: details.uid,
112
+ csrfToken: ctx.request.csrfToken,
113
+ brand,
114
+ message: maintenance.message ?? translate(cfg.messages, 'maintenance.default_message'),
115
+ });
116
+ }
117
+ const gate = await this.secondFactorGate(ctx, cfg, account.id, details, input.method, input.factorId, input.remember, input.passwordless === true);
118
+ if (gate.kind === 'challenge') {
119
+ ctx.session.put(MFA_CUSTOM_PRIMARY_KEY, {
120
+ method: input.method,
121
+ accountId: account.id,
122
+ uid: details.uid,
123
+ remember: input.remember === true,
124
+ });
125
+ return gate.response;
126
+ }
127
+ await notifyLoginSuccess(ctx, cfg, {
128
+ accountId: account.id,
129
+ email: account.email,
130
+ ip: ctx.request.ip(),
131
+ clientId: details.params.client_id ?? null,
132
+ metadata: { method: input.method },
133
+ trustedDevice: gate.kind === 'trusted',
134
+ });
135
+ forgetLoginEmail(ctx);
136
+ return completeCustomLoginSession(ctx, account.id, { amr: [input.method] }, input.remember === true);
137
+ }
67
138
  /**
68
139
  * Métodos de login efetivos (com os pins do `cfg.authMethods`) para QUALQUER render do
69
140
  * passo login. Sem isto, os re-renders (erro de senha, magic link enviado, lockout…) mandam
@@ -481,13 +552,225 @@ export default class AuthInteractionController {
481
552
  * 2º fator: lê o accountId pendente da sessão e aceita um código TOTP (`code`)
482
553
  * OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
483
554
  */
555
+ async renderMfaChallenge(ctx, cfg, props) {
556
+ const flow = getMfaFlow(ctx);
557
+ const custom = flow ? await customMfaViewProps(ctx) : {};
558
+ const used = new Set(flow ? [flow.primaryFactorId, ...flow.completed.map((entry) => entry.factorId)] : []);
559
+ return cfg.render(ctx, 'mfa-challenge', {
560
+ ...props,
561
+ ...custom,
562
+ ...(used.has('totp') ? { totpAvailable: false } : {}),
563
+ ...(used.has('webauthn') ? { passkeyAvailable: false } : {}),
564
+ });
565
+ }
566
+ async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd') {
567
+ const requiredFactors = cfg.mfa?.requiredFactors ?? 2;
568
+ const mfaRequired = this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
569
+ const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
570
+ const enrolledTotp = mfa.totp ?? mfa.enabled;
571
+ const enrolledPasskey = await this.hasPasskeys(cfg, accountId);
572
+ ctx.session.put(MFA_PENDING_KEY, accountId);
573
+ ctx.session.put(MFA_PRIMARY_KEY, primary);
574
+ const rememberValue = ctx.request.input('remember');
575
+ initializeMfaFlow(ctx, {
576
+ uid: details.uid,
577
+ accountId,
578
+ primaryMethod: primary,
579
+ primaryFactorId,
580
+ passwordless,
581
+ requiredFactors,
582
+ remember: rememberRequested ??
583
+ (rememberValue === true || ['on', '1', 'true'].includes(String(rememberValue))),
584
+ });
585
+ const flow = getMfaFlow(ctx);
586
+ const custom = await resolveCustomMfaMethods(ctx, accountId, details.uid, primary, flow.challengeId);
587
+ const totpAvailable = Boolean(enrolledTotp && primaryFactorId !== 'totp');
588
+ const passkeyAvailable = Boolean(enrolledPasskey && primaryFactorId !== 'webauthn');
589
+ const groups = new Set(custom.map((method) => method.factorId).filter((group) => group !== primaryFactorId));
590
+ if (totpAvailable)
591
+ groups.add('totp');
592
+ if (passkeyAvailable)
593
+ groups.add('webauthn');
594
+ const enrolled = Boolean(enrolledTotp || enrolledPasskey || custom.length);
595
+ if (!enrolled && !mfaRequired) {
596
+ clearMfaFlow(ctx);
597
+ ctx.session.forget(MFA_PENDING_KEY);
598
+ ctx.session.forget(MFA_PRIMARY_KEY);
599
+ return { kind: 'none' };
600
+ }
601
+ const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
602
+ if (groups.size < requiredFactors - 1) {
603
+ clearMfaFlow(ctx);
604
+ ctx.session.forget(MFA_PENDING_KEY);
605
+ ctx.session.forget(MFA_PRIMARY_KEY);
606
+ return {
607
+ kind: 'challenge',
608
+ response: await this.renderMfaChallenge(ctx, cfg, {
609
+ uid: details.uid,
610
+ csrfToken: ctx.request.csrfToken,
611
+ brand,
612
+ noEnrollment: true,
613
+ totpAvailable: false,
614
+ passkeyAvailable: false,
615
+ error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
616
+ }),
617
+ };
618
+ }
619
+ // Custom enrollment does not expose a revocation timestamp; never bypass it with a legacy trusted-device cookie.
620
+ if (cfg.trustedDevices.enabled &&
621
+ !mfaRequired &&
622
+ !custom.length &&
623
+ requiredFactors === 2 &&
624
+ (await this.checkTrustedDevice(ctx, accountId, mfa.enabledAt ?? null))) {
625
+ clearMfaFlow(ctx);
626
+ ctx.session.forget(MFA_PENDING_KEY);
627
+ ctx.session.forget(MFA_PRIMARY_KEY);
628
+ return { kind: 'trusted' };
629
+ }
630
+ return {
631
+ kind: 'challenge',
632
+ response: await this.renderMfaChallenge(ctx, cfg, {
633
+ uid: details.uid,
634
+ csrfToken: ctx.request.csrfToken,
635
+ brand,
636
+ totpAvailable,
637
+ passkeyAvailable,
638
+ trustedDevicesEnabled: cfg.trustedDevices.enabled && !custom.length,
639
+ trustedDeviceDays: cfg.trustedDevices.days,
640
+ }),
641
+ };
642
+ }
643
+ async customMfaBegin(ctx) {
644
+ await beginCustomMfa(ctx, String(ctx.request.param('method')));
645
+ const service = await ctx.containerResolver.make('authkit.server');
646
+ const flow = getMfaFlow(ctx);
647
+ return this.renderMfaChallenge(ctx, service.config, {
648
+ uid: flow.uid,
649
+ csrfToken: ctx.request.csrfToken,
650
+ totpAvailable: await this.hasTotp(service.config, flow.accountId),
651
+ passkeyAvailable: await this.hasPasskeys(service.config, flow.accountId),
652
+ brand: brandFor(service.config.branding, undefined),
653
+ });
654
+ }
655
+ async customMfaVerify(ctx) {
656
+ const service = await ctx.containerResolver.make('authkit.server');
657
+ const details = await service.interactions.details(ctx);
658
+ const method = String(ctx.request.param('method'));
659
+ let ok;
660
+ try {
661
+ ok = await verifyCustomMfa(ctx, method);
662
+ }
663
+ catch (error) {
664
+ await service.config.audit?.record({
665
+ type: 'login.failure',
666
+ accountId: getMfaFlow(ctx)?.accountId ?? null,
667
+ metadata: { stage: 'mfa', method },
668
+ });
669
+ throw error;
670
+ }
671
+ const flow = getMfaFlow(ctx);
672
+ if (!ok) {
673
+ await service.config.audit?.record({
674
+ type: 'login.failure',
675
+ accountId: flow?.accountId ?? null,
676
+ metadata: { stage: 'mfa', method },
677
+ });
678
+ if (!flow)
679
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
680
+ return this.renderMfaChallenge(ctx, service.config, {
681
+ uid: flow.uid,
682
+ csrfToken: ctx.request.csrfToken,
683
+ error: translate(service.config.messages, 'errors.invalid_code'),
684
+ totpAvailable: await this.hasTotp(service.config, flow.accountId),
685
+ passkeyAvailable: await this.hasPasskeys(service.config, flow.accountId),
686
+ brand: brandFor(service.config.branding, undefined),
687
+ });
688
+ }
689
+ if (!flow)
690
+ throw new Error('Missing MFA flow');
691
+ const descriptors = await resolveCustomMfaMethods(ctx, flow.accountId, flow.uid, flow.primaryMethod, flow.challengeId);
692
+ const descriptor = descriptors.find((item) => item.id === method);
693
+ if (!descriptor)
694
+ throw new Error('MFA method is no longer enabled');
695
+ return this.finishVerifiedMfa(ctx, service.config, details, method, descriptor.factorId);
696
+ }
697
+ async finishVerifiedMfa(ctx, cfg, details, method, factorId) {
698
+ const flow = getMfaFlow(ctx);
699
+ if (!flow)
700
+ throw new Error('Missing MFA flow');
701
+ const liveDetails = await (await ctx.containerResolver.make('authkit.server')).interactions.details(ctx);
702
+ if (liveDetails.uid !== flow.uid || liveDetails.prompt.name !== 'login') {
703
+ this.takeMfaPending(ctx);
704
+ clearMfaFlow(ctx);
705
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
706
+ }
707
+ const progress = recordVerifiedMfaFactor(ctx, method, factorId);
708
+ if (!progress.complete)
709
+ return this.renderMfaChallenge(ctx, cfg, {
710
+ uid: flow.uid,
711
+ csrfToken: ctx.request.csrfToken,
712
+ totpAvailable: await this.hasTotp(cfg, flow.accountId),
713
+ passkeyAvailable: await this.hasPasskeys(cfg, flow.accountId),
714
+ brand: brandFor(cfg.branding, undefined),
715
+ });
716
+ const settings = await resolveRuntimeSettingsOrNoop(ctx);
717
+ const account = await cfg.accountStore.findById(flow.accountId);
718
+ const status = account
719
+ ? await assertLoginAllowed(cfg, account.id, {
720
+ email: account.email,
721
+ ip: ctx.request.ip?.() ?? null,
722
+ settings,
723
+ passwordless: flow.passwordless === true,
724
+ })
725
+ : { allowed: false, reason: 'disabled' };
726
+ this.takeMfaPending(ctx);
727
+ clearMfaFlow(ctx);
728
+ if (!account ||
729
+ !status.allowed ||
730
+ (await isEmailUnverifiedBlock(cfg, flow.accountId, settings))) {
731
+ return cfg.render(ctx, 'login', {
732
+ uid: flow.uid,
733
+ csrfToken: ctx.request.csrfToken,
734
+ error: translate(cfg.messages, !status.allowed ? accountStatusErrorKey(status.reason) : 'errors.email_unverified'),
735
+ brand: brandFor(cfg.branding, undefined),
736
+ });
737
+ }
738
+ const maintenance = await resolveEffectiveMaintenanceMode(settings);
739
+ if (maintenance.enabled &&
740
+ !(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
741
+ return cfg.render(ctx, 'maintenance', {
742
+ uid: flow.uid,
743
+ csrfToken: ctx.request.csrfToken,
744
+ message: maintenance.message ?? translate(cfg.messages, 'maintenance.default_message'),
745
+ brand: brandFor(cfg.branding, undefined),
746
+ adminLoginAllowed: true,
747
+ });
748
+ }
749
+ forgetLoginEmail(ctx);
750
+ await notifyLoginSuccess(ctx, cfg, {
751
+ accountId: account.id,
752
+ email: account.email,
753
+ metadata: { mfa: flow.completed.map((entry) => entry.method) },
754
+ });
755
+ const stepUp = this.acrRequiresMfa(cfg, details) ? { acr: cfg.stepUp?.mfaAcr } : {};
756
+ return completeCustomLoginSession(ctx, account.id, {
757
+ ...stepUp,
758
+ amr: [flow.primaryMethod, 'mfa', ...flow.completed.map((entry) => entry.method)],
759
+ }, flow.remember === true);
760
+ }
484
761
  async mfaVerify(ctx) {
485
762
  const service = await ctx.containerResolver.make('authkit.server');
486
763
  const cfg = service.config;
487
- const render = cfg.render;
488
764
  const details = await service.interactions.details(ctx);
489
765
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
490
766
  const accountId = ctx.session.get(MFA_PENDING_KEY);
767
+ if (cfg.mfa && !getMfaFlow(ctx))
768
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
769
+ if (cfg.mfa && (details.uid !== ctx.request.param('uid') || details.prompt.name !== 'login')) {
770
+ this.takeMfaPending(ctx);
771
+ clearMfaFlow(ctx);
772
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
773
+ }
491
774
  const ip = ctx.request.ip?.() ?? null;
492
775
  const clientId = details.params.client_id ?? null;
493
776
  if (!accountId) {
@@ -501,7 +784,7 @@ export default class AuthInteractionController {
501
784
  const otpLockout = createOtpLockout(otpLockoutCfg, ctx.logger);
502
785
  // Verifica se o fator OTP está travado ANTES de tentar verificar.
503
786
  if (otpLockoutCfg.enabled && (await otpLockout.isLocked(accountId))) {
504
- return render(ctx, 'mfa-challenge', {
787
+ return this.renderMfaChallenge(ctx, cfg, {
505
788
  uid: ctx.request.param('uid'),
506
789
  csrfToken: ctx.request.csrfToken,
507
790
  error: translate(cfg.messages, 'errors.otp_locked'),
@@ -535,7 +818,7 @@ export default class AuthInteractionController {
535
818
  if (nowLocked) {
536
819
  // Emite e-mail de desbloqueio (best-effort, fail-safe).
537
820
  await this.sendOtpUnlockEmailIfAble(ctx, cfg, accountId, otpLockoutCfg.unlockTtlHours);
538
- return render(ctx, 'mfa-challenge', {
821
+ return this.renderMfaChallenge(ctx, cfg, {
539
822
  uid: ctx.request.param('uid'),
540
823
  csrfToken: ctx.request.csrfToken,
541
824
  error: translate(cfg.messages, 'errors.otp_locked'),
@@ -547,7 +830,7 @@ export default class AuthInteractionController {
547
830
  otpLocked: true,
548
831
  });
549
832
  }
550
- return render(ctx, 'mfa-challenge', {
833
+ return this.renderMfaChallenge(ctx, cfg, {
551
834
  uid: ctx.request.param('uid'),
552
835
  csrfToken: ctx.request.csrfToken,
553
836
  error: translate(cfg.messages, 'errors.invalid_code'),
@@ -560,10 +843,12 @@ export default class AuthInteractionController {
560
843
  }
561
844
  // Código correto: zera o contador de falhas OTP.
562
845
  await otpLockout.clearFailures(accountId);
846
+ if (cfg.mfa)
847
+ return this.finishVerifiedMfa(ctx, cfg, details, usedRecovery ? 'recovery' : 'totp', 'totp');
563
848
  // Sucesso no 2º fator: opcionalmente confia neste dispositivo (checkbox).
564
849
  await this.maybeTrustDevice(ctx, cfg, accountId);
565
850
  // Finaliza a interaction para o accountId pendente.
566
- const { primary } = this.takeMfaPending(ctx);
851
+ const { primary, customRemember } = this.takeMfaPending(ctx);
567
852
  forgetLoginEmail(ctx);
568
853
  await notifyLoginSuccess(ctx, cfg, {
569
854
  accountId,
@@ -571,9 +856,13 @@ export default class AuthInteractionController {
571
856
  clientId,
572
857
  metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
573
858
  });
574
- // Um 2º fator foi de fato verificado: amr = [primário, 'mfa', método]. Com
575
- // step-up (mfaAcr solicitado nesta requisição), carimba também o acr.
576
- await service.interactions.completeLogin(ctx, accountId, this.mfaCompletion(cfg, details, primary, usedRecovery ? 'recovery' : 'totp'));
859
+ const extra = this.mfaCompletion(cfg, details, primary, usedRecovery ? 'recovery' : 'totp');
860
+ if (customRemember !== undefined) {
861
+ await completeCustomLoginSession(ctx, accountId, extra, customRemember);
862
+ }
863
+ else {
864
+ await service.interactions.completeLogin(ctx, accountId, extra);
865
+ }
577
866
  }
578
867
  /**
579
868
  * true se o authorize request exige MFA via acr_values (contém o mfaAcr da
@@ -619,9 +908,25 @@ export default class AuthInteractionController {
619
908
  */
620
909
  takeMfaPending(ctx) {
621
910
  const raw = ctx.session.get(MFA_PRIMARY_KEY);
911
+ const custom = ctx.session.get(MFA_CUSTOM_PRIMARY_KEY);
912
+ const accountId = ctx.session.get(MFA_PENDING_KEY);
622
913
  ctx.session.forget(MFA_PENDING_KEY);
623
914
  ctx.session.forget(MFA_PRIMARY_KEY);
624
- return { primary: raw === 'pwd' || raw === 'email' ? raw : undefined };
915
+ ctx.session.forget(MFA_CUSTOM_PRIMARY_KEY);
916
+ if (raw === 'pwd' || raw === 'email')
917
+ return { primary: raw };
918
+ const boundCustom = custom !== null &&
919
+ typeof custom === 'object' &&
920
+ 'method' in custom &&
921
+ custom.method === raw &&
922
+ 'accountId' in custom &&
923
+ custom.accountId === accountId &&
924
+ 'uid' in custom &&
925
+ custom.uid === ctx.request.param('uid');
926
+ return {
927
+ ...(boundCustom ? { customRemember: 'remember' in custom && custom.remember === true } : {}),
928
+ primary: boundCustom && typeof raw === 'string' && isCustomLoginMethodId(raw) ? raw : undefined,
929
+ };
625
930
  }
626
931
  /**
627
932
  * Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
@@ -645,7 +950,11 @@ export default class AuthInteractionController {
645
950
  * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
646
951
  * primário (`primary`) em `MFA_PRIMARY_KEY`.
647
952
  */
648
- async secondFactorGate(ctx, cfg, accountId, details, primary) {
953
+ async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd') {
954
+ ctx.session.forget(MFA_CUSTOM_PRIMARY_KEY);
955
+ clearMfaFlow(ctx);
956
+ if (cfg.mfa)
957
+ return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless);
649
958
  const mfaRequired = this.acrRequiresMfa(cfg, details);
650
959
  const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
651
960
  // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
@@ -658,13 +967,12 @@ export default class AuthInteractionController {
658
967
  const hasFactor = passkeyAvailable || totpAvailable;
659
968
  if (!hasFactor && !mfaRequired)
660
969
  return { kind: 'none' };
661
- const render = cfg.render;
662
970
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
663
971
  const uid = ctx.request.param('uid');
664
972
  if (!hasFactor) {
665
973
  return {
666
974
  kind: 'challenge',
667
- response: await render(ctx, 'mfa-challenge', {
975
+ response: await this.renderMfaChallenge(ctx, cfg, {
668
976
  uid,
669
977
  csrfToken: ctx.request.csrfToken,
670
978
  brand,
@@ -685,7 +993,7 @@ export default class AuthInteractionController {
685
993
  ctx.session.put(MFA_PRIMARY_KEY, primary);
686
994
  return {
687
995
  kind: 'challenge',
688
- response: await render(ctx, 'mfa-challenge', {
996
+ response: await this.renderMfaChallenge(ctx, cfg, {
689
997
  uid,
690
998
  csrfToken: ctx.request.csrfToken,
691
999
  brand,
@@ -1234,8 +1542,15 @@ export default class AuthInteractionController {
1234
1542
  async passkeyVerify(ctx) {
1235
1543
  const service = await ctx.containerResolver.make('authkit.server');
1236
1544
  const cfg = service.config;
1237
- const render = cfg.render;
1238
1545
  const details = await service.interactions.details(ctx);
1546
+ if (cfg.mfa && (details.uid !== ctx.request.param('uid') || details.prompt.name !== 'login')) {
1547
+ this.takeMfaPending(ctx);
1548
+ clearMfaFlow(ctx);
1549
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
1550
+ }
1551
+ if (cfg.mfa && ctx.session.get(MFA_PENDING_KEY) && !getMfaFlow(ctx)) {
1552
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
1553
+ }
1239
1554
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
1240
1555
  let accountId = await this.resolvePasskeyAccountId(ctx, cfg);
1241
1556
  const challenge = ctx.session.get(PASSKEY_AUTH_CHALLENGE_KEY);
@@ -1300,7 +1615,7 @@ export default class AuthInteractionController {
1300
1615
  clientId,
1301
1616
  metadata: { stage: 'mfa', method: 'webauthn', reason: 'method_disabled_by_user' },
1302
1617
  });
1303
- return render(ctx, 'mfa-challenge', {
1618
+ return this.renderMfaChallenge(ctx, cfg, {
1304
1619
  uid: ctx.request.param('uid'),
1305
1620
  csrfToken: ctx.request.csrfToken,
1306
1621
  error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
@@ -1319,7 +1634,7 @@ export default class AuthInteractionController {
1319
1634
  clientId,
1320
1635
  metadata: { stage: 'mfa', method: 'webauthn' },
1321
1636
  });
1322
- return render(ctx, 'mfa-challenge', {
1637
+ return this.renderMfaChallenge(ctx, cfg, {
1323
1638
  uid: ctx.request.param('uid'),
1324
1639
  csrfToken: ctx.request.csrfToken,
1325
1640
  error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
@@ -1342,7 +1657,7 @@ export default class AuthInteractionController {
1342
1657
  clientId,
1343
1658
  metadata: { stage: 'mfa', method: 'webauthn', reason: 'unverified' },
1344
1659
  });
1345
- return render(ctx, 'mfa-challenge', {
1660
+ return this.renderMfaChallenge(ctx, cfg, {
1346
1661
  uid: ctx.request.param('uid'),
1347
1662
  csrfToken: ctx.request.csrfToken,
1348
1663
  error: translate(cfg.messages, 'errors.email_unverified'),
@@ -1373,7 +1688,7 @@ export default class AuthInteractionController {
1373
1688
  passwordless: true,
1374
1689
  });
1375
1690
  if (!passkeyStatusGate.allowed) {
1376
- return render(ctx, 'mfa-challenge', {
1691
+ return this.renderMfaChallenge(ctx, cfg, {
1377
1692
  uid: ctx.request.param('uid'),
1378
1693
  csrfToken: ctx.request.csrfToken,
1379
1694
  error: translate(cfg.messages, accountStatusErrorKey(passkeyStatusGate.reason)),
@@ -1384,12 +1699,22 @@ export default class AuthInteractionController {
1384
1699
  trustedDeviceDays: cfg.trustedDevices.days,
1385
1700
  });
1386
1701
  }
1702
+ if (cfg.mfa && ctx.session.get(MFA_PENDING_KEY)) {
1703
+ if (!getMfaFlow(ctx))
1704
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
1705
+ return this.finishVerifiedMfa(ctx, cfg, details, 'webauthn', 'webauthn');
1706
+ }
1707
+ if (cfg.mfa) {
1708
+ const gate = await this.secondFactorGate(ctx, cfg, accountId, details, 'webauthn');
1709
+ if (gate.kind === 'challenge')
1710
+ return gate.response;
1711
+ }
1387
1712
  // Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
1388
1713
  await this.maybeTrustDevice(ctx, cfg, accountId);
1389
1714
  // Passkey como 2º fator (havia desafio pendente) ou passkey-first (não havia)?
1390
1715
  // Lido ANTES de esquecer: é o que decide o amr abaixo.
1391
1716
  const wasSecondFactor = ctx.session.get(MFA_PENDING_KEY) === accountId;
1392
- const { primary } = this.takeMfaPending(ctx);
1717
+ const { primary, customRemember } = this.takeMfaPending(ctx);
1393
1718
  forgetLoginEmail(ctx);
1394
1719
  await notifyLoginSuccess(ctx, cfg, {
1395
1720
  accountId,
@@ -1397,12 +1722,15 @@ export default class AuthInteractionController {
1397
1722
  clientId,
1398
1723
  metadata: { mfa: 'webauthn' },
1399
1724
  });
1400
- // 2º fator: amr = [primário, 'mfa', 'webauthn'] (+ acr no step-up), igual ao
1401
- // TOTP. Passkey-first: a passkey É o login (amr `['webauthn']`); o step-up
1402
- // carimba acr/amr quando solicitado.
1403
- await service.interactions.completeLogin(ctx, accountId, wasSecondFactor
1725
+ const extra = wasSecondFactor
1404
1726
  ? this.mfaCompletion(cfg, details, primary, 'webauthn')
1405
- : (this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] }));
1727
+ : (this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] });
1728
+ if (customRemember !== undefined) {
1729
+ await completeCustomLoginSession(ctx, accountId, extra, customRemember);
1730
+ }
1731
+ else {
1732
+ await service.interactions.completeLogin(ctx, accountId, extra);
1733
+ }
1406
1734
  }
1407
1735
  /**
1408
1736
  * GET /auth/interaction/:uid/switch
@@ -0,0 +1,36 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /** A verified identity, obtained from trusted host logic rather than request.accountId. */
3
+ export interface CustomLoginIdentity {
4
+ accountId: string;
5
+ /** Host-validated remember-me choice, subject to the runtime session policy. */
6
+ remember?: boolean;
7
+ }
8
+ /** Host-defined primary authentication. AuthKit owns policies, MFA and session completion. */
9
+ export interface CustomLoginMethod {
10
+ /** Credential group, used to prevent repeating it as an additional MFA factor. */
11
+ readonly factorId?: string;
12
+ /** Set true only for proofs that do not authenticate with the account password. */
13
+ readonly passwordless?: boolean;
14
+ /** Optional challenge initiation; response shape, validation and delivery are host-owned. */
15
+ begin?(ctx: HttpContext): Promise<unknown>;
16
+ /** Validate/consume the primary proof, bound to this interaction. Null denies login. */
17
+ authenticate(ctx: HttpContext): Promise<CustomLoginIdentity | null>;
18
+ }
19
+ /** Constructors are resolved through the request container, including @inject dependencies. */
20
+ export type CustomLoginMethodConstructor = new (...args: never[]) => CustomLoginMethod;
21
+ export type CustomLoginMethodBinding = CustomLoginMethodConstructor | CustomLoginMethod;
22
+ export type CustomLoginMethods = Readonly<Record<string, CustomLoginMethodBinding>>;
23
+ export declare function isCustomLoginMethodId(method: string): boolean;
24
+ /** Start a registered method's optional challenge within a valid OIDC login interaction. */
25
+ export declare function beginCustomLogin(ctx: HttpContext, method: string): Promise<unknown>;
26
+ /** Verify using a registered class/instance, then pass through the shared login gates. */
27
+ export declare function authenticateCustomLogin(ctx: HttpContext, method: string): Promise<unknown>;
28
+ /**
29
+ * Trusted host escape hatch for arbitrary flows. Call ONLY after validating a primary proof.
30
+ * Never forward an account ID supplied by the browser. Prefer a registered CustomLoginMethod.
31
+ */
32
+ export declare function completeCustomLogin(ctx: HttpContext, input: CustomLoginIdentity & {
33
+ method: string;
34
+ factorId?: string;
35
+ passwordless?: boolean;
36
+ }): Promise<unknown>;
@@ -0,0 +1,76 @@
1
+ import { Exception } from '@adonisjs/core/exceptions';
2
+ import AuthInteractionController from './controllers/interaction_controller.js';
3
+ export function isCustomLoginMethodId(method) {
4
+ return (/^[a-z][a-z0-9:_-]{0,63}$/.test(method) &&
5
+ !['pwd', 'email', 'mfa', 'totp', 'webauthn', 'recovery'].includes(method));
6
+ }
7
+ function failure(message, code, status = 422) {
8
+ return new Exception(message, { code, status });
9
+ }
10
+ async function resolveMethod(ctx, method) {
11
+ if (!isCustomLoginMethodId(method))
12
+ throw failure('Invalid custom login method', 'E_CUSTOM_LOGIN_METHOD');
13
+ const service = await ctx.containerResolver.make('authkit.server');
14
+ const details = await service.interactions.details(ctx);
15
+ if (details.uid !== ctx.request.param('uid') || details.prompt.name !== 'login') {
16
+ throw failure('Invalid login interaction', 'E_CUSTOM_LOGIN_INTERACTION');
17
+ }
18
+ const registry = service.config.customLoginMethods;
19
+ if (!registry || !Object.hasOwn(registry, method)) {
20
+ throw failure('Unknown custom login method', 'E_CUSTOM_LOGIN_METHOD');
21
+ }
22
+ const binding = registry[method];
23
+ return typeof binding === 'function' ? ctx.containerResolver.make(binding) : binding;
24
+ }
25
+ /** Start a registered method's optional challenge within a valid OIDC login interaction. */
26
+ export async function beginCustomLogin(ctx, method) {
27
+ const handler = await resolveMethod(ctx, method);
28
+ if (!handler.begin)
29
+ throw failure('This method has no challenge initiation', 'E_CUSTOM_LOGIN_BEGIN');
30
+ return handler.begin(ctx);
31
+ }
32
+ /** Verify using a registered class/instance, then pass through the shared login gates. */
33
+ export async function authenticateCustomLogin(ctx, method) {
34
+ const handler = await resolveMethod(ctx, method);
35
+ let identity;
36
+ try {
37
+ identity = await handler.authenticate(ctx);
38
+ }
39
+ catch (error) {
40
+ await recordFailure(ctx, method, 'method_error');
41
+ throw error;
42
+ }
43
+ if (!identity || typeof identity.accountId !== 'string' || !identity.accountId.trim()) {
44
+ await recordFailure(ctx, method, 'invalid_proof');
45
+ throw failure('Authentication failed', 'E_CUSTOM_LOGIN_FAILED');
46
+ }
47
+ return completeCustomLogin(ctx, {
48
+ ...identity,
49
+ method,
50
+ factorId: handler.factorId,
51
+ passwordless: handler.passwordless === true,
52
+ });
53
+ }
54
+ /**
55
+ * Trusted host escape hatch for arbitrary flows. Call ONLY after validating a primary proof.
56
+ * Never forward an account ID supplied by the browser. Prefer a registered CustomLoginMethod.
57
+ */
58
+ export function completeCustomLogin(ctx, input) {
59
+ return new AuthInteractionController().completeCustomLogin(ctx, input);
60
+ }
61
+ async function recordFailure(ctx, method, reason) {
62
+ const service = await ctx.containerResolver.make('authkit.server');
63
+ const details = await service.interactions.details(ctx);
64
+ try {
65
+ await service.config.audit?.record({
66
+ type: 'login.failure',
67
+ accountId: null,
68
+ ip: ctx.request.ip(),
69
+ clientId: details.params.client_id ?? null,
70
+ metadata: { method, reason },
71
+ });
72
+ }
73
+ catch {
74
+ ctx.logger.warn('AuthKit custom login failure audit could not be recorded');
75
+ }
76
+ }
@@ -0,0 +1,4 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { CompleteLoginExtra } from '../provider/interaction_actions.js';
3
+ /** Shared by direct custom completion and its TOTP/passkey continuation. */
4
+ export declare function completeCustomLoginSession(ctx: HttpContext, accountId: string, extra: CompleteLoginExtra, rememberRequested?: boolean): Promise<unknown>;