@adonis-agora/authkit-server 0.54.0 → 0.55.1

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 (63) hide show
  1. package/build/index.d.ts +4 -2
  2. package/build/index.js +2 -1
  3. package/build/providers/authkit_server_provider.js +18 -0
  4. package/build/src/accounts/lucid_account_store.d.ts +35 -0
  5. package/build/src/accounts/lucid_account_store.js +9 -0
  6. package/build/src/accounts/lucid_store/core.js +88 -19
  7. package/build/src/accounts/lucid_store/shared.d.ts +14 -0
  8. package/build/src/accounts/lucid_store/token_hash.d.ts +79 -0
  9. package/build/src/accounts/lucid_store/token_hash.js +145 -0
  10. package/build/src/define_config.d.ts +93 -8
  11. package/build/src/define_config.js +28 -2
  12. package/build/src/host/account_api/account_api_controller.js +5 -4
  13. package/build/src/host/admin_api/admin_users_service.js +2 -1
  14. package/build/src/host/admin_api/api_orgs_controller.js +2 -1
  15. package/build/src/host/admin_console/console_impersonation_controller.d.ts +15 -1
  16. package/build/src/host/admin_console/console_impersonation_controller.js +26 -2
  17. package/build/src/host/admin_console/console_orgs_controller.js +3 -1
  18. package/build/src/host/admin_validators.d.ts +3 -3
  19. package/build/src/host/auth_host_config.d.ts +30 -0
  20. package/build/src/host/auth_host_config.js +15 -0
  21. package/build/src/host/config_locks.d.ts +29 -0
  22. package/build/src/host/config_locks.js +46 -0
  23. package/build/src/host/console_session.d.ts +38 -2
  24. package/build/src/host/console_session.js +46 -2
  25. package/build/src/host/controllers/account_mfa_controller.js +5 -5
  26. package/build/src/host/controllers/account_orgs_controller.js +2 -1
  27. package/build/src/host/controllers/account_security_controller.js +6 -5
  28. package/build/src/host/controllers/interaction_controller.js +122 -26
  29. package/build/src/host/controllers/registration_controller.js +5 -4
  30. package/build/src/host/controllers/social_controller.js +37 -0
  31. package/build/src/host/default_mailer.d.ts +36 -5
  32. package/build/src/host/default_mailer.js +63 -10
  33. package/build/src/host/i18n.d.ts +10 -0
  34. package/build/src/host/i18n.js +16 -0
  35. package/build/src/host/login_attempt.d.ts +88 -2
  36. package/build/src/host/login_attempt.js +101 -48
  37. package/build/src/host/login_notify.js +2 -2
  38. package/build/src/host/oidc_rp_guard.d.ts +25 -2
  39. package/build/src/host/oidc_rp_guard.js +55 -9
  40. package/build/src/host/origin.d.ts +21 -0
  41. package/build/src/host/origin.js +22 -0
  42. package/build/src/host/register_auth_host.d.ts +104 -3
  43. package/build/src/host/register_auth_host.js +222 -26
  44. package/build/src/host/runtime_settings.d.ts +16 -0
  45. package/build/src/host/runtime_settings.js +24 -0
  46. package/build/src/host/runtime_toggles.d.ts +10 -0
  47. package/build/src/host/runtime_toggles.js +4 -0
  48. package/build/src/host/security_notice_service.d.ts +4 -2
  49. package/build/src/host/security_notice_service.js +4 -2
  50. package/build/src/host/sudo/index.d.ts +8 -0
  51. package/build/src/host/sudo/index.js +8 -0
  52. package/build/src/host/sudo/methods/magic_link.d.ts +17 -3
  53. package/build/src/host/sudo/methods/magic_link.js +37 -13
  54. package/build/src/host/sudo/runtime.d.ts +44 -4
  55. package/build/src/host/sudo/runtime.js +90 -6
  56. package/build/src/host/sudo/satisfiability.d.ts +62 -0
  57. package/build/src/host/sudo/satisfiability.js +89 -0
  58. package/build/src/password/common_passwords.js +27 -7
  59. package/build/src/provider/oidc_service.js +30 -13
  60. package/build/src/provider/token_exchange.d.ts +25 -1
  61. package/build/src/provider/token_exchange.js +29 -2
  62. package/package.json +6 -3
  63. /package/build/{password → src/password}/common_passwords.txt +0 -0
@@ -1,4 +1,5 @@
1
1
  import { getAccountLoginUrl } from './account_login_url.js';
2
+ import { impersonationState } from './impersonation_session.js';
2
3
  import { ACCOUNT_SESSION_KEY } from './middleware/account_auth.js';
3
4
  /**
4
5
  * Helpers públicos para integrar a sessão do console do AuthKit com
@@ -10,13 +11,56 @@ import { ACCOUNT_SESSION_KEY } from './middleware/account_auth.js';
10
11
  * sessão mudar.
11
12
  */
12
13
  /**
13
- * Retorna o id da conta logada no console (ou `null` quando não há
14
- * sessão).
14
+ * Retorna o id da conta que o request está representando no console (ou `null`
15
+ * quando não há sessão).
16
+ *
17
+ * ATENÇÃO: com impersonation ativa isto é a conta PERSONIFICADA, não o admin
18
+ * que a personificou. É o comportamento certo para "como qual conta este
19
+ * request está agindo?" e é a entrada ERRADA para uma checagem de
20
+ * permissão/role — para isso use `realAccountId`.
15
21
  */
16
22
  export function getAccountId(ctx) {
17
23
  const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
18
24
  return accountId ?? null;
19
25
  }
26
+ /**
27
+ * Retorna o id do HUMANO real por trás do request — o id que uma decisão de
28
+ * autorização deve usar.
29
+ *
30
+ * ```
31
+ * impersonation ativa → o id do impersonator (o admin real)
32
+ * sem impersonation → o id da conta logada
33
+ * sem sessão → null
34
+ * ```
35
+ *
36
+ * USE ESTE em qualquer checagem de permissão/role ("esta pessoa é admin?",
37
+ * "esta pessoa pode aprovar isto?"). Use `getAccountId` só para responder
38
+ * "como qual conta este request está agindo?" (queries com escopo no alvo,
39
+ * UI, banner de impersonation).
40
+ *
41
+ * POR QUE ISSO EXISTE. Durante uma impersonation, `getAccountId` devolve a
42
+ * conta PERSONIFICADA — é o comportamento correto dele e o resto do app
43
+ * depende disso. Mas ele é, por isso mesmo, a entrada ERRADA para um role
44
+ * check: perguntar "o `getAccountId` é admin?" com impersonation ativa
45
+ * pergunta sobre o usuário personificado e, quando ele por acaso for admin,
46
+ * entrega os privilégios do admin a quem está sendo personificado — e, no
47
+ * sentido inverso, faz o admin real perder o próprio acesso. `realAccountId`
48
+ * ignora a troca de conta e responde sempre sobre quem está de fato dirigindo
49
+ * a sessão.
50
+ *
51
+ * @example
52
+ * // gate de /admin: a pessoa por trás do request é admin?
53
+ * const id = realAccountId(ctx)
54
+ * if (!id || !(await authz.hasRole(id, 'admin'))) throw new Error('forbidden')
55
+ */
56
+ export function realAccountId(ctx) {
57
+ // Sem sessão não há nem impersonation nem conta: mesma tolerância do
58
+ // `getAccountId` (que usa `ctx.session?.`), pois `impersonationState` assume
59
+ // uma sessão presente.
60
+ if (!ctx.session)
61
+ return null;
62
+ return impersonationState(ctx).impersonatorId ?? getAccountId(ctx);
63
+ }
20
64
  /**
21
65
  * `true` quando o request carrega uma sessão de conta do console.
22
66
  */
@@ -112,13 +112,13 @@ export default class AccountMfaController {
112
112
  kind: 'passkey_added',
113
113
  ip,
114
114
  timestamp: ts,
115
- }, cfg.mail, cfg.audit);
115
+ }, cfg.mail, cfg.audit, cfg);
116
116
  await dispatchSecurityNotice(ctx, {
117
117
  account: { id: userId, email: accountForNotice.email },
118
118
  kind: 'mfa_enabled',
119
119
  ip,
120
120
  timestamp: ts,
121
- }, cfg.mail, cfg.audit);
121
+ }, cfg.mail, cfg.audit, cfg);
122
122
  }
123
123
  // Resposta DUAL. A cerimônia WebAuthn da tela built-in (`mfa.edge`) chama
124
124
  // este endpoint por `fetch` e só olha `res.ok` — mas um `<form>` HTML
@@ -158,7 +158,7 @@ export default class AccountMfaController {
158
158
  kind: 'passkey_removed',
159
159
  ip: ctx.request.ip?.() ?? null,
160
160
  timestamp: new Date().toISOString(),
161
- }, cfg.mail, cfg.audit);
161
+ }, cfg.mail, cfg.audit, cfg);
162
162
  }
163
163
  return ctx.response.redirect(accountPath('mfa'));
164
164
  }
@@ -225,7 +225,7 @@ export default class AccountMfaController {
225
225
  kind: 'mfa_enabled',
226
226
  ip: ctx.request.ip?.() ?? null,
227
227
  timestamp: new Date().toISOString(),
228
- }, cfg.mail, cfg.audit);
228
+ }, cfg.mail, cfg.audit, cfg);
229
229
  }
230
230
  // Mostra os recovery codes UMA vez (flash) e volta pro estado "ativado".
231
231
  ctx.session.flash('recoveryCodes', result.recoveryCodes ?? []);
@@ -255,7 +255,7 @@ export default class AccountMfaController {
255
255
  kind: 'mfa_disabled',
256
256
  ip: ctx.request.ip?.() ?? null,
257
257
  timestamp: new Date().toISOString(),
258
- }, cfg.mail, cfg.audit);
258
+ }, cfg.mail, cfg.audit, cfg);
259
259
  }
260
260
  return ctx.response.redirect(accountPath('mfa'));
261
261
  }
@@ -4,6 +4,7 @@ import { getAccountLoginUrl } from '../account_login_url.js';
4
4
  import { accountPath } from '../account_paths.js';
5
5
  import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
6
6
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
7
+ import { authkitOrigin } from '../origin.js';
7
8
  import { resolveRuntimeSettings } from '../runtime_settings.js';
8
9
  import { isRoleInCatalog } from '../runtime_toggles.js';
9
10
  /**
@@ -190,7 +191,7 @@ export default class AccountOrgsController {
190
191
  // Dispara e-mail via mail hook (best-effort)
191
192
  if (cfg.mail?.onOrgInvitation) {
192
193
  const org = await store.findOrgById(params.id);
193
- const acceptUrl = `${ctx.request.protocol()}://${ctx.request.host()}${accountPath('orgs')}/invitations/${token}/accept`;
194
+ const acceptUrl = `${authkitOrigin(cfg)}${accountPath('orgs')}/invitations/${token}/accept`;
194
195
  try {
195
196
  await cfg.mail.onOrgInvitation({
196
197
  email,
@@ -11,6 +11,7 @@ import { AvatarUploadError, isAvatarUploadSupported, storeAvatar } from '../avat
11
11
  import { sendEmailChangeConfirmationEmail, sendEmailChangeNoticeEmail, sendEmailChangedCompletedEmail, } from '../default_mailer.js';
12
12
  import { translate } from '../i18n.js';
13
13
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
14
+ import { authkitOrigin } from '../origin.js';
14
15
  import { resolveRuntimeSettings } from '../runtime_settings.js';
15
16
  import { resolveEffectiveEmailChange, resolveEffectivePasswordHistory, } from '../runtime_toggles.js';
16
17
  import { dispatchSecurityNotice } from '../security_notice_service.js';
@@ -366,7 +367,7 @@ export default class AccountSecurityController {
366
367
  kind: 'password_changed',
367
368
  ip: ctx.request.ip?.() ?? null,
368
369
  timestamp: new Date().toISOString(),
369
- }, cfg.mail, cfg.audit);
370
+ }, cfg.mail, cfg.audit, cfg);
370
371
  }
371
372
  ctx.session.flash('passwordChanged', translate(cfg.messages, 'account.security.password_changed'));
372
373
  return ctx.response.redirect(accountPath('security'));
@@ -417,7 +418,7 @@ export default class AccountSecurityController {
417
418
  email: newEmail,
418
419
  ip: ctx.request.ip?.() ?? null,
419
420
  });
420
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
421
+ const origin = authkitOrigin(cfg);
421
422
  const confirmUrl = `${origin}${accountPath('emailConfirm')}?token=${encodeURIComponent(issued.token)}`;
422
423
  // 1. Link de confirmação → NOVO e-mail (hook onEmailChangeConfirm > onEmailVerification > default).
423
424
  if (cfg.mail?.onEmailChangeConfirm) {
@@ -448,7 +449,7 @@ export default class AccountSecurityController {
448
449
  await cfg.mail.onEmailChangeNotice({ email: account.email, newEmail });
449
450
  }
450
451
  else {
451
- await sendEmailChangeNoticeEmail(ctx, {
452
+ await sendEmailChangeNoticeEmail(ctx, cfg, {
452
453
  email: account.email,
453
454
  newEmail,
454
455
  });
@@ -530,7 +531,7 @@ export default class AccountSecurityController {
530
531
  .catch(() => { });
531
532
  }
532
533
  else {
533
- await sendEmailChangedCompletedEmail(ctx, {
534
+ await sendEmailChangedCompletedEmail(ctx, cfg, {
534
535
  oldEmail: result.oldEmail,
535
536
  newEmail: result.newEmail,
536
537
  });
@@ -542,7 +543,7 @@ export default class AccountSecurityController {
542
543
  ip: ctx.request.ip?.() ?? null,
543
544
  timestamp: new Date().toISOString(),
544
545
  metadata: { oldEmail: result.oldEmail, newEmail: result.newEmail },
545
- }, cfg.mail, cfg.audit);
546
+ }, cfg.mail, cfg.audit, cfg);
546
547
  }
547
548
  return render(ctx, 'account/email-confirmed', { ok: result.ok });
548
549
  }
@@ -6,28 +6,35 @@ import { brandFor, isFirstParty } from '../branding.js';
6
6
  import { sendMagicLinkEmail } from '../default_mailer.js';
7
7
  import { sendOtpUnlockEmail } from '../default_mailer.js';
8
8
  import { translate } from '../i18n.js';
9
- import { attemptPasswordLogin, isEmailUnverifiedBlock } from '../login_attempt.js';
9
+ import { assertLoginAllowed, attemptPasswordLogin, isEmailUnverifiedBlock, } from '../login_attempt.js';
10
10
  import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
11
11
  import { notifyLoginSuccess } from '../login_notify.js';
12
+ import { authkitOrigin } from '../origin.js';
12
13
  import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
13
- import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
14
+ import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
14
15
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
15
16
  import { TRUSTED_DEVICE_COOKIE, buildTrustedDevicePayload, isTrustedDeviceValid, } from '../trusted_device.js';
16
17
  /**
17
- * Best-effort: returns a RuntimeSettings backed by the container DB, or a no-op
18
- * fallback. Contrato NON-NULL preservado é chamado 6× passando direto p/
19
- * funções que exigem `SettingsCapability` non-null. Reusa a fábrica canônica
20
- * `resolveRuntimeSettings` e degrada para o RuntimeSettings no-op (probe via
21
- * SELECT lança tabela ausente config fallback) quando a resolução falha.
18
+ * Chave i18n do erro de status de conta (disabled/expired) compartilhada pelos
19
+ * fluxos passwordless (magic link, OTP, passkey).
20
+ *
21
+ * O braço `password_expired` é HOJE inalcançável por estes três: eles passam
22
+ * `passwordless: true` ao gate, que pula a checagem de senha vencida (plan 012 —
23
+ * uma conta sem senha ficava trancada, porque estes fluxos não têm o passo de
24
+ * troca obrigatória para onde mandá-la). Ele permanece aqui porque
25
+ * {@link AccountStatusReason} é exaustivo e um chamador futuro pode não passar a
26
+ * flag; reaproveita o texto do passo de troca (`login.password_expired_intro`)
27
+ * em vez de inventar uma chave nova.
22
28
  */
23
- async function getRuntimeSettings(ctx) {
24
- const rs = await resolveRuntimeSettings(ctx);
25
- return (rs ??
26
- new RuntimeSettings({
27
- table: () => {
28
- throw new Error('no-op');
29
- },
30
- }));
29
+ function accountStatusErrorKey(reason) {
30
+ switch (reason) {
31
+ case 'disabled':
32
+ return 'errors.account_disabled';
33
+ case 'account_expired':
34
+ return 'errors.account_expired';
35
+ case 'password_expired':
36
+ return 'login.password_expired_intro';
37
+ }
31
38
  }
32
39
  const SESSION_KEY = 'authkit_login_email';
33
40
  /** accountId aguardando o 2º fator depois da senha verificada. */
@@ -51,7 +58,7 @@ export default class AuthInteractionController {
51
58
  * maintenance) para evitar uma segunda resolução; ausente, resolve sob demanda.
52
59
  */
53
60
  async #loginMethods(ctx, cfg, runtimeSettings) {
54
- const settings = runtimeSettings ?? (await getRuntimeSettings(ctx));
61
+ const settings = runtimeSettings ?? (await resolveRuntimeSettingsOrNoop(ctx));
55
62
  const magicLinkCapableConfig = cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore);
56
63
  const authMethods = await resolveEffectiveAuthMethods(settings, {
57
64
  configuredSocialProviders: cfg.social?.providers ?? [],
@@ -75,7 +82,7 @@ export default class AuthInteractionController {
75
82
  // Maintenance mode: verifica se é uma conta admin antes de bloquear.
76
83
  // Contas admin continuam podendo logar para que o operador possa desligar a
77
84
  // manutenção via console. Contas comuns veem a tela de manutenção.
78
- const runtimeSettingsForMaintenance = await getRuntimeSettings(ctx);
85
+ const runtimeSettingsForMaintenance = await resolveRuntimeSettingsOrNoop(ctx);
79
86
  const maintenance = await resolveEffectiveMaintenanceMode(runtimeSettingsForMaintenance);
80
87
  if (maintenance.enabled) {
81
88
  // Verifica se já há uma conta admin na sessão (já autenticado) OU
@@ -145,7 +152,7 @@ export default class AuthInteractionController {
145
152
  const passkeyFirstCapable = cfg.passwordless.passkeyFirst && !!acc && (await this.hasPasskeys(cfg, acc.id));
146
153
  const passkeyFirstAvailable = authMethods.passkey && passkeyFirstCapable;
147
154
  // Effective bot protection: may be overridden at runtime via auth_settings.
148
- const effectiveBot = await resolveEffectiveBotProtection(cfg.botProtection, await getRuntimeSettings(ctx));
155
+ const effectiveBot = await resolveEffectiveBotProtection(cfg.botProtection, await resolveRuntimeSettingsOrNoop(ctx));
149
156
  // Session policy: resolve para exibir o checkbox "manter conectado".
150
157
  const sessionPolicyForShow = await resolveEffectiveSessionPolicy(runtimeSettingsForMaintenance, cfg.ttl?.session ? Math.ceil(cfg.ttl.session / 3600) : undefined);
151
158
  return render(ctx, 'login', {
@@ -191,7 +198,7 @@ export default class AuthInteractionController {
191
198
  const ip = ctx.request.ip?.() ?? null;
192
199
  const clientId = details.params.client_id ?? null;
193
200
  // Resolve runtime settings (reutilizado por bot + maintenance + verifiedEmail + session_policy).
194
- const runtimeSettings = await getRuntimeSettings(ctx);
201
+ const runtimeSettings = await resolveRuntimeSettingsOrNoop(ctx);
195
202
  // Maintenance mode: verifica se a conta que tenta logar é admin.
196
203
  // Admins CONTINUAM podendo logar (senão o operador se tranca fora).
197
204
  // A verificação de role acontece APÓS as credenciais (senha correta primeiro).
@@ -411,7 +418,7 @@ export default class AuthInteractionController {
411
418
  }
412
419
  const { code, recoveryCode } = ctx.request.only(['code', 'recoveryCode']);
413
420
  // Resolve OTP lockout settings (fail-safe).
414
- const runtimeForOtp = await getRuntimeSettings(ctx);
421
+ const runtimeForOtp = await resolveRuntimeSettingsOrNoop(ctx);
415
422
  const otpLockoutCfg = await resolveEffectiveOtpLockout(runtimeForOtp);
416
423
  const otpLockout = createOtpLockout(otpLockoutCfg, ctx.logger);
417
424
  // Verifica se o fator OTP está travado ANTES de tentar verificar.
@@ -572,7 +579,7 @@ export default class AuthInteractionController {
572
579
  row.passwordResetExpiresAt = DateTime.now().plus({ hours: unlockTtlHours });
573
580
  await row.save();
574
581
  }
575
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
582
+ const origin = authkitOrigin(cfg);
576
583
  const unlockUrl = `${origin}/auth/otp-unlock/${encodeURIComponent(raw)}`;
577
584
  if (cfg.mail?.onOtpUnlock) {
578
585
  await cfg.mail.onOtpUnlock({ email: account.email, unlockUrl, token: raw }).catch(() => { });
@@ -655,7 +662,7 @@ export default class AuthInteractionController {
655
662
  clientId,
656
663
  });
657
664
  }
658
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
665
+ const origin = authkitOrigin(cfg);
659
666
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
660
667
  if (cfg.mail?.onMagicLink) {
661
668
  await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code, channel });
@@ -710,7 +717,7 @@ export default class AuthInteractionController {
710
717
  }
711
718
  // E-mail não verificado (LGPD/compliance): mesmo com o link válido, não
712
719
  // materializa a sessão se a política exige verificação. Volta ao login com erro.
713
- const magicLinkRuntimeSettings = await getRuntimeSettings(ctx);
720
+ const magicLinkRuntimeSettings = await resolveRuntimeSettingsOrNoop(ctx);
714
721
  if (await isEmailUnverifiedBlock(cfg, acc.id, magicLinkRuntimeSettings)) {
715
722
  await cfg.audit?.record({
716
723
  type: 'login.failure',
@@ -732,6 +739,39 @@ export default class AuthInteractionController {
732
739
  error: translate(cfg.messages, 'errors.email_unverified'),
733
740
  });
734
741
  }
742
+ // Status da conta (disabled/expired): mesmo gate de `attemptPasswordLogin`,
743
+ // agora aplicado ao magic link (o botão "desabilitar" no admin console não
744
+ // tinha efeito nenhum aqui antes desta checagem). Emite o MESMO evento
745
+ // `login.failure`/`account.expired_login_blocked` que o fluxo de senha
746
+ // (auditoria uniforme entre fluxos).
747
+ //
748
+ // `passwordless: true` — este fluxo não usa senha e não tem como levar o
749
+ // usuário à troca obrigatória: o único caminho até `step: 'password_expired'`
750
+ // passa por `attemptPasswordLogin`, que exige a senha CORRETA antes de
751
+ // classificar a expiração. Uma conta que nunca definiu senha
752
+ // (`password_changed_at` NULL, que `isPasswordExpired` trata como vencida)
753
+ // só poderia pedir outro magic link e cair no mesmo bloqueio. Ver
754
+ // `LoginAllowedInput.passwordless`.
755
+ const magicLinkStatusGate = await assertLoginAllowed(cfg, acc.id, {
756
+ email: acc.email,
757
+ ip,
758
+ clientId,
759
+ settings: magicLinkRuntimeSettings,
760
+ passwordless: true,
761
+ });
762
+ if (!magicLinkStatusGate.allowed) {
763
+ const render = cfg.render;
764
+ return render(ctx, 'login', {
765
+ ...(await this.#loginMethods(ctx, cfg)),
766
+ uid,
767
+ csrfToken: ctx.request.csrfToken,
768
+ step: 'password',
769
+ email: acc.email,
770
+ account: null,
771
+ brand: brandFor(cfg.branding, clientId ?? undefined, undefined),
772
+ error: translate(cfg.messages, accountStatusErrorKey(magicLinkStatusGate.reason)),
773
+ });
774
+ }
735
775
  await notifyLoginSuccess(ctx, cfg, {
736
776
  accountId: acc.id,
737
777
  email: acc.email,
@@ -791,7 +831,7 @@ export default class AuthInteractionController {
791
831
  if (result.status === 'ok') {
792
832
  // E-mail não verificado (LGPD): mesmo com código válido, não materializa a
793
833
  // sessão se a política exige verificação. Espelha o magicLinkConsume.
794
- const runtimeSettings = await getRuntimeSettings(ctx);
834
+ const runtimeSettings = await resolveRuntimeSettingsOrNoop(ctx);
795
835
  if (await isEmailUnverifiedBlock(cfg, result.account.id, runtimeSettings)) {
796
836
  await cfg.audit?.record({
797
837
  type: 'login.failure',
@@ -812,6 +852,32 @@ export default class AuthInteractionController {
812
852
  error: translate(cfg.messages, 'errors.email_unverified'),
813
853
  });
814
854
  }
855
+ // Status da conta (disabled/expired): mesmo gate de `attemptPasswordLogin`,
856
+ // agora aplicado ao login por OTP. Emite o MESMO evento de auditoria que o
857
+ // fluxo de senha (uniforme entre fluxos).
858
+ //
859
+ // `passwordless: true` — mesmo raciocínio do magic link (o OTP é o outro
860
+ // metade do mesmo e-mail): fluxo sem senha e sem passo de troca
861
+ // obrigatória alcançável. Ver `LoginAllowedInput.passwordless`.
862
+ const otpStatusGate = await assertLoginAllowed(cfg, result.account.id, {
863
+ email: result.account.email,
864
+ ip,
865
+ clientId,
866
+ settings: runtimeSettings,
867
+ passwordless: true,
868
+ });
869
+ if (!otpStatusGate.allowed) {
870
+ return render(ctx, 'login', {
871
+ ...(await this.#loginMethods(ctx, cfg)),
872
+ uid,
873
+ csrfToken: ctx.request.csrfToken,
874
+ step: 'password',
875
+ email: result.account.email,
876
+ account: null,
877
+ brand,
878
+ error: translate(cfg.messages, accountStatusErrorKey(otpStatusGate.reason)),
879
+ });
880
+ }
815
881
  await cfg.audit?.record({
816
882
  type: 'login.otp_verified',
817
883
  accountId: result.account.id,
@@ -988,7 +1054,7 @@ export default class AuthInteractionController {
988
1054
  // E-mail não verificado (LGPD/compliance): bloqueia mesmo com a passkey válida.
989
1055
  // Relevante sobretudo no passkey-first (a etapa de senha — que também checa —
990
1056
  // não rodou). Volta à tela de desafio com o erro.
991
- const passkeyRuntimeSettings = await getRuntimeSettings(ctx);
1057
+ const passkeyRuntimeSettings = await resolveRuntimeSettingsOrNoop(ctx);
992
1058
  if (await isEmailUnverifiedBlock(cfg, accountId, passkeyRuntimeSettings)) {
993
1059
  await cfg.audit?.record({
994
1060
  type: 'login.failure',
@@ -1007,6 +1073,36 @@ export default class AuthInteractionController {
1007
1073
  trustedDeviceDays: cfg.trustedDevices.days,
1008
1074
  });
1009
1075
  }
1076
+ // Status da conta (disabled/expired): mesmo gate de `attemptPasswordLogin`,
1077
+ // agora aplicado ao passkey (cobre sobretudo o passkey-first, que nunca passa
1078
+ // pelo fluxo de senha). Emite o MESMO evento de auditoria que os outros fluxos.
1079
+ //
1080
+ // `passwordless: true` — no passkey-first é o dead-end mais duro dos três:
1081
+ // a view de erro é a `mfa-challenge`, que só oferece o desafio da passkey
1082
+ // (nem campo de senha nem os outros métodos de login), então uma conta sem
1083
+ // senha ficaria trancada sem NENHUMA ação possível. No modo MFA (pós-senha)
1084
+ // a flag é inócua: `attemptPasswordLogin` já rodou a checagem de senha
1085
+ // vencida e desviou para `step: 'password_expired'` antes de chegar aqui.
1086
+ // Ver `LoginAllowedInput.passwordless`.
1087
+ const passkeyAccount = await cfg.accountStore.findById(accountId);
1088
+ const passkeyStatusGate = await assertLoginAllowed(cfg, accountId, {
1089
+ email: passkeyAccount?.email ?? '',
1090
+ ip,
1091
+ clientId,
1092
+ settings: passkeyRuntimeSettings,
1093
+ passwordless: true,
1094
+ });
1095
+ if (!passkeyStatusGate.allowed) {
1096
+ return render(ctx, 'mfa-challenge', {
1097
+ uid: ctx.request.param('uid'),
1098
+ csrfToken: ctx.request.csrfToken,
1099
+ error: translate(cfg.messages, accountStatusErrorKey(passkeyStatusGate.reason)),
1100
+ brand,
1101
+ passkeyAvailable: await this.hasPasskeys(cfg, accountId),
1102
+ trustedDevicesEnabled: cfg.trustedDevices.enabled,
1103
+ trustedDeviceDays: cfg.trustedDevices.days,
1104
+ });
1105
+ }
1010
1106
  // Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
1011
1107
  await this.maybeTrustDevice(ctx, cfg, accountId);
1012
1108
  ctx.session.forget(MFA_PENDING_KEY);
@@ -1177,7 +1273,7 @@ export default class AuthInteractionController {
1177
1273
  return render(ctx, 'otp-unlock', { ok: false, expired: true });
1178
1274
  }
1179
1275
  // Token válido: zera o lock OTP + limpa o token do DB.
1180
- const runtimeForOtp = await getRuntimeSettings(ctx);
1276
+ const runtimeForOtp = await resolveRuntimeSettingsOrNoop(ctx);
1181
1277
  const otpLockoutCfg = await resolveEffectiveOtpLockout(runtimeForOtp);
1182
1278
  const otpLockout = createOtpLockout(otpLockoutCfg, ctx.logger);
1183
1279
  await otpLockout.unlock(row.id);
@@ -6,6 +6,7 @@ import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protec
6
6
  import { brandFor } from '../branding.js';
7
7
  import { sendEmailVerificationEmail, sendMagicLinkEmail, sendPasswordResetEmail, } from '../default_mailer.js';
8
8
  import { translate } from '../i18n.js';
9
+ import { authkitOrigin } from '../origin.js';
9
10
  import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
10
11
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, } from '../runtime_toggles.js';
11
12
  import { dispatchSecurityNotice } from '../security_notice_service.js';
@@ -187,7 +188,7 @@ export default class AuthRegistrationController {
187
188
  email: data.email,
188
189
  ip: ctx.request.ip?.() ?? null,
189
190
  });
190
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
191
+ const origin = authkitOrigin(cfg);
191
192
  const verifyUrl = `${origin}/auth/verify-email?token=${issued.token}`;
192
193
  // Hook do config tem prioridade (override); senão usa o mailer default do host.
193
194
  if (cfg.mail?.onEmailVerification) {
@@ -234,7 +235,7 @@ export default class AuthRegistrationController {
234
235
  // Emite + envia o magic link (mesma construção do login por magic link).
235
236
  const issued = await accountStore.issueMagicLinkToken(data.email);
236
237
  if (issued) {
237
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
238
+ const origin = authkitOrigin(cfg);
238
239
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
239
240
  if (cfg.mail?.onMagicLink) {
240
241
  await cfg.mail.onMagicLink({ email: data.email, magicUrl, token: issued.token });
@@ -328,7 +329,7 @@ export default class AuthRegistrationController {
328
329
  email,
329
330
  ip: ctx.request.ip?.() ?? null,
330
331
  });
331
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
332
+ const origin = authkitOrigin(cfg);
332
333
  const url = `${origin}/auth/reset-password?token=${result.token}`;
333
334
  // Hook do config tem prioridade (override); senão usa o mailer default do host.
334
335
  if (cfg.mail?.onPasswordReset) {
@@ -430,7 +431,7 @@ export default class AuthRegistrationController {
430
431
  kind: 'password_changed',
431
432
  ip: ctx.request.ip?.() ?? null,
432
433
  timestamp: new Date().toISOString(),
433
- }, cfg.mail, cfg.audit);
434
+ }, cfg.mail, cfg.audit, cfg);
434
435
  }
435
436
  return render(ctx, 'reset', {
436
437
  token: '',
@@ -1,6 +1,8 @@
1
1
  import '../augmentations.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { supportsProviderIdentity } from '../../accounts/account_store.js';
4
+ import { assertLoginAllowed } from '../login_attempt.js';
5
+ import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
4
6
  const UID_SESSION_KEY = 'authkit_social_uid';
5
7
  /**
6
8
  * `AllyService.use()` é tipado contra a interface `SocialProviders`, que só é
@@ -81,6 +83,41 @@ export default class AuthSocialController {
81
83
  });
82
84
  user = created;
83
85
  }
86
+ // Status da conta: mesmo gate compartilhado dos demais fluxos de login. O
87
+ // callback já voltou do provider OAuth — não há passo de formulário para
88
+ // re-renderizar um erro, então seguimos a convenção deste controller e
89
+ // redirecionamos de volta ao login.
90
+ //
91
+ // O QUE ESTE CAMINHO APLICA:
92
+ // - `disabled` → recusa (evento `login.failure` com `reason: 'disabled'`).
93
+ // - `account_expiration` (inatividade) → recusa (evento
94
+ // `account.expired_login_blocked`). Isto exige `settings`: sem eles,
95
+ // `assertAccountNotExpired` deixa AMBAS as checagens de expiração como
96
+ // no-op, e era exatamente esse o bug — uma política de controle de acesso
97
+ // valendo em todo lugar menos no "Continue with Google".
98
+ //
99
+ // O QUE ESTE CAMINHO **NÃO** APLICA (`passwordless: true`):
100
+ // - `password_expiration`. Este é um fluxo SEM senha: a conta pode nunca ter
101
+ // definido uma (login social puro → `password_changed_at` NULL, que
102
+ // `isPasswordExpired` trata como "vencida"), e este controller não tem um
103
+ // passo de troca obrigatória para onde mandar o usuário — só sabe
104
+ // redirecionar de volta ao login. Aplicar a checagem aqui trancaria
105
+ // permanentemente todo usuário social-only assim que o operador ligasse a
106
+ // política, devolvendo-o a uma tela sem senha nenhuma que pudesse trocar.
107
+ //
108
+ // `resolveRuntimeSettingsOrNoop` degrada para um RuntimeSettings no-op quando
109
+ // não há tabela de settings — host sem a migração continua logando por social.
110
+ const settings = await resolveRuntimeSettingsOrNoop(ctx);
111
+ const statusGate = await assertLoginAllowed(cfg, user.id, {
112
+ email: user.email,
113
+ ip: ctx.request.ip?.() ?? null,
114
+ settings,
115
+ passwordless: true,
116
+ });
117
+ if (!statusGate.allowed) {
118
+ ctx.session.forget(UID_SESSION_KEY);
119
+ return ctx.response.redirect(backToLogin);
120
+ }
84
121
  ctx.session.forget(UID_SESSION_KEY);
85
122
  // Conclui a interaction OIDC para este usuário (escreve o 303 de volta ao authorize).
86
123
  // O cookie de interaction (path '/') sobrevive ao round-trip do provider OAuth.
@@ -1,4 +1,5 @@
1
1
  import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { ResolvedServerConfig } from '../define_config.js';
2
3
  /**
3
4
  * Envio de e-mail default do host-kit, usando o mailer `default` do host via
4
5
  * `@adonisjs/mail`. Permite que o dev não escreva nenhum hook: por padrão os
@@ -40,7 +41,7 @@ export declare function sendEmailChangeConfirmationEmail(ctx: HttpContext, data:
40
41
  * Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
41
42
  * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
42
43
  */
43
- export declare function sendNewLoginEmail(ctx: HttpContext, data: {
44
+ export declare function sendNewLoginEmail(ctx: HttpContext, cfg: ResolvedServerConfig, data: {
44
45
  email: string;
45
46
  ip: string;
46
47
  when: string;
@@ -49,7 +50,7 @@ export declare function sendNewLoginEmail(ctx: HttpContext, data: {
49
50
  * Envia o e-mail de alerta de NOVO DISPOSITIVO (login sem cookie de dispositivo
50
51
  * confiável). Best-effort: no fallback (sem mail) loga o evento; nunca lança.
51
52
  */
52
- export declare function sendNewDeviceLoginEmail(ctx: HttpContext, data: {
53
+ export declare function sendNewDeviceLoginEmail(ctx: HttpContext, cfg: ResolvedServerConfig, data: {
53
54
  email: string;
54
55
  ip?: string | null;
55
56
  userAgent?: string | null;
@@ -65,12 +66,42 @@ export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
65
66
  code?: string;
66
67
  channel?: 'code' | 'link';
67
68
  }): Promise<void>;
69
+ /**
70
+ * Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo) pelo mailer default do host.
71
+ *
72
+ * POR QUE EXISTE. `sudoMethods.magicLink()` dependia EXCLUSIVAMENTE do hook
73
+ * `mail.onSudoLink`, e sem ele o método se declarava indisponível. Num host
74
+ * passwordless isso fechava o deadlock: sem senha, sem passkey e sem hook, a
75
+ * tela `/account/confirm` não tinha um único método satisfazível — e o usuário
76
+ * ficava trancado fora de exportar/excluir os próprios dados, do MFA, dos PATs
77
+ * e da troca de e-mail, inclusive do cadastro de passkey que destravaria tudo.
78
+ *
79
+ * A postura aqui é a MESMA de todos os outros e-mails da lib (reset de senha,
80
+ * verificação, magic link de login, avisos de segurança): o hook do host, quando
81
+ * existe, tem prioridade; sem hook, o próprio host-kit envia pelo mailer default
82
+ * (`@adonisjs/mail`) com branding e i18n; sem mailer, loga o link (dev).
83
+ *
84
+ * O TOKEN continua sendo o de sudo — emitido e verificado em
85
+ * `sudo/methods/magic_link.ts`, hasheado na sessão que pediu, de uso único,
86
+ * 5 min, vinculado ao `accountId` emissor. Isto aqui é só ENTREGA; nada é
87
+ * compartilhado com o token de login.
88
+ *
89
+ * @returns `true` quando o e-mail foi entregue ao mailer (ou logado no fallback
90
+ * de dev) e `false` quando o envio falhou de verdade. O chamador PRECISA disso:
91
+ * ao contrário dos outros e-mails da lib, um link de sudo que não saiu tem de
92
+ * apagar o pendente da sessão e recusar — flashar "link enviado" deixaria o
93
+ * usuário esperando um e-mail que nunca vem, na tela que já é o gargalo dele.
94
+ */
95
+ export declare function sendSudoLinkEmail(ctx: HttpContext, data: {
96
+ email: string;
97
+ sudoUrl: string;
98
+ }): Promise<boolean>;
68
99
  /**
69
100
  * Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de
70
101
  * e-mail é solicitada ("Alguém pediu troca para X — se não foi você...").
71
102
  * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
72
103
  */
73
- export declare function sendEmailChangeNoticeEmail(ctx: HttpContext, data: {
104
+ export declare function sendEmailChangeNoticeEmail(ctx: HttpContext, cfg: ResolvedServerConfig, data: {
74
105
  email: string;
75
106
  newEmail: string;
76
107
  }): Promise<void>;
@@ -79,7 +110,7 @@ export declare function sendEmailChangeNoticeEmail(ctx: HttpContext, data: {
79
110
  * troca foi concluída ("Seu e-mail foi alterado de A para B — se não foi você...").
80
111
  * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
81
112
  */
82
- export declare function sendEmailChangedCompletedEmail(ctx: HttpContext, data: {
113
+ export declare function sendEmailChangedCompletedEmail(ctx: HttpContext, cfg: ResolvedServerConfig, data: {
83
114
  oldEmail: string;
84
115
  newEmail: string;
85
116
  }): Promise<void>;
@@ -88,7 +119,7 @@ export declare function sendEmailChangedCompletedEmail(ctx: HttpContext, data: {
88
119
  * MFA habilitado/desabilitado, passkey adicionada/removida, e-mail alterado).
89
120
  * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
90
121
  */
91
- export declare function sendSecurityNoticeEmail(ctx: HttpContext, data: {
122
+ export declare function sendSecurityNoticeEmail(ctx: HttpContext, cfg: ResolvedServerConfig, data: {
92
123
  email: string;
93
124
  kind: 'password_changed' | 'mfa_enabled' | 'mfa_disabled' | 'passkey_added' | 'passkey_removed' | 'email_changed';
94
125
  timestamp: string;