@adonis-agora/authkit-server 0.59.0 → 0.60.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/build/index.d.ts CHANGED
@@ -23,8 +23,8 @@ export { lucidAccountStore, appKeyEncrypter } from './src/accounts/lucid_account
23
23
  export { lucidStores } from './src/accounts/lucid_stores.js';
24
24
  export type { LucidStoresModels, LucidStoresOptions, LucidStoresResult, } from './src/accounts/lucid_stores.js';
25
25
  export type { LucidAccountStoreOptions, AccountSecretEncrypter, } from './src/accounts/lucid_account_store.js';
26
- export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, ProviderIdentitySummary, AccountSecurityCapability, AccountStatusCapability, ProfileCapability, MagicLinkCapability, OtpLoginCapability, OtpLoginVerifyResult, EmailVerificationStatusCapability, AccountDeletionCapability, AccountImportCapability, ImportAccountInput, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
27
- export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
26
+ export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, ProviderIdentitySummary, AccountSecurityCapability, AccountStatusCapability, ProfileCapability, MagicLinkCapability, OtpLoginCapability, OtpLoginVerifyResult, EmailVerificationStatusCapability, AccountDeletionCapability, AccountImportCapability, ImportAccountInput, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, LoginMethodsPreferenceCapability, } from './src/accounts/account_store.js';
27
+ export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, supportsLoginMethodsPreference, } from './src/accounts/account_store.js';
28
28
  export { type OtpLoginConfigInput, type ResolvedOtpLoginConfig, type OtpVerifyOutcome, resolveOtpLoginConfig, generateOtpCode, evaluateLoginOtp, OTP_LOGIN_DEFAULTS, } from './src/host/otp_login.js';
29
29
  export { PasswordManager, PasswordPolicyError, } from './src/password/password_manager.js';
30
30
  export type { PasswordConfigInput, LegacyPasswordVerifier, PasswordVerifyResult, } from './src/password/password_manager.js';
@@ -125,6 +125,8 @@ export { resolveEffectiveBotProtection } from './src/host/bot_protection.js';
125
125
  export type { BotProtectionSetting } from './src/host/bot_protection.js';
126
126
  export { SETTING_KEYS, resolveEffectiveRegistration, resolveEffectiveRequireVerifiedEmail, resolveEffectiveMaintenanceMode, resolveEffectiveAuthMethods, configLockedAuthMethods, } from './src/host/runtime_toggles.js';
127
127
  export type { SettingKey, RegistrationSetting, RequireVerifiedEmailSetting, MaintenanceModeSetting, ResolvedMaintenanceMode, AuthMethodsSetting, ResolvedAuthMethods, AuthMethodsCapabilities, AuthMethodsConfigOverride, } from './src/host/runtime_toggles.js';
128
+ export { USER_LOGIN_METHOD_KEYS, normalizeUserLoginMethods, parseUserLoginMethodsPayload, resolveEffectiveUserLoginMethods, } from './src/host/user_login_methods.js';
129
+ export type { UserLoginMethodKey, UserLoginMethods, ResolvedUserLoginMethods, } from './src/host/user_login_methods.js';
128
130
  export { resolveRegistration } from './src/define_config.js';
129
131
  export type { RegistrationConfigInput, ResolvedRegistrationConfig, } from './src/define_config.js';
130
132
  export { getAccountId, realAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
package/build/index.js CHANGED
@@ -14,7 +14,7 @@ export { resolveTrustedDevices, isTrustedDeviceValid, buildTrustedDevicePayload,
14
14
  export { resolveBotProtection, botProtectionApplies, extractBotToken, verifyBotProtection, guardBotProtection, DEFAULT_BOT_TOKEN_FIELDS, } from './src/host/bot_protection.js';
15
15
  export { lucidAccountStore, appKeyEncrypter } from './src/accounts/lucid_account_store.js';
16
16
  export { lucidStores } from './src/accounts/lucid_stores.js';
17
- export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
17
+ export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, supportsLoginMethodsPreference, } from './src/accounts/account_store.js';
18
18
  // Login por OTP (código digitável): config + helpers puros.
19
19
  export { resolveOtpLoginConfig, generateOtpCode, evaluateLoginOtp, OTP_LOGIN_DEFAULTS, } from './src/host/otp_login.js';
20
20
  // Gerência de senha: lazy rehash + legacy verifier, política e checagem de vazamento.
@@ -85,6 +85,8 @@ export { buildKeysStatus, rotateNow } from './src/host/key_rotation_actions.js';
85
85
  export { resolveEffectiveBotProtection } from './src/host/bot_protection.js';
86
86
  // Runtime toggles (registration, require_verified_email, maintenance_mode).
87
87
  export { SETTING_KEYS, resolveEffectiveRegistration, resolveEffectiveRequireVerifiedEmail, resolveEffectiveMaintenanceMode, resolveEffectiveAuthMethods, configLockedAuthMethods, } from './src/host/runtime_toggles.js';
88
+ // Tipos de login POR USUÁRIO (self-service no console de conta).
89
+ export { USER_LOGIN_METHOD_KEYS, normalizeUserLoginMethods, parseUserLoginMethodsPayload, resolveEffectiveUserLoginMethods, } from './src/host/user_login_methods.js';
88
90
  export { resolveRegistration } from './src/define_config.js';
89
91
  export { getAccountId, realAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
90
92
  export { ACCOUNT_SESSION_KEY } from './src/host/account_session_key.js';
@@ -1,4 +1,5 @@
1
1
  /** DTO público da conta — o que o provider e os controllers enxergam. Nunca um model Lucid. */
2
+ import type { UserLoginMethods } from '../host/user_login_methods.js';
2
3
  export interface AuthAccount {
3
4
  id: string;
4
5
  email: string;
@@ -211,6 +212,22 @@ export interface ProviderIdentitySummary {
211
212
  providerUserId: string;
212
213
  email?: string | null;
213
214
  }
215
+ /**
216
+ * Preferência POR USUÁRIO de tipos de login (self-service no console de conta).
217
+ * CAPACIDADE opcional, montada quando o model tem a coluna `login_methods`
218
+ * (JSONB) — hosts adotam por migração própria. `null` = sem preferência (herda
219
+ * os métodos globais efetivos). Ver `host/user_login_methods.ts` para o shape
220
+ * canônico (`UserLoginMethods`) e a semântica da interseção.
221
+ */
222
+ export interface LoginMethodsPreferenceCapability {
223
+ /** Lê a preferência da conta; null quando não há preferência persistida. */
224
+ getLoginMethods(accountId: string): Promise<UserLoginMethods | null>;
225
+ /**
226
+ * Grava a preferência da conta. Objeto vazio ou null REMOVE a preferência
227
+ * (volta a herdar os globais). No-op se a conta não existe.
228
+ */
229
+ setLoginMethods(accountId: string, methods: UserLoginMethods | null): Promise<void>;
230
+ }
214
231
  /**
215
232
  * MFA / TOTP. Stores sem suporte a MFA omitem a capacidade inteira; o interaction
216
233
  * flow trata a ausência como "MFA desligado".
@@ -599,7 +616,7 @@ export type AccountStore = CoreAccountStore & {
599
616
  * blocos `Partial<...>` de capacidades probáveis.
600
617
  */
601
618
  readonly connectionName?: string;
602
- } & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability>;
619
+ } & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability & LoginMethodsPreferenceCapability>;
603
620
  /** Type guard: o store implementa a capacidade de MFA / TOTP. */
604
621
  export declare function supportsMfa(store: AccountStore): store is AccountStore & MfaCapability;
605
622
  /**
@@ -634,3 +651,5 @@ export declare function supportsAccountImport(store: AccountStore): store is Acc
634
651
  export declare function supportsPasswordHistory(store: AccountStore): store is AccountStore & PasswordHistoryCapability;
635
652
  /** Type guard: o store implementa expiração de senha (password_changed_at coluna). */
636
653
  export declare function supportsPasswordExpiration(store: AccountStore): store is AccountStore & PasswordExpirationCapability;
654
+ /** Type guard: o store implementa a preferência por usuário de tipos de login. */
655
+ export declare function supportsLoginMethodsPreference(store: AccountStore): store is AccountStore & LoginMethodsPreferenceCapability;
@@ -63,3 +63,7 @@ export function supportsPasswordHistory(store) {
63
63
  export function supportsPasswordExpiration(store) {
64
64
  return typeof store.getPasswordChangedAt === 'function';
65
65
  }
66
+ /** Type guard: o store implementa a preferência por usuário de tipos de login. */
67
+ export function supportsLoginMethodsPreference(store) {
68
+ return typeof store.getLoginMethods === 'function';
69
+ }
@@ -1,6 +1,7 @@
1
1
  import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
2
2
  import { PasswordManager } from '../password/password_manager.js';
3
3
  import { buildCore } from './lucid_store/core.js';
4
+ import { buildLoginMethods, supportsLoginMethodsColumn } from './lucid_store/login_methods.js';
4
5
  import { buildMfa } from './lucid_store/mfa.js';
5
6
  import { buildOrganizations } from './lucid_store/organizations.js';
6
7
  import { buildPasswordExpiration, buildPasswordHistory } from './lucid_store/password_hygiene.js';
@@ -212,6 +213,9 @@ export function lucidAccountStore(Model, options = {}) {
212
213
  ...buildDeletion(ctx),
213
214
  // Expiração de senha: só quando o model tem a coluna `password_changed_at`.
214
215
  ...(hasColumn(Model, 'passwordChangedAt') ? buildPasswordExpiration(ctx) : {}),
216
+ // Preferência por usuário de tipos de login: só quando o model tem a coluna
217
+ // `login_methods` (JSONB). Sem a coluna → capacidade ausente (feature no-op).
218
+ ...(supportsLoginMethodsColumn(Model) ? buildLoginMethods(ctx) : {}),
215
219
  // Organizations (multi-tenancy): só quando os três models foram fornecidos.
216
220
  ...(OrgModels
217
221
  ? buildOrganizations({
@@ -0,0 +1,11 @@
1
+ import type { LoginMethodsPreferenceCapability } from '../account_store.js';
2
+ import type { LucidStoreContext } from './shared.js';
3
+ /**
4
+ * Preferência por usuário de tipos de login sobre a coluna `login_methods`
5
+ * (JSONB) do model. Só deve ser montada quando a coluna existe
6
+ * ({@link hasColumn}) — caso contrário a capacidade fica ausente e a feature
7
+ * degrada para no-op (hosts adotam por migração própria).
8
+ */
9
+ export declare function buildLoginMethods(ctx: LucidStoreContext): LoginMethodsPreferenceCapability;
10
+ /** Indica se o model suporta a preferência de tipos de login (coluna presente). */
11
+ export declare function supportsLoginMethodsColumn(Model: any): boolean;
@@ -0,0 +1,32 @@
1
+ import { normalizeUserLoginMethods } from '../../host/user_login_methods.js';
2
+ import { hasColumn } from './status_profile.js';
3
+ /**
4
+ * Preferência por usuário de tipos de login sobre a coluna `login_methods`
5
+ * (JSONB) do model. Só deve ser montada quando a coluna existe
6
+ * ({@link hasColumn}) — caso contrário a capacidade fica ausente e a feature
7
+ * degrada para no-op (hosts adotam por migração própria).
8
+ */
9
+ export function buildLoginMethods(ctx) {
10
+ const { Model } = ctx;
11
+ return {
12
+ async getLoginMethods(accountId) {
13
+ const row = await Model.find(accountId);
14
+ if (!row)
15
+ return null;
16
+ return normalizeUserLoginMethods(row.loginMethods);
17
+ },
18
+ async setLoginMethods(accountId, methods) {
19
+ const row = await Model.find(accountId);
20
+ if (!row)
21
+ return;
22
+ // Objeto vazio/null → grava null (sem preferência = herda globais).
23
+ const value = methods && Object.keys(methods).length > 0 ? normalizeUserLoginMethods(methods) : null;
24
+ row.loginMethods = value;
25
+ await row.save();
26
+ },
27
+ };
28
+ }
29
+ /** Indica se o model suporta a preferência de tipos de login (coluna presente). */
30
+ export function supportsLoginMethodsColumn(Model) {
31
+ return hasColumn(Model, 'loginMethods');
32
+ }
@@ -30,6 +30,7 @@
30
30
  import '../augmentations.js';
31
31
  import type { HttpContext } from '@adonisjs/core/http';
32
32
  export default class AccountApiController {
33
+ #private;
33
34
  /** Perfil + flags do usuário logado. */
34
35
  me(ctx: HttpContext): Promise<void | {
35
36
  id: any;
@@ -171,6 +172,43 @@ export default class AccountApiController {
171
172
  available: any;
172
173
  };
173
174
  }>;
175
+ /**
176
+ * Preferência POR USUÁRIO de tipos de login + o catálogo do que está
177
+ * disponível/travado. A interseção com os métodos globais efetivos é feita
178
+ * AQUI (a UI mostra o estado final; o enforcement no login reusa o resolver).
179
+ */
180
+ getLoginMethods(ctx: HttpContext): Promise<{
181
+ supported: false;
182
+ methods: null;
183
+ available: null;
184
+ locked: null;
185
+ } | {
186
+ supported: true;
187
+ methods: any;
188
+ available: {
189
+ password: boolean;
190
+ magicLink: boolean;
191
+ passkey: boolean;
192
+ social: string[];
193
+ forgotPassword: boolean;
194
+ };
195
+ locked: {
196
+ password: boolean;
197
+ magicLink: boolean;
198
+ passkey: boolean;
199
+ social: boolean;
200
+ };
201
+ }>;
202
+ /** Grava a preferência de tipos de login do usuário logado. */
203
+ updateLoginMethods(ctx: HttpContext): Promise<void | {
204
+ ok: boolean;
205
+ methods: {
206
+ password?: boolean;
207
+ magicLink?: boolean;
208
+ passkey?: boolean;
209
+ social?: boolean;
210
+ };
211
+ }>;
174
212
  /** Lista passkeys do usuário logado. */
175
213
  listPasskeys(ctx: HttpContext): Promise<{
176
214
  supported: boolean;
@@ -28,7 +28,7 @@
28
28
  * GET /account/api/orgs/invitations → convites pendentes
29
29
  */
30
30
  import '../augmentations.js';
31
- import { supportsAccountSecurity, supportsOrganizations, supportsPasskeys, supportsProfile, } from '../../accounts/account_store.js';
31
+ import { supportsAccountSecurity, supportsLoginMethodsPreference, supportsMagicLink, supportsOrganizations, supportsPasskeys, supportsProfile, } from '../../accounts/account_store.js';
32
32
  import { PasswordPolicyError } from '../../password/password_manager.js';
33
33
  import { accountPath } from '../account_paths.js';
34
34
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
@@ -39,11 +39,12 @@ import { AvatarUploadError, isAvatarUploadSupported, storeAvatar } from '../avat
39
39
  import { sendEmailChangeConfirmationEmail, sendEmailChangeNoticeEmail } from '../default_mailer.js';
40
40
  import { translate } from '../i18n.js';
41
41
  import { authkitOrigin } from '../origin.js';
42
- import { resolveRuntimeSettings } from '../runtime_settings.js';
43
- import { resolveEffectiveEmailChange, resolveEffectivePasswordHistory, } from '../runtime_toggles.js';
42
+ import { resolveRuntimeSettings, resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
43
+ import { configLockedAuthMethods, resolveEffectiveAuthMethods, resolveEffectiveEmailChange, resolveEffectivePasswordHistory, } from '../runtime_toggles.js';
44
44
  import { dispatchSecurityNotice } from '../security_notice_service.js';
45
45
  import { enrichSessionsWithContext } from '../session_context.js';
46
46
  import { SUDO_MODE_DEFAULTS, isSudoActive, requireSudo, resolveEffectiveSudoMode, } from '../sudo_mode.js';
47
+ import { parseUserLoginMethodsPayload, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
47
48
  import { changeEmailValidator, changePasswordValidator, updateProfileValidator, } from '../validators.js';
48
49
  // ---------------------------------------------------------------------------
49
50
  // Helpers (shared with existing controllers — kept local to avoid coupling)
@@ -641,6 +642,86 @@ export default class AccountApiController {
641
642
  recovery: { available: enabled },
642
643
  };
643
644
  }
645
+ // ─── GET /account/api/login-methods ─────────────────────────────────────
646
+ /**
647
+ * Preferência POR USUÁRIO de tipos de login + o catálogo do que está
648
+ * disponível/travado. A interseção com os métodos globais efetivos é feita
649
+ * AQUI (a UI mostra o estado final; o enforcement no login reusa o resolver).
650
+ */
651
+ async getLoginMethods(ctx) {
652
+ const service = await ctx.containerResolver.make('authkit.server');
653
+ const cfg = service.config;
654
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
655
+ const supported = supportsLoginMethodsPreference(cfg.accountStore);
656
+ if (!supported) {
657
+ return { supported: false, methods: null, available: null, locked: null };
658
+ }
659
+ const pref = await cfg.accountStore.getLoginMethods(userId);
660
+ const { resolved: global, locked: cfgLocked } = await this.#resolveGlobalAuthMethods(ctx, cfg);
661
+ const effective = resolveEffectiveUserLoginMethods(global, pref);
662
+ return {
663
+ supported: true,
664
+ // Preferência crua do usuário (o que ELE escolheu; null = sem preferência).
665
+ methods: pref ?? {},
666
+ // Estado final por método (global ∩ preferência) — o que a tela de login mostra.
667
+ available: {
668
+ password: effective.password,
669
+ magicLink: effective.magicLink,
670
+ passkey: effective.passkey,
671
+ social: effective.social,
672
+ forgotPassword: effective.forgotPassword,
673
+ },
674
+ // Métodos que o usuário NÃO pode controlar (globalmente off ou pin de config).
675
+ // Pin de config: `cfgLocked` lista as chaves fixadas no arquivo de config.
676
+ locked: {
677
+ password: !global.password || cfgLocked.includes('password'),
678
+ magicLink: !global.magicLink || cfgLocked.includes('magicLink'),
679
+ passkey: !global.passkey || cfgLocked.includes('passkey'),
680
+ social: global.social.length === 0,
681
+ },
682
+ };
683
+ }
684
+ // ─── PUT /account/api/login-methods ─────────────────────────────────────
685
+ /** Grava a preferência de tipos de login do usuário logado. */
686
+ async updateLoginMethods(ctx) {
687
+ const service = await ctx.containerResolver.make('authkit.server');
688
+ const cfg = service.config;
689
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
690
+ if (!supportsLoginMethodsPreference(cfg.accountStore)) {
691
+ return ctx.response.notFound(apiErr('not_supported', 'Login methods preference not supported.'));
692
+ }
693
+ const parsed = parseUserLoginMethodsPayload(ctx.request.body());
694
+ if (!parsed.ok) {
695
+ return ctx.response.badRequest(apiErr(parsed.error, 'Invalid login methods payload.'));
696
+ }
697
+ // All-off guard server-side: a preferência não pode zerar todos os métodos
698
+ // GLOBAIS disponíveis. Um `false` que deixaria zero métodos é recusado.
699
+ const { resolved: global } = await this.#resolveGlobalAuthMethods(ctx, cfg);
700
+ const value = { ...parsed.value };
701
+ const wouldBeAllOff = (value.password === false || !global.password) &&
702
+ (value.magicLink === false || !global.magicLink) &&
703
+ (value.passkey === false || !global.passkey) &&
704
+ (value.social === false || global.social.length === 0);
705
+ if (wouldBeAllOff) {
706
+ return ctx.response.badRequest(apiErr('all_methods_off', 'Cannot disable every login method.'));
707
+ }
708
+ await cfg.accountStore.setLoginMethods(userId, Object.keys(value).length > 0 ? value : null);
709
+ return { ok: true, methods: value };
710
+ }
711
+ /**
712
+ * Métodos globais efetivos + pins de config, compartilhados pelos handlers
713
+ * acima. Fail-safe: erro → defaults conservadores.
714
+ */
715
+ async #resolveGlobalAuthMethods(ctx, cfg) {
716
+ const settings = await resolveRuntimeSettingsOrNoop(ctx);
717
+ const resolved = await resolveEffectiveAuthMethods(settings, {
718
+ configuredSocialProviders: cfg.social?.providers ?? [],
719
+ magicLinkCapable: cfg.passwordless?.magicLink && supportsMagicLink(cfg.accountStore),
720
+ passkeyCapable: supportsPasskeys(cfg.accountStore),
721
+ configOverrides: cfg.authMethods,
722
+ });
723
+ return { resolved, locked: configLockedAuthMethods(cfg.authMethods) };
724
+ }
644
725
  // ─── GET /account/api/passkeys ──────────────────────────────────────────
645
726
  /** Lista passkeys do usuário logado. */
646
727
  async listPasskeys(ctx) {
@@ -1,5 +1,5 @@
1
1
  import '../augmentations.js';
2
- import { supportsMagicLink, supportsOtpLogin, supportsPasskeys, } from '../../accounts/account_store.js';
2
+ import { supportsLoginMethodsPreference, supportsMagicLink, supportsOtpLogin, supportsPasskeys, } from '../../accounts/account_store.js';
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';
@@ -14,6 +14,7 @@ import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolv
14
14
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
15
15
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
16
16
  import { TRUSTED_DEVICE_COOKIE, buildTrustedDevicePayload, isTrustedDeviceValid, } from '../trusted_device.js';
17
+ import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
17
18
  /**
18
19
  * Chave i18n do erro de status de conta (disabled/expired) compartilhada pelos
19
20
  * fluxos passwordless (magic link, OTP, passkey).
@@ -73,6 +74,44 @@ export default class AuthInteractionController {
73
74
  otpEnabled,
74
75
  };
75
76
  }
77
+ /**
78
+ * Métodos de login com a preferência POR USUÁRIO aplicada (interseção global ×
79
+ * `login_methods` da conta). Usado em TODO render/POST que já conhece o email
80
+ * (passo 2+ do login) — o passo 1 (identifier) não tem usuário, então usa só o
81
+ * global via {@link #loginMethods}.
82
+ *
83
+ * Fail-safe: store sem a capacidade, conta inexistente ou qualquer erro →
84
+ * métodos globais sem alteração (a preferência nunca derruba o login).
85
+ */
86
+ async #userScopedLoginMethods(ctx, cfg, email, runtimeSettings) {
87
+ const base = await this.#loginMethods(ctx, cfg, runtimeSettings);
88
+ if (!email)
89
+ return base;
90
+ try {
91
+ if (!supportsLoginMethodsPreference(cfg.accountStore))
92
+ return base;
93
+ const acc = await cfg.accountStore.findByEmail(email);
94
+ if (!acc)
95
+ return base;
96
+ const pref = await cfg.accountStore.getLoginMethods(acc.id);
97
+ const normalized = normalizeUserLoginMethods(pref);
98
+ if (!normalized)
99
+ return base;
100
+ const scoped = resolveEffectiveUserLoginMethods(base.authMethods, normalized);
101
+ const magicLinkCapableConfig = cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore);
102
+ return {
103
+ ...base,
104
+ authMethods: scoped,
105
+ magicLinkAvailable: scoped.magicLink && magicLinkCapableConfig,
106
+ // OTP é extensão do magic link — desligar magic link desliga o código.
107
+ otpEnabled: base.otpEnabled && scoped.magicLink,
108
+ };
109
+ }
110
+ catch {
111
+ // Preferência nunca derruba o login.
112
+ return base;
113
+ }
114
+ }
76
115
  async show(ctx) {
77
116
  const service = await ctx.containerResolver.make('authkit.server');
78
117
  const cfg = service.config;
@@ -129,7 +168,11 @@ export default class AuthInteractionController {
129
168
  // `authMethods`, `magicLinkAvailable` e `otpEnabled` saiam POR CONSTRUÇÃO
130
169
  // (o seletor choose-first precisa de `otpEnabled` para oferecer "código").
131
170
  // Passa o runtimeSettings já resolvido para maintenance (sem 2ª resolução).
132
- const loginMethods = await this.#loginMethods(ctx, cfg, runtimeSettingsForMaintenance);
171
+ // Com email na sessão (passo 2+), aplica a preferência POR USUÁRIO — os
172
+ // métodos que o dono da conta desligou não aparecem nem aqui nem nos POSTs.
173
+ const loginMethods = email
174
+ ? await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettingsForMaintenance)
175
+ : await this.#loginMethods(ctx, cfg, runtimeSettingsForMaintenance);
133
176
  const authMethods = loginMethods.authMethods;
134
177
  if (!email) {
135
178
  // Step 1: identifier (email only).
@@ -215,7 +258,7 @@ export default class AuthInteractionController {
215
258
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
216
259
  : null;
217
260
  return render(ctx, 'login', {
218
- ...(await this.#loginMethods(ctx, cfg)),
261
+ ...(await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettings)),
219
262
  uid: ctx.request.param('uid'),
220
263
  csrfToken: ctx.request.csrfToken,
221
264
  step: 'password',
@@ -226,6 +269,35 @@ export default class AuthInteractionController {
226
269
  botProtection: effectiveBotLogin?.widget,
227
270
  });
228
271
  }
272
+ // Preferência por usuário: senha desligada para ESTA conta → recusa ANTES de
273
+ // verificar credenciais (mesma tela/erro de credencial inválida — não vaza
274
+ // se a conta existe nem qual preferência está gravada).
275
+ const userMethodsForLogin = await this.#userScopedLoginMethods(ctx, cfg, email, runtimeSettings);
276
+ if (!userMethodsForLogin.authMethods.password) {
277
+ const found = await cfg.accountStore.findByEmail(email);
278
+ const account = found
279
+ ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
280
+ : null;
281
+ await cfg.audit?.record({
282
+ type: 'login.failure',
283
+ ip,
284
+ clientId,
285
+ metadata: { stage: 'password', reason: 'method_disabled_by_user' },
286
+ });
287
+ return render(ctx, 'login', {
288
+ ...userMethodsForLogin,
289
+ uid: ctx.request.param('uid'),
290
+ csrfToken: ctx.request.csrfToken,
291
+ step: 'password',
292
+ email,
293
+ account,
294
+ error: translate(cfg.messages, 'errors.invalid_credentials'),
295
+ brand,
296
+ botProtection: effectiveBotLogin?.on.includes('login')
297
+ ? effectiveBotLogin.widget
298
+ : undefined,
299
+ });
300
+ }
229
301
  // Verificamos as credenciais ANTES de finalizar a interaction, porque com MFA
230
302
  // ligado precisamos exigir o 2º fator e NÃO podemos chamar interactionFinished
231
303
  // ainda. A sequência verificação + lockout + auditoria de falha é centralizada
@@ -260,7 +332,7 @@ export default class AuthInteractionController {
260
332
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
261
333
  : null;
262
334
  return render(ctx, 'login', {
263
- ...(await this.#loginMethods(ctx, cfg)),
335
+ ...userMethodsForLogin,
264
336
  uid: ctx.request.param('uid'),
265
337
  csrfToken: ctx.request.csrfToken,
266
338
  step: 'password',
@@ -633,6 +705,24 @@ export default class AuthInteractionController {
633
705
  // o e-mail e a tela mostrem SÓ aquele método. Ausente/ inválido = both (histórico).
634
706
  // NÃO condiciona a emissão de token — os dois continuam saindo co-locados.
635
707
  const channel = normalizeLoginChannel(ctx.request.input('channel'));
708
+ // Preferência por usuário: magic link desligado para ESTA conta → não emite
709
+ // token (resposta uniforme "enviado", sem vazar a preferência).
710
+ const userScopedMethods = email && cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore)
711
+ ? await this.#userScopedLoginMethods(ctx, cfg, email)
712
+ : null;
713
+ if (userScopedMethods && !userScopedMethods.authMethods.magicLink) {
714
+ return render(ctx, 'login', {
715
+ ...userScopedMethods,
716
+ uid,
717
+ csrfToken: ctx.request.csrfToken,
718
+ step: 'password',
719
+ email,
720
+ account: null,
721
+ brand,
722
+ magicLinkSent: true,
723
+ magicChannel: 'none',
724
+ });
725
+ }
636
726
  if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
637
727
  const ip = ctx.request.ip?.() ?? null;
638
728
  const clientId = details.params.client_id ?? null;
@@ -676,7 +766,9 @@ export default class AuthInteractionController {
676
766
  // `otpEnabled` vem do spread de `#loginMethods` (mesmo valor do `otpEnabled`
677
767
  // local usado acima para decidir a emissão) — não re-declarado aqui.
678
768
  return render(ctx, 'login', {
679
- ...(await this.#loginMethods(ctx, cfg)),
769
+ ...(email
770
+ ? await this.#userScopedLoginMethods(ctx, cfg, email)
771
+ : await this.#loginMethods(ctx, cfg)),
680
772
  uid,
681
773
  csrfToken: ctx.request.csrfToken,
682
774
  step: 'password',
@@ -715,6 +807,31 @@ export default class AuthInteractionController {
715
807
  });
716
808
  return ctx.response.redirect(`/auth/interaction/${uid}`);
717
809
  }
810
+ // Preferência por usuário: magic link desligado para esta conta (p. ex. o
811
+ // token foi emitido antes de o usuário desligar o método) → não materializa
812
+ // a sessão; volta ao login com erro genérico (não vaza a preferência).
813
+ const magicScoped = await this.#userScopedLoginMethods(ctx, cfg, acc.email);
814
+ if (!magicScoped.authMethods.magicLink) {
815
+ await cfg.audit?.record({
816
+ type: 'login.failure',
817
+ accountId: acc.id,
818
+ email: acc.email,
819
+ ip,
820
+ clientId,
821
+ metadata: { stage: 'magic_link', reason: 'method_disabled_by_user' },
822
+ });
823
+ const render = cfg.render;
824
+ return render(ctx, 'login', {
825
+ ...magicScoped,
826
+ uid,
827
+ csrfToken: ctx.request.csrfToken,
828
+ step: 'password',
829
+ email: acc.email,
830
+ account: null,
831
+ brand: brandFor(cfg.branding, clientId ?? undefined, undefined),
832
+ error: translate(cfg.messages, 'errors.invalid_credentials'),
833
+ });
834
+ }
718
835
  // E-mail não verificado (LGPD/compliance): mesmo com o link válido, não
719
836
  // materializa a sessão se a política exige verificação. Volta ao login com erro.
720
837
  const magicLinkRuntimeSettings = await resolveRuntimeSettingsOrNoop(ctx);
@@ -729,7 +846,7 @@ export default class AuthInteractionController {
729
846
  });
730
847
  const render = cfg.render;
731
848
  return render(ctx, 'login', {
732
- ...(await this.#loginMethods(ctx, cfg)),
849
+ ...(await this.#userScopedLoginMethods(ctx, cfg, acc.email)),
733
850
  uid,
734
851
  csrfToken: ctx.request.csrfToken,
735
852
  step: 'password',
@@ -762,7 +879,7 @@ export default class AuthInteractionController {
762
879
  if (!magicLinkStatusGate.allowed) {
763
880
  const render = cfg.render;
764
881
  return render(ctx, 'login', {
765
- ...(await this.#loginMethods(ctx, cfg)),
882
+ ...(await this.#userScopedLoginMethods(ctx, cfg, acc.email)),
766
883
  uid,
767
884
  csrfToken: ctx.request.csrfToken,
768
885
  step: 'password',
@@ -814,10 +931,10 @@ export default class AuthInteractionController {
814
931
  maxAttempts: cfg.login.otp.maxAttempts,
815
932
  });
816
933
  // Re-render da tela "link enviado" com o campo de código + erro localizado.
817
- // `otpEnabled` vem do spread de `#loginMethods` (aqui, após a guarda acima,
818
- // vale `true`) não re-declarado inline.
934
+ // `otpEnabled` vem do spread (scoped OTP é extensão do magic link, e a
935
+ // preferência do usuário que desligou magic link já foi barrada acima).
819
936
  const renderOtpError = async (messageKey) => render(ctx, 'login', {
820
- ...(await this.#loginMethods(ctx, cfg)),
937
+ ...(await this.#userScopedLoginMethods(ctx, cfg, email)),
821
938
  uid,
822
939
  csrfToken: ctx.request.csrfToken,
823
940
  step: 'password',
@@ -842,7 +959,7 @@ export default class AuthInteractionController {
842
959
  metadata: { stage: 'otp', reason: 'unverified' },
843
960
  });
844
961
  return render(ctx, 'login', {
845
- ...(await this.#loginMethods(ctx, cfg)),
962
+ ...(await this.#userScopedLoginMethods(ctx, cfg, result.account.email)),
846
963
  uid,
847
964
  csrfToken: ctx.request.csrfToken,
848
965
  step: 'password',
@@ -868,7 +985,7 @@ export default class AuthInteractionController {
868
985
  });
869
986
  if (!otpStatusGate.allowed) {
870
987
  return render(ctx, 'login', {
871
- ...(await this.#loginMethods(ctx, cfg)),
988
+ ...(await this.#userScopedLoginMethods(ctx, cfg, result.account.email)),
872
989
  uid,
873
990
  csrfToken: ctx.request.csrfToken,
874
991
  step: 'password',
@@ -1033,6 +1150,36 @@ export default class AuthInteractionController {
1033
1150
  false)
1034
1151
  : false;
1035
1152
  ctx.session.forget(PASSKEY_AUTH_CHALLENGE_KEY);
1153
+ // Preferência por usuário: passkey desligada para esta conta → recusa a
1154
+ // assertion. Checamos DEPOIS de `ok` para não revelar a preferência no
1155
+ // timing de uma assertion inválida. Aqui vale SÓ a preferência explícita do
1156
+ // usuário (`pref.passkey === false`): os switches globais de passkey já são
1157
+ // aplicados na RENDERIZAÇÃO do botão/passkey-first (e o desafio MFA usa
1158
+ // passkey como 2º fator independente do passkey-first).
1159
+ if (ok && accountId && supportsLoginMethodsPreference(cfg.accountStore)) {
1160
+ const prefAccount = await cfg.accountStore.findById(accountId);
1161
+ const pref = prefAccount
1162
+ ? normalizeUserLoginMethods(await cfg.accountStore.getLoginMethods(prefAccount.id))
1163
+ : null;
1164
+ if (pref?.passkey === false) {
1165
+ await cfg.audit?.record({
1166
+ type: 'login.failure',
1167
+ accountId,
1168
+ ip,
1169
+ clientId,
1170
+ metadata: { stage: 'mfa', method: 'webauthn', reason: 'method_disabled_by_user' },
1171
+ });
1172
+ return render(ctx, 'mfa-challenge', {
1173
+ uid: ctx.request.param('uid'),
1174
+ csrfToken: ctx.request.csrfToken,
1175
+ error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
1176
+ brand,
1177
+ passkeyAvailable: false,
1178
+ trustedDevicesEnabled: cfg.trustedDevices.enabled,
1179
+ trustedDeviceDays: cfg.trustedDevices.days,
1180
+ });
1181
+ }
1182
+ }
1036
1183
  if (!ok) {
1037
1184
  await cfg.audit?.record({
1038
1185
  type: 'login.failure',