@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.
- package/README.md +145 -0
- package/build/host/views/mfa-challenge.edge +42 -3
- package/build/index.d.ts +9 -1
- package/build/index.js +5 -1
- package/build/src/accounts/account_store.d.ts +2 -0
- package/build/src/accounts/lucid_account_store.js +2 -0
- package/build/src/adapters/adapter_contract.d.ts +2 -0
- package/build/src/adapters/database_adapter.d.ts +1 -0
- package/build/src/adapters/database_adapter.js +26 -0
- package/build/src/adapters/redis_adapter.d.ts +1 -0
- package/build/src/adapters/redis_adapter.js +30 -0
- package/build/src/define_config.d.ts +24 -0
- package/build/src/define_config.js +8 -0
- package/build/src/host/controllers/account_security_controller.js +8 -4
- package/build/src/host/controllers/interaction_controller.d.ts +13 -1
- package/build/src/host/controllers/interaction_controller.js +352 -24
- package/build/src/host/custom_login.d.ts +36 -0
- package/build/src/host/custom_login.js +76 -0
- package/build/src/host/custom_login_completion.d.ts +4 -0
- package/build/src/host/custom_login_completion.js +33 -0
- package/build/src/host/custom_mfa.d.ts +77 -0
- package/build/src/host/custom_mfa.js +268 -0
- package/build/src/host/i18n.d.ts +4 -0
- package/build/src/host/i18n.js +10 -6
- package/build/src/host/idp_session_bridge.d.ts +5 -3
- package/build/src/host/idp_session_bridge.js +5 -3
- package/build/src/host/oidc_rp_guard.d.ts +5 -1
- package/build/src/host/oidc_rp_guard.js +15 -2
- package/build/src/host/persistent_rp_session.d.ts +8 -0
- package/build/src/host/persistent_rp_session.js +74 -0
- package/build/src/host/register_auth_host.js +12 -0
- package/build/src/host/whatsapp_code_sender.d.ts +20 -0
- package/build/src/host/whatsapp_code_sender.js +9 -0
- package/build/src/host/whatsapp_senders/http.d.ts +5 -0
- package/build/src/host/whatsapp_senders/http.js +33 -0
- package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
- package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
- package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
- package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
- package/build/src/provider/oidc_service.js +6 -2
- package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
- package/package.json +2 -1
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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>;
|