@adonis-agora/authkit-server 0.59.0 → 0.61.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 (27) hide show
  1. package/build/index.d.ts +4 -2
  2. package/build/index.js +3 -1
  3. package/build/providers/authkit_server_provider.js +7 -0
  4. package/build/src/accounts/account_store.d.ts +20 -1
  5. package/build/src/accounts/account_store.js +4 -0
  6. package/build/src/accounts/lucid_account_store.js +4 -0
  7. package/build/src/accounts/lucid_store/login_methods.d.ts +11 -0
  8. package/build/src/accounts/lucid_store/login_methods.js +32 -0
  9. package/build/src/define_config.d.ts +23 -0
  10. package/build/src/define_config.js +6 -0
  11. package/build/src/host/account_api/account_api_controller.d.ts +38 -0
  12. package/build/src/host/account_api/account_api_controller.js +84 -3
  13. package/build/src/host/auth_host_config.d.ts +9 -0
  14. package/build/src/host/controllers/headless_login_methods_controller.d.ts +31 -0
  15. package/build/src/host/controllers/headless_login_methods_controller.js +79 -0
  16. package/build/src/host/controllers/interaction_controller.js +159 -12
  17. package/build/src/host/controllers/social_controller.js +29 -1
  18. package/build/src/host/login_methods_state.d.ts +39 -0
  19. package/build/src/host/login_methods_state.js +52 -0
  20. package/build/src/host/register_auth_host.js +18 -0
  21. package/build/src/host/ui-dist/assets/{index-6cE5JMyP.js → index-Cb38N16r.js} +1 -1
  22. package/build/src/host/ui-dist/index.html +1 -1
  23. package/build/src/host/user_login_methods.d.ts +94 -0
  24. package/build/src/host/user_login_methods.js +132 -0
  25. package/package.json +7 -4
  26. package/skills/authkit-idp-setup/SKILL.md +222 -0
  27. package/skills/authkit-interactions/SKILL.md +236 -0
@@ -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',
@@ -1,8 +1,10 @@
1
1
  import '../augmentations.js';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { supportsProviderIdentity } from '../../accounts/account_store.js';
3
+ import { supportsLoginMethodsPreference, supportsMagicLink, supportsPasskeys, supportsProviderIdentity, } from '../../accounts/account_store.js';
4
4
  import { assertLoginAllowed } from '../login_attempt.js';
5
5
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
6
+ import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
7
+ import { resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
6
8
  const UID_SESSION_KEY = 'authkit_social_uid';
7
9
  /**
8
10
  * `AllyService.use()` é tipado contra a interface `SocialProviders`, que só é
@@ -13,6 +15,19 @@ const UID_SESSION_KEY = 'authkit_social_uid';
13
15
  function useProvider(ctx, provider) {
14
16
  return ctx.ally.use(provider);
15
17
  }
18
+ /**
19
+ * Métodos globais efetivos para o gate por usuário do callback social.
20
+ * Espelha o `#loginMethods` do interaction controller (mesma fonte de
21
+ * capabilities), sem acoplar os dois controllers.
22
+ */
23
+ async function resolveGlobalAuthMethodsForUser(cfg, settings) {
24
+ return await resolveEffectiveAuthMethods(settings, {
25
+ configuredSocialProviders: cfg.social?.providers ?? [],
26
+ magicLinkCapable: cfg.passwordless?.magicLink && supportsMagicLink(cfg.accountStore),
27
+ passkeyCapable: supportsPasskeys(cfg.accountStore),
28
+ configOverrides: cfg.authMethods,
29
+ });
30
+ }
16
31
  export default class AuthSocialController {
17
32
  /** GET /auth/:provider/redirect/:uid — guarda o uid e redireciona para o provider OAuth. */
18
33
  async redirect(ctx) {
@@ -118,6 +133,19 @@ export default class AuthSocialController {
118
133
  ctx.session.forget(UID_SESSION_KEY);
119
134
  return ctx.response.redirect(backToLogin);
120
135
  }
136
+ // Preferência por usuário: método social desligado para esta conta → recusa.
137
+ // Aplica-se a contas EXISTENTES (identidade ligada ou e-mail conhecido); uma
138
+ // conta recém-criada pelo callback não tem preferência gravada, então herda
139
+ // os globais — o gate é no-op para ela por construção.
140
+ const existingUser = user;
141
+ if (supportsLoginMethodsPreference(store)) {
142
+ const pref = await store.getLoginMethods(existingUser.id);
143
+ const scoped = resolveEffectiveUserLoginMethods(await resolveGlobalAuthMethodsForUser(cfg, settings), pref);
144
+ if (scoped.social.length === 0) {
145
+ ctx.session.forget(UID_SESSION_KEY);
146
+ return ctx.response.redirect(backToLogin);
147
+ }
148
+ }
121
149
  ctx.session.forget(UID_SESSION_KEY);
122
150
  // Conclui a interaction OIDC para este usuário (escreve o 303 de volta ao authorize).
123
151
  // O cookie de interaction (path '/') sobrevive ao round-trip do provider OAuth.
@@ -0,0 +1,39 @@
1
+ import type { ResolvedServerConfig } from '../define_config.js';
2
+ import type { SettingsCapability } from './runtime_settings.js';
3
+ import { type UserLoginMethods } from './user_login_methods.js';
4
+ /** Resultado compartilhado de "estado de tipos de login" entregue à UI. */
5
+ export interface LoginMethodsState {
6
+ supported: boolean;
7
+ /** Preferência crua do usuário; {} = sem preferência (herda globais). */
8
+ methods: UserLoginMethods | Record<string, never>;
9
+ /** Estado final por método (global ∩ preferência) — o que a tela de login mostra. */
10
+ available: {
11
+ password: boolean;
12
+ magicLink: boolean;
13
+ passkey: boolean;
14
+ social: string[];
15
+ forgotPassword: boolean;
16
+ } | null;
17
+ /** Métodos fora do controle do usuário (globalmente off ou pin de config). */
18
+ locked: {
19
+ password: boolean;
20
+ magicLink: boolean;
21
+ passkey: boolean;
22
+ social: boolean;
23
+ } | null;
24
+ }
25
+ /**
26
+ * Métodos globais efetivos + pins de config — resolução compartilhada entre o
27
+ * console (account API) e a API headless. Fail-safe: erro → defaults.
28
+ */
29
+ export declare function resolveGlobalAuthMethods(settings: SettingsCapability, cfg: ResolvedServerConfig): Promise<{
30
+ resolved: import("./runtime_toggles.js").ResolvedAuthMethods;
31
+ locked: (keyof import("./runtime_toggles.js").AuthMethodsConfigOverride)[];
32
+ }>;
33
+ /**
34
+ * Monta o estado final de tipos de login para uma conta, intersectando a
35
+ * preferência do usuário com os métodos globais efetivos. Reusado pelo console
36
+ * de conta (`/account/api/login-methods`) e pela API headless
37
+ * (`{baseUrl}/login-methods`) — sem duplicar regra.
38
+ */
39
+ export declare function loginMethodsStateForAccount(accountId: string, store: ResolvedServerConfig['accountStore'], settings: SettingsCapability, cfg: ResolvedServerConfig): Promise<LoginMethodsState>;
@@ -0,0 +1,52 @@
1
+ import { supportsMagicLink } from '../accounts/account_store.js';
2
+ import { supportsPasskeys } from '../accounts/account_store.js';
3
+ import { supportsLoginMethodsPreference } from '../accounts/account_store.js';
4
+ import { resolveEffectiveAuthMethods } from './runtime_toggles.js';
5
+ import { configLockedAuthMethods } from './runtime_toggles.js';
6
+ import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from './user_login_methods.js';
7
+ /**
8
+ * Métodos globais efetivos + pins de config — resolução compartilhada entre o
9
+ * console (account API) e a API headless. Fail-safe: erro → defaults.
10
+ */
11
+ export async function resolveGlobalAuthMethods(settings, cfg) {
12
+ const resolved = await resolveEffectiveAuthMethods(settings, {
13
+ configuredSocialProviders: cfg.social?.providers ?? [],
14
+ magicLinkCapable: cfg.passwordless?.magicLink && supportsMagicLink(cfg.accountStore),
15
+ passkeyCapable: supportsPasskeys(cfg.accountStore),
16
+ configOverrides: cfg.authMethods,
17
+ });
18
+ return { resolved, locked: configLockedAuthMethods(cfg.authMethods) };
19
+ }
20
+ /**
21
+ * Monta o estado final de tipos de login para uma conta, intersectando a
22
+ * preferência do usuário com os métodos globais efetivos. Reusado pelo console
23
+ * de conta (`/account/api/login-methods`) e pela API headless
24
+ * (`{baseUrl}/login-methods`) — sem duplicar regra.
25
+ */
26
+ export async function loginMethodsStateForAccount(accountId, store, settings, cfg) {
27
+ if (!supportsLoginMethodsPreference(store)) {
28
+ return { supported: false, methods: {}, available: null, locked: null };
29
+ }
30
+ const pref = normalizeUserLoginMethods(await store.getLoginMethods(accountId));
31
+ const { resolved: global, locked: cfgLocked } = await resolveGlobalAuthMethods(settings, cfg);
32
+ const effective = resolveEffectiveUserLoginMethods(global, pref);
33
+ return {
34
+ supported: true,
35
+ // Preferência crua do usuário; {} = sem preferência (herda globais).
36
+ methods: pref ?? {},
37
+ available: {
38
+ password: effective.password,
39
+ magicLink: effective.magicLink,
40
+ passkey: effective.passkey,
41
+ social: effective.social,
42
+ forgotPassword: effective.forgotPassword,
43
+ },
44
+ // Métodos que o usuário NÃO pode controlar (globalmente off ou pin de config).
45
+ locked: {
46
+ password: !global.password || cfgLocked.includes('password'),
47
+ magicLink: !global.magicLink || cfgLocked.includes('magicLink'),
48
+ passkey: !global.passkey || cfgLocked.includes('passkey'),
49
+ social: global.social.length === 0,
50
+ },
51
+ };
52
+ }
@@ -201,6 +201,8 @@ const C = {
201
201
  apiKeys: () => import('./admin_api/api_keys_controller.js'),
202
202
  // Account self-service JSON API (session-authed, under /account/api/*).
203
203
  accountApi: () => import('./account_api/account_api_controller.js'),
204
+ // API headless (Clerk-style) — host-session-authed via `headless.resolveAccountId`.
205
+ headlessLoginMethods: () => import('./controllers/headless_login_methods_controller.js'),
204
206
  };
205
207
  /**
206
208
  * Monta todas as rotas do host-kit do Authorization Server numa chamada.
@@ -607,6 +609,9 @@ export function registerAuthHost(router, opts = {}) {
607
609
  router.delete(`${apiBase}/apps/:clientId`, [C.accountApi, 'revokeApp']);
608
610
  // MFA + passkeys.
609
611
  router.get(`${apiBase}/mfa`, [C.accountApi, 'mfaStatus']);
612
+ // Login methods preference (self-service, por usuário).
613
+ router.get(`${apiBase}/login-methods`, [C.accountApi, 'getLoginMethods']);
614
+ router.put(`${apiBase}/login-methods`, [C.accountApi, 'updateLoginMethods']);
610
615
  router.get(`${apiBase}/passkeys`, [C.accountApi, 'listPasskeys']);
611
616
  router.delete(`${apiBase}/passkeys/:id`, [C.accountApi, 'removePasskey']);
612
617
  // PATs.
@@ -619,6 +624,19 @@ export function registerAuthHost(router, opts = {}) {
619
624
  router.get(`${apiBase}/orgs/:id`, [C.accountApi, 'showOrg']);
620
625
  })
621
626
  .use([accountGuard]);
627
+ // API headless (Clerk-style) — montada fora do `accountGuard` (não depende da
628
+ // sessão de CONTA), autenticada pelo resolver do host (`headless.resolveAccountId`).
629
+ // `PUT` usa CSRF igual à API do console (middleware de shield do host).
630
+ const headlessCfg = hostCfg?.headless;
631
+ if (headlessCfg) {
632
+ const hb = headlessCfg.baseUrl;
633
+ router
634
+ .get(`${hb}/login-methods`, [C.headlessLoginMethods, 'index'])
635
+ .as('authkit.headless.login_methods.index');
636
+ router
637
+ .put(`${hb}/login-methods`, [C.headlessLoginMethods, 'update'])
638
+ .as('authkit.headless.login_methods.update');
639
+ }
622
640
  // Prefixos resolvidos dos consoles — `null` quando o grupo não foi montado.
623
641
  // Compõem o `AuthHostRouteMap` devolvido no fim.
624
642
  let resolvedAdminPrefix = null;