@adonis-agora/authkit-server 0.75.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 (101) hide show
  1. package/README.md +148 -0
  2. package/build/host/views/account/apps.edge +30 -0
  3. package/build/host/views/agents/consent.edge +60 -0
  4. package/build/host/views/agents/done.edge +24 -0
  5. package/build/host/views/consent.edge +9 -1
  6. package/build/host/views/mfa-challenge.edge +42 -3
  7. package/build/host/views/partials/styles.edge +1 -1
  8. package/build/index.d.ts +18 -1
  9. package/build/index.js +9 -1
  10. package/build/providers/authkit_server_provider.js +75 -50
  11. package/build/src/accounts/account_store.d.ts +2 -0
  12. package/build/src/accounts/lucid_account_store.js +2 -0
  13. package/build/src/adapters/adapter_contract.d.ts +2 -0
  14. package/build/src/adapters/database_adapter.d.ts +1 -0
  15. package/build/src/adapters/database_adapter.js +26 -0
  16. package/build/src/adapters/redis_adapter.d.ts +1 -0
  17. package/build/src/adapters/redis_adapter.js +30 -0
  18. package/build/src/agents/agent_identity.d.ts +33 -0
  19. package/build/src/agents/agent_identity.js +156 -0
  20. package/build/src/agents/config.d.ts +99 -0
  21. package/build/src/agents/config.js +101 -0
  22. package/build/src/agents/delegation_service.d.ts +154 -0
  23. package/build/src/agents/delegation_service.js +394 -0
  24. package/build/src/agents/delegation_store.d.ts +93 -0
  25. package/build/src/agents/delegation_store.js +222 -0
  26. package/build/src/agents/middleware.d.ts +73 -0
  27. package/build/src/agents/middleware.js +113 -0
  28. package/build/src/agents/protocol.d.ts +62 -0
  29. package/build/src/agents/protocol.js +51 -0
  30. package/build/src/agents/runtime.d.ts +36 -0
  31. package/build/src/agents/runtime.js +65 -0
  32. package/build/src/agents/signer.d.ts +34 -0
  33. package/build/src/agents/signer.js +70 -0
  34. package/build/src/audit/audit_sink.d.ts +1 -1
  35. package/build/src/audit/audit_sink.js +5 -0
  36. package/build/src/controllers/authorization_server_metadata_controller.d.ts +10 -0
  37. package/build/src/controllers/authorization_server_metadata_controller.js +20 -0
  38. package/build/src/define_config.d.ts +50 -3
  39. package/build/src/define_config.js +42 -1
  40. package/build/src/host/account_api/account_api_controller.d.ts +12 -0
  41. package/build/src/host/account_api/account_api_controller.js +40 -0
  42. package/build/src/host/admin_api/dto.d.ts +1 -1
  43. package/build/src/host/admin_sessions_service.d.ts +2 -0
  44. package/build/src/host/admin_sessions_service.js +18 -0
  45. package/build/src/host/auth_host_config.d.ts +4 -0
  46. package/build/src/host/client_names.d.ts +11 -0
  47. package/build/src/host/client_names.js +12 -0
  48. package/build/src/host/controllers/account_apps_controller.d.ts +2 -0
  49. package/build/src/host/controllers/account_apps_controller.js +39 -3
  50. package/build/src/host/controllers/account_orgs_controller.js +2 -1
  51. package/build/src/host/controllers/account_security_controller.js +8 -4
  52. package/build/src/host/controllers/account_session_controller.js +2 -1
  53. package/build/src/host/controllers/agent_consent_controller.d.ts +17 -0
  54. package/build/src/host/controllers/agent_consent_controller.js +138 -0
  55. package/build/src/host/controllers/agent_oauth_controller.d.ts +23 -0
  56. package/build/src/host/controllers/agent_oauth_controller.js +110 -0
  57. package/build/src/host/controllers/interaction_controller.d.ts +13 -1
  58. package/build/src/host/controllers/interaction_controller.js +380 -24
  59. package/build/src/host/csrf.d.ts +8 -18
  60. package/build/src/host/csrf.js +28 -2
  61. package/build/src/host/custom_login.d.ts +36 -0
  62. package/build/src/host/custom_login.js +76 -0
  63. package/build/src/host/custom_login_completion.d.ts +4 -0
  64. package/build/src/host/custom_login_completion.js +33 -0
  65. package/build/src/host/custom_mfa.d.ts +77 -0
  66. package/build/src/host/custom_mfa.js +268 -0
  67. package/build/src/host/i18n.d.ts +58 -0
  68. package/build/src/host/i18n.js +64 -6
  69. package/build/src/host/idp_session_bridge.d.ts +5 -3
  70. package/build/src/host/idp_session_bridge.js +5 -3
  71. package/build/src/host/oidc_rp_guard.d.ts +5 -1
  72. package/build/src/host/oidc_rp_guard.js +15 -2
  73. package/build/src/host/persistent_rp_session.d.ts +8 -0
  74. package/build/src/host/persistent_rp_session.js +74 -0
  75. package/build/src/host/redirect_exact.d.ts +12 -0
  76. package/build/src/host/redirect_exact.js +17 -0
  77. package/build/src/host/register_auth_host.js +60 -7
  78. package/build/src/host/request_url.d.ts +10 -0
  79. package/build/src/host/request_url.js +17 -0
  80. package/build/src/host/sudo/methods/magic_link.js +2 -1
  81. package/build/src/host/sudo/runtime.js +3 -2
  82. package/build/src/host/sudo_mode.js +4 -4
  83. package/build/src/host/whatsapp_code_sender.d.ts +20 -0
  84. package/build/src/host/whatsapp_code_sender.js +9 -0
  85. package/build/src/host/whatsapp_senders/http.d.ts +5 -0
  86. package/build/src/host/whatsapp_senders/http.js +33 -0
  87. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
  88. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
  89. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
  90. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
  91. package/build/src/mcp/mcp_oauth.d.ts +85 -0
  92. package/build/src/mcp/mcp_oauth.js +154 -0
  93. package/build/src/provider/build_provider.js +15 -1
  94. package/build/src/provider/oidc_service.d.ts +8 -0
  95. package/build/src/provider/oidc_service.js +16 -2
  96. package/build/src/schema/ensure.js +92 -0
  97. package/build/stubs/ui/react/pages/consent.tsx +17 -1
  98. package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
  99. package/package.json +2 -1
  100. package/stubs/ui/react/pages/consent.tsx +17 -1
  101. 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
@@ -180,6 +251,7 @@ export default class AuthInteractionController {
180
251
  params: details.params,
181
252
  csrfToken: ctx.request.csrfToken,
182
253
  brand,
254
+ ...(await consentSubject(service, details, cfg.messages)),
183
255
  });
184
256
  }
185
257
  const email = ctx.session.get(SESSION_KEY);
@@ -480,13 +552,225 @@ export default class AuthInteractionController {
480
552
  * 2º fator: lê o accountId pendente da sessão e aceita um código TOTP (`code`)
481
553
  * OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
482
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
+ }
483
761
  async mfaVerify(ctx) {
484
762
  const service = await ctx.containerResolver.make('authkit.server');
485
763
  const cfg = service.config;
486
- const render = cfg.render;
487
764
  const details = await service.interactions.details(ctx);
488
765
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
489
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
+ }
490
774
  const ip = ctx.request.ip?.() ?? null;
491
775
  const clientId = details.params.client_id ?? null;
492
776
  if (!accountId) {
@@ -500,7 +784,7 @@ export default class AuthInteractionController {
500
784
  const otpLockout = createOtpLockout(otpLockoutCfg, ctx.logger);
501
785
  // Verifica se o fator OTP está travado ANTES de tentar verificar.
502
786
  if (otpLockoutCfg.enabled && (await otpLockout.isLocked(accountId))) {
503
- return render(ctx, 'mfa-challenge', {
787
+ return this.renderMfaChallenge(ctx, cfg, {
504
788
  uid: ctx.request.param('uid'),
505
789
  csrfToken: ctx.request.csrfToken,
506
790
  error: translate(cfg.messages, 'errors.otp_locked'),
@@ -534,7 +818,7 @@ export default class AuthInteractionController {
534
818
  if (nowLocked) {
535
819
  // Emite e-mail de desbloqueio (best-effort, fail-safe).
536
820
  await this.sendOtpUnlockEmailIfAble(ctx, cfg, accountId, otpLockoutCfg.unlockTtlHours);
537
- return render(ctx, 'mfa-challenge', {
821
+ return this.renderMfaChallenge(ctx, cfg, {
538
822
  uid: ctx.request.param('uid'),
539
823
  csrfToken: ctx.request.csrfToken,
540
824
  error: translate(cfg.messages, 'errors.otp_locked'),
@@ -546,7 +830,7 @@ export default class AuthInteractionController {
546
830
  otpLocked: true,
547
831
  });
548
832
  }
549
- return render(ctx, 'mfa-challenge', {
833
+ return this.renderMfaChallenge(ctx, cfg, {
550
834
  uid: ctx.request.param('uid'),
551
835
  csrfToken: ctx.request.csrfToken,
552
836
  error: translate(cfg.messages, 'errors.invalid_code'),
@@ -559,10 +843,12 @@ export default class AuthInteractionController {
559
843
  }
560
844
  // Código correto: zera o contador de falhas OTP.
561
845
  await otpLockout.clearFailures(accountId);
846
+ if (cfg.mfa)
847
+ return this.finishVerifiedMfa(ctx, cfg, details, usedRecovery ? 'recovery' : 'totp', 'totp');
562
848
  // Sucesso no 2º fator: opcionalmente confia neste dispositivo (checkbox).
563
849
  await this.maybeTrustDevice(ctx, cfg, accountId);
564
850
  // Finaliza a interaction para o accountId pendente.
565
- const { primary } = this.takeMfaPending(ctx);
851
+ const { primary, customRemember } = this.takeMfaPending(ctx);
566
852
  forgetLoginEmail(ctx);
567
853
  await notifyLoginSuccess(ctx, cfg, {
568
854
  accountId,
@@ -570,9 +856,13 @@ export default class AuthInteractionController {
570
856
  clientId,
571
857
  metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
572
858
  });
573
- // Um 2º fator foi de fato verificado: amr = [primário, 'mfa', método]. Com
574
- // step-up (mfaAcr solicitado nesta requisição), carimba também o acr.
575
- 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
+ }
576
866
  }
577
867
  /**
578
868
  * true se o authorize request exige MFA via acr_values (contém o mfaAcr da
@@ -618,9 +908,25 @@ export default class AuthInteractionController {
618
908
  */
619
909
  takeMfaPending(ctx) {
620
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);
621
913
  ctx.session.forget(MFA_PENDING_KEY);
622
914
  ctx.session.forget(MFA_PRIMARY_KEY);
623
- 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
+ };
624
930
  }
625
931
  /**
626
932
  * Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
@@ -644,7 +950,11 @@ export default class AuthInteractionController {
644
950
  * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
645
951
  * primário (`primary`) em `MFA_PRIMARY_KEY`.
646
952
  */
647
- 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);
648
958
  const mfaRequired = this.acrRequiresMfa(cfg, details);
649
959
  const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
650
960
  // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
@@ -657,13 +967,12 @@ export default class AuthInteractionController {
657
967
  const hasFactor = passkeyAvailable || totpAvailable;
658
968
  if (!hasFactor && !mfaRequired)
659
969
  return { kind: 'none' };
660
- const render = cfg.render;
661
970
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
662
971
  const uid = ctx.request.param('uid');
663
972
  if (!hasFactor) {
664
973
  return {
665
974
  kind: 'challenge',
666
- response: await render(ctx, 'mfa-challenge', {
975
+ response: await this.renderMfaChallenge(ctx, cfg, {
667
976
  uid,
668
977
  csrfToken: ctx.request.csrfToken,
669
978
  brand,
@@ -684,7 +993,7 @@ export default class AuthInteractionController {
684
993
  ctx.session.put(MFA_PRIMARY_KEY, primary);
685
994
  return {
686
995
  kind: 'challenge',
687
- response: await render(ctx, 'mfa-challenge', {
996
+ response: await this.renderMfaChallenge(ctx, cfg, {
688
997
  uid,
689
998
  csrfToken: ctx.request.csrfToken,
690
999
  brand,
@@ -1233,8 +1542,15 @@ export default class AuthInteractionController {
1233
1542
  async passkeyVerify(ctx) {
1234
1543
  const service = await ctx.containerResolver.make('authkit.server');
1235
1544
  const cfg = service.config;
1236
- const render = cfg.render;
1237
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
+ }
1238
1554
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
1239
1555
  let accountId = await this.resolvePasskeyAccountId(ctx, cfg);
1240
1556
  const challenge = ctx.session.get(PASSKEY_AUTH_CHALLENGE_KEY);
@@ -1299,7 +1615,7 @@ export default class AuthInteractionController {
1299
1615
  clientId,
1300
1616
  metadata: { stage: 'mfa', method: 'webauthn', reason: 'method_disabled_by_user' },
1301
1617
  });
1302
- return render(ctx, 'mfa-challenge', {
1618
+ return this.renderMfaChallenge(ctx, cfg, {
1303
1619
  uid: ctx.request.param('uid'),
1304
1620
  csrfToken: ctx.request.csrfToken,
1305
1621
  error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
@@ -1318,7 +1634,7 @@ export default class AuthInteractionController {
1318
1634
  clientId,
1319
1635
  metadata: { stage: 'mfa', method: 'webauthn' },
1320
1636
  });
1321
- return render(ctx, 'mfa-challenge', {
1637
+ return this.renderMfaChallenge(ctx, cfg, {
1322
1638
  uid: ctx.request.param('uid'),
1323
1639
  csrfToken: ctx.request.csrfToken,
1324
1640
  error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
@@ -1341,7 +1657,7 @@ export default class AuthInteractionController {
1341
1657
  clientId,
1342
1658
  metadata: { stage: 'mfa', method: 'webauthn', reason: 'unverified' },
1343
1659
  });
1344
- return render(ctx, 'mfa-challenge', {
1660
+ return this.renderMfaChallenge(ctx, cfg, {
1345
1661
  uid: ctx.request.param('uid'),
1346
1662
  csrfToken: ctx.request.csrfToken,
1347
1663
  error: translate(cfg.messages, 'errors.email_unverified'),
@@ -1372,7 +1688,7 @@ export default class AuthInteractionController {
1372
1688
  passwordless: true,
1373
1689
  });
1374
1690
  if (!passkeyStatusGate.allowed) {
1375
- return render(ctx, 'mfa-challenge', {
1691
+ return this.renderMfaChallenge(ctx, cfg, {
1376
1692
  uid: ctx.request.param('uid'),
1377
1693
  csrfToken: ctx.request.csrfToken,
1378
1694
  error: translate(cfg.messages, accountStatusErrorKey(passkeyStatusGate.reason)),
@@ -1383,12 +1699,22 @@ export default class AuthInteractionController {
1383
1699
  trustedDeviceDays: cfg.trustedDevices.days,
1384
1700
  });
1385
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
+ }
1386
1712
  // Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
1387
1713
  await this.maybeTrustDevice(ctx, cfg, accountId);
1388
1714
  // Passkey como 2º fator (havia desafio pendente) ou passkey-first (não havia)?
1389
1715
  // Lido ANTES de esquecer: é o que decide o amr abaixo.
1390
1716
  const wasSecondFactor = ctx.session.get(MFA_PENDING_KEY) === accountId;
1391
- const { primary } = this.takeMfaPending(ctx);
1717
+ const { primary, customRemember } = this.takeMfaPending(ctx);
1392
1718
  forgetLoginEmail(ctx);
1393
1719
  await notifyLoginSuccess(ctx, cfg, {
1394
1720
  accountId,
@@ -1396,12 +1722,15 @@ export default class AuthInteractionController {
1396
1722
  clientId,
1397
1723
  metadata: { mfa: 'webauthn' },
1398
1724
  });
1399
- // 2º fator: amr = [primário, 'mfa', 'webauthn'] (+ acr no step-up), igual ao
1400
- // TOTP. Passkey-first: a passkey É o login (amr `['webauthn']`); o step-up
1401
- // carimba acr/amr quando solicitado.
1402
- await service.interactions.completeLogin(ctx, accountId, wasSecondFactor
1725
+ const extra = wasSecondFactor
1403
1726
  ? this.mfaCompletion(cfg, details, primary, 'webauthn')
1404
- : (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
+ }
1405
1734
  }
1406
1735
  /**
1407
1736
  * GET /auth/interaction/:uid/switch
@@ -1564,3 +1893,30 @@ export default class AuthInteractionController {
1564
1893
  }
1565
1894
  }
1566
1895
  }
1896
+ /**
1897
+ * Quem pede o acesso e o quê, para a tela de consent: o nome que o client registrou (um cliente
1898
+ * MCP registra o seu, ex.: "Claude Code") e os escopos pedidos, com o rótulo de cada um. O nome
1899
+ * vem de fora (registro dinâmico aberto) — `clientNameHtml` sai escapado para o `{{{ }}}`.
1900
+ */
1901
+ async function consentSubject(service, details, messages) {
1902
+ const clientId = String(details.params?.client_id ?? '');
1903
+ const client = clientId ? await service.provider.Client.find(clientId).catch(() => null) : null;
1904
+ const clientName = client?.clientName || clientId;
1905
+ const requested = String(details.params?.scope ?? '')
1906
+ .split(' ')
1907
+ .filter((scope) => scope !== '' && scope !== 'openid');
1908
+ const scopes = requested.map((scope) => {
1909
+ const key = `consent.scope.${scope}`;
1910
+ const label = translate(messages, key);
1911
+ return { id: scope, label: label === key ? scope : label };
1912
+ });
1913
+ return { clientName, clientNameHtml: escapeHtml(clientName), scopes };
1914
+ }
1915
+ function escapeHtml(value) {
1916
+ return value
1917
+ .replace(/&/g, '&amp;')
1918
+ .replace(/</g, '&lt;')
1919
+ .replace(/>/g, '&gt;')
1920
+ .replace(/"/g, '&quot;')
1921
+ .replace(/'/g, '&#39;');
1922
+ }
@@ -1,29 +1,19 @@
1
- /**
2
- * Helper de exceções de CSRF para as rotas machine-to-machine do AuthKit.
3
- *
4
- * Os endpoints do IdP (token/introspection/PAT) e o back-channel logout são
5
- * server-to-server (sem browser/token XSRF). Em vez de cada app reescrever a
6
- * checagem de URL no `config/shield.ts`, use este helper:
7
- *
8
- * ```ts
9
- * // config/shield.ts
10
- * import { authkitCsrfExceptions } from '@adonis-agora/authkit-server'
11
- * csrf: {
12
- * enabled: true,
13
- * exceptRoutes: (ctx) => authkitCsrfExceptions(ctx.request.url(), { mountPath: '/oidc' })
14
- * || ctx.request.url().includes('/api'),
15
- * }
16
- * ```
17
- */
18
1
  export interface AuthkitCsrfOptions {
19
2
  /** mountPath do IdP (mesmo de defineConfig/registerAuthHost). Default: `/oidc`. */
20
3
  mountPath?: string;
21
4
  /** Inclui a rota de back-channel logout do CLIENT (default: `/auth/backchannel-logout`). */
22
5
  backchannelLogoutPath?: string | false;
6
+ /**
7
+ * Prefixo de `personalAgents`: isenta os endpoints OAuth que os agentes chamam
8
+ * (`{prefix}/oauth/*`). Default: o prefixo do config quando `personalAgents`
9
+ * está ligado; nada quando não está. `false` desliga. A tela de
10
+ * consentimento (`{prefix}/consent`) continua protegida.
11
+ */
12
+ personalAgentsPrefix?: string | false;
23
13
  }
24
14
  /**
25
15
  * Retorna `true` quando `url` é uma rota AuthKit que deve ser ISENTA de CSRF
26
16
  * (machine-to-machine). Cobre o mountPath do IdP, a introspecção de PAT e a
27
- * rota de back-channel logout do client.
17
+ * rota de back-channel logout do client e os endpoints OAuth de personal agents.
28
18
  */
29
19
  export declare function authkitCsrfExceptions(url: string, options?: AuthkitCsrfOptions): boolean;
@@ -1,14 +1,40 @@
1
+ /**
2
+ * Helper de exceções de CSRF para as rotas machine-to-machine do AuthKit.
3
+ *
4
+ * Os endpoints do IdP (token/introspection/PAT) e o back-channel logout são
5
+ * server-to-server (sem browser/token XSRF). Em vez de cada app reescrever a
6
+ * checagem de URL no `config/shield.ts`, use este helper:
7
+ *
8
+ * ```ts
9
+ * // config/shield.ts
10
+ * import { authkitCsrfExceptions } from '@adonis-agora/authkit-server'
11
+ * csrf: {
12
+ * enabled: true,
13
+ * exceptRoutes: (ctx) => authkitCsrfExceptions(ctx.request.url(), { mountPath: '/oidc' })
14
+ * || ctx.request.url().includes('/api'),
15
+ * }
16
+ * ```
17
+ */
18
+ import { normalizePersonalAgentsPrefix } from '../agents/config.js';
19
+ import { getAuthHostConfig } from './auth_host_config.js';
1
20
  /**
2
21
  * Retorna `true` quando `url` é uma rota AuthKit que deve ser ISENTA de CSRF
3
22
  * (machine-to-machine). Cobre o mountPath do IdP, a introspecção de PAT e a
4
- * rota de back-channel logout do client.
23
+ * rota de back-channel logout do client e os endpoints OAuth de personal agents.
5
24
  */
6
25
  export function authkitCsrfExceptions(url, options = {}) {
7
26
  const mountPath = options.mountPath ?? '/oidc';
8
27
  const backchannel = options.backchannelLogoutPath === false
9
28
  ? null
10
29
  : (options.backchannelLogoutPath ?? '/auth/backchannel-logout');
30
+ const agentsPrefix = options.personalAgentsPrefix === false
31
+ ? undefined
32
+ : options.personalAgentsPrefix !== undefined
33
+ ? normalizePersonalAgentsPrefix(options.personalAgentsPrefix)
34
+ : getAuthHostConfig()?.personalAgents?.prefix;
35
+ const agentsOAuth = agentsPrefix ? `${agentsPrefix}/oauth/` : null;
11
36
  return (url.includes(mountPath) ||
12
37
  url.includes('/authkit/pat') ||
13
- (backchannel !== null && url === backchannel));
38
+ (backchannel !== null && url === backchannel) ||
39
+ (agentsOAuth !== null && url.startsWith(agentsOAuth)));
14
40
  }