@adonis-agora/authkit-server 0.58.3 → 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.
@@ -72,8 +72,13 @@ export default class AccountOrgsController {
72
72
  if (!name || !slug)
73
73
  return response.redirect(accountPath('orgs'));
74
74
  try {
75
- await store.createOrg({ name, slug, ownerAccountId: accountId });
76
- await cfg.audit?.record({ type: 'organization.created', accountId, metadata: { slug } });
75
+ const org = await store.createOrg({ name, slug, ownerAccountId: accountId });
76
+ await cfg.audit?.record({
77
+ type: 'organization.created',
78
+ accountId,
79
+ orgId: org.id,
80
+ metadata: { slug },
81
+ });
77
82
  }
78
83
  catch {
79
84
  // slug duplicado ou outro erro — redireciona sem mensagem de erro específica
@@ -112,6 +117,7 @@ export default class AccountOrgsController {
112
117
  await cfg.audit?.record({
113
118
  type: 'organization.switched',
114
119
  accountId,
120
+ orgId,
115
121
  metadata: { orgId, orgSlug: org.slug },
116
122
  });
117
123
  return response.redirect(accountPath('orgs'));
@@ -140,6 +146,7 @@ export default class AccountOrgsController {
140
146
  await cfg.audit?.record({
141
147
  type: 'organization.member_removed',
142
148
  accountId,
149
+ orgId: params.id,
143
150
  metadata: { orgId: params.id, self: true },
144
151
  });
145
152
  }
@@ -217,6 +224,7 @@ export default class AccountOrgsController {
217
224
  await cfg.audit?.record({
218
225
  type: 'organization.invitation_sent',
219
226
  accountId,
227
+ orgId: params.id,
220
228
  metadata: { orgId: params.id, email, role },
221
229
  });
222
230
  return response.redirect(accountPath('orgs'));
@@ -270,6 +278,7 @@ export default class AccountOrgsController {
270
278
  await cfg.audit?.record({
271
279
  type: 'organization.invitation_accepted',
272
280
  accountId,
281
+ orgId: invitation.organizationId,
273
282
  metadata: { orgId: invitation.organizationId, invitationId: invitation.id },
274
283
  });
275
284
  }
@@ -294,6 +303,7 @@ export default class AccountOrgsController {
294
303
  await cfg.audit?.record({
295
304
  type: 'organization.member_removed',
296
305
  actorId,
306
+ orgId: params.id,
297
307
  metadata: { orgId: params.id, targetAccountId: params.accountId },
298
308
  });
299
309
  }
@@ -318,6 +328,7 @@ export default class AccountOrgsController {
318
328
  await cfg.audit?.record({
319
329
  type: 'organization.invitation_revoked',
320
330
  actorId,
331
+ orgId: params.id,
321
332
  metadata: { orgId: params.id, invitationId: params.invId },
322
333
  });
323
334
  return response.redirect(accountPath('orgs'));
@@ -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.
@@ -607,6 +607,9 @@ export function registerAuthHost(router, opts = {}) {
607
607
  router.delete(`${apiBase}/apps/:clientId`, [C.accountApi, 'revokeApp']);
608
608
  // MFA + passkeys.
609
609
  router.get(`${apiBase}/mfa`, [C.accountApi, 'mfaStatus']);
610
+ // Login methods preference (self-service, por usuário).
611
+ router.get(`${apiBase}/login-methods`, [C.accountApi, 'getLoginMethods']);
612
+ router.put(`${apiBase}/login-methods`, [C.accountApi, 'updateLoginMethods']);
610
613
  router.get(`${apiBase}/passkeys`, [C.accountApi, 'listPasskeys']);
611
614
  router.delete(`${apiBase}/passkeys/:id`, [C.accountApi, 'removePasskey']);
612
615
  // PATs.