@adonis-agora/authkit-server 0.69.0 → 0.71.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 (48) hide show
  1. package/build/commands/commands.json +18 -0
  2. package/build/commands/normalize_emails.d.ts +21 -0
  3. package/build/commands/normalize_emails.js +106 -0
  4. package/build/index.d.ts +4 -3
  5. package/build/index.js +6 -2
  6. package/build/src/accounts/account_store.d.ts +65 -1
  7. package/build/src/accounts/account_store.js +24 -0
  8. package/build/src/accounts/lucid_store/core.d.ts +2 -2
  9. package/build/src/accounts/lucid_store/core.js +16 -0
  10. package/build/src/accounts/lucid_store/mfa.js +22 -0
  11. package/build/src/audit/audit_sink.d.ts +1 -1
  12. package/build/src/audit/audit_sink.js +4 -0
  13. package/build/src/commands/import_users.js +7 -2
  14. package/build/src/commands/normalize_emails.d.ts +78 -0
  15. package/build/src/commands/normalize_emails.js +129 -0
  16. package/build/src/host/account_api/account_api_controller.d.ts +2 -0
  17. package/build/src/host/account_api/account_api_controller.js +22 -3
  18. package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
  19. package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
  20. package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
  21. package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
  22. package/build/src/host/account_lockout.js +7 -2
  23. package/build/src/host/admin_api/admin_users_service.js +11 -3
  24. package/build/src/host/admin_api/dto.d.ts +1 -1
  25. package/build/src/host/admin_validators.d.ts +2 -2
  26. package/build/src/host/admin_validators.js +3 -2
  27. package/build/src/host/controllers/account_mfa_controller.js +1 -2
  28. package/build/src/host/controllers/account_orgs_controller.js +4 -18
  29. package/build/src/host/controllers/account_security_controller.js +3 -1
  30. package/build/src/host/controllers/account_session_controller.js +7 -5
  31. package/build/src/host/controllers/interaction_controller.d.ts +10 -0
  32. package/build/src/host/controllers/interaction_controller.js +46 -14
  33. package/build/src/host/controllers/registration_controller.js +15 -5
  34. package/build/src/host/controllers/social_controller.js +8 -1
  35. package/build/src/host/email_identifier.d.ts +29 -0
  36. package/build/src/host/email_identifier.js +31 -0
  37. package/build/src/host/org_policy.d.ts +26 -0
  38. package/build/src/host/org_policy.js +35 -0
  39. package/build/src/host/passkey_registration_challenge.d.ts +12 -0
  40. package/build/src/host/passkey_registration_challenge.js +12 -0
  41. package/build/src/host/register_auth_host.js +51 -0
  42. package/build/src/host/sudo_mode.d.ts +17 -0
  43. package/build/src/host/sudo_mode.js +31 -12
  44. package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
  45. package/build/src/host/ui-dist/index.html +1 -1
  46. package/build/src/host/validators.d.ts +5 -5
  47. package/build/src/host/validators.js +17 -5
  48. package/package.json +2 -2
@@ -48,7 +48,7 @@ export declare function grantDto(grant: AdminGrant): {
48
48
  };
49
49
  export declare function auditDto(event: StoredAuditEvent): {
50
50
  id: string;
51
- type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "impersonation.stopped" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
51
+ type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "impersonation.stopped" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "mfa.recovery_codes_regenerated" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
52
52
  accountId: string | null;
53
53
  email: string | null;
54
54
  clientId: string | null;
@@ -69,7 +69,7 @@ export declare function clientPartialInput(v: ClientInputFields): Partial<Client
69
69
  * `minLength` — seria duplicar/contornar a policy configurável do projeto.
70
70
  */
71
71
  export declare const adminUserCreateValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
72
- email: import("@vinejs/vine").VineString;
72
+ email: import("@vinejs/vine/schema/base/literal").TransformModifier<import("@vinejs/vine").VineString, string>;
73
73
  name: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
74
74
  password: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
75
75
  invite: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineBoolean>;
@@ -179,7 +179,7 @@ export declare const orgMemberRoleValidator: import("@vinejs/vine").VineValidato
179
179
  }>, Record<string, any> | undefined>;
180
180
  /** Criação de convite: e-mail obrigatório e validado; role opcional (default `member`). */
181
181
  export declare const orgInvitationValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
182
- email: import("@vinejs/vine").VineString;
182
+ email: import("@vinejs/vine/schema/base/literal").TransformModifier<import("@vinejs/vine").VineString, string>;
183
183
  role: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
184
184
  }, {
185
185
  email: string;
@@ -1,4 +1,5 @@
1
1
  import vine from '@vinejs/vine';
2
+ import { normalizeEmailIdentifier } from './email_identifier.js';
2
3
  /**
3
4
  * Validators VineJS dos recursos administrativos (Admin REST API + console admin).
4
5
  *
@@ -123,7 +124,7 @@ export function clientPartialInput(v) {
123
124
  * `minLength` — seria duplicar/contornar a policy configurável do projeto.
124
125
  */
125
126
  export const adminUserCreateValidator = vine.compile(vine.object({
126
- email: vine.string().trim().email(),
127
+ email: vine.string().trim().email().transform(normalizeEmailIdentifier),
127
128
  name: vine.string().trim().maxLength(255).optional(),
128
129
  password: vine.string().maxLength(255).optional(),
129
130
  invite: vine.boolean().optional(),
@@ -162,7 +163,7 @@ export const orgMemberRoleValidator = vine.compile(vine.object({
162
163
  }));
163
164
  /** Criação de convite: e-mail obrigatório e validado; role opcional (default `member`). */
164
165
  export const orgInvitationValidator = vine.compile(vine.object({
165
- email: vine.string().trim().email(),
166
+ email: vine.string().trim().email().transform(normalizeEmailIdentifier),
166
167
  role: vine.string().trim().minLength(1).optional(),
167
168
  }));
168
169
  // ─── Catálogo de roles ──────────────────────────────────────────────────────
@@ -4,11 +4,10 @@ import { supportsPasskeys } from '../../accounts/account_store.js';
4
4
  import { accountPath } from '../account_paths.js';
5
5
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
6
6
  import { translate } from '../i18n.js';
7
+ import { PASSKEY_REG_CHALLENGE_KEY } from '../passkey_registration_challenge.js';
7
8
  import { resolveRuntimeSettings } from '../runtime_settings.js';
8
9
  import { dispatchSecurityNotice } from '../security_notice_service.js';
9
10
  import { requireSudo } from '../sudo_mode.js';
10
- /** Desafio WebAuthn pendente (registro) guardado na sessão entre begin/finish. */
11
- const PASSKEY_REG_CHALLENGE_KEY = 'authkit_passkey_reg_challenge';
12
11
  /**
13
12
  * `true` quando a requisição é uma NAVEGAÇÃO (form/browser) e não um XHR/fetch:
14
13
  * o cliente aceita `text/html` e NÃO pede explicitamente `application/json`.
@@ -6,26 +6,12 @@ import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
6
6
  import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
7
7
  import { sendOrgInvitationEmail } from '../default_mailer.js';
8
8
  import { ensureConsoleSession } from '../idp_session_bridge.js';
9
+ // Política efetiva compartilhada com o espelho JSON (`account_orgs_api_controller`):
10
+ // ponto de verdade único, para as duas superfícies nunca divergirem.
11
+ import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
9
12
  import { authkitOrigin } from '../origin.js';
10
13
  import { resolveRuntimeSettings } from '../runtime_settings.js';
11
- import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy, } from '../runtime_toggles.js';
12
- /** Defaults estáticos da política de org (config do host) — o fallback da setting. */
13
- function orgPolicyDefaults(cfg) {
14
- return {
15
- roles: cfg.organizations.roles,
16
- allowSelfCreate: cfg.organizations.allowSelfCreate,
17
- invitationTtlHours: cfg.organizations.invitationTtlHours,
18
- };
19
- }
20
- /**
21
- * Política EFETIVA de organizações: setting `organizations_policy` (org → global)
22
- * → `config.organizations` → default da lib. É o que a doc promete; antes este
23
- * controller lia só o config estático, e a setting não tinha efeito aqui.
24
- */
25
- async function effectiveOrgPolicy(ctx, cfg, orgId) {
26
- const settings = await resolveRuntimeSettings(ctx);
27
- return resolveEffectiveOrganizationsPolicy(settings, orgPolicyDefaults(cfg), orgId);
28
- }
14
+ import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy } from '../runtime_toggles.js';
29
15
  /**
30
16
  * Console de conta — Organizations. Server-rendered, padrão dos outros controllers
31
17
  * de conta (account_tokens_controller, account_security_controller, etc.).
@@ -10,6 +10,7 @@ import { AdminSessionsService } from '../admin_sessions_service.js';
10
10
  import { syncAdonisAuthLogout } from '../adonis_auth_sync.js';
11
11
  import { AvatarUploadError, isAvatarUploadSupported, storeAvatar } from '../avatar_storage.js';
12
12
  import { sendEmailChangeConfirmationEmail, sendEmailChangedCompletedEmail, sendEmailChangeNoticeEmail, } from '../default_mailer.js';
13
+ import { normalizeEmailIdentifier } from '../email_identifier.js';
13
14
  import { translate } from '../i18n.js';
14
15
  import { authkitOrigin } from '../origin.js';
15
16
  import { resolveRuntimeSettings } from '../runtime_settings.js';
@@ -164,7 +165,8 @@ export default class AccountSecurityController {
164
165
  confirmed = !!(await store.verifyCredentials(account.email, currentPassword));
165
166
  }
166
167
  if (!confirmed && confirmEmail) {
167
- confirmed = confirmEmail.trim().toLowerCase() === account.email.toLowerCase();
168
+ confirmed =
169
+ normalizeEmailIdentifier(confirmEmail) === normalizeEmailIdentifier(account.email);
168
170
  }
169
171
  if (!confirmed) {
170
172
  ctx.session.flash('deleteError', translate(cfg.messages, 'account.delete.invalid_confirmation'));
@@ -3,6 +3,7 @@ import { accountHome } from '../account_home.js';
3
3
  import { getAccountLoginUrl } from '../account_login_url.js';
4
4
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
5
5
  import { syncAdonisAuthLogin, syncAdonisAuthLogout } from '../adonis_auth_sync.js';
6
+ import { normalizeEmailIdentifier } from '../email_identifier.js';
6
7
  import { translate } from '../i18n.js';
7
8
  import { endBridgedIdpSession } from '../idp_session_bridge.js';
8
9
  import { attemptPasswordLogin } from '../login_attempt.js';
@@ -60,10 +61,6 @@ export default class AccountSessionController {
60
61
  const cfg = service.config;
61
62
  const render = cfg.render;
62
63
  const { email: rawEmail, password } = ctx.request.only(['email', 'password']);
63
- // L6: normaliza o e-mail (trim + lowercase) ANTES de usar — garante que o
64
- // lookup, o lockout (keyed por email) e a auditoria usem a forma canônica,
65
- // independente do casing/espaços digitados.
66
- const email = typeof rawEmail === 'string' ? rawEmail.trim().toLowerCase() : rawEmail;
67
64
  const ip = ctx.request.ip?.() ?? null;
68
65
  // Lê e valida o return_to do corpo do formulário (hidden input) — nunca confiar sem revalidar.
69
66
  const rawReturnTo = ctx.request.input?.('return_to');
@@ -71,6 +68,11 @@ export default class AccountSessionController {
71
68
  // Verificação + lockout + auditoria de falha centralizados (sem clientId no console).
72
69
  // M1: passa `settings` p/ o lockout (e verified-email/expiração) runtime valerem aqui também.
73
70
  const settings = await resolveRuntimeSettings(ctx);
71
+ // L6: normaliza (trim + lowercase, a MESMA função do cadastro e do passo de
72
+ // identificador do login OIDC) para que o lookup, o lockout (keyed por
73
+ // e-mail) e a auditoria usem a forma canônica, independente do casing/espaços
74
+ // digitados. A tela de erro é a mesma (credencial inválida), ache conta ou não.
75
+ const email = normalizeEmailIdentifier(rawEmail);
74
76
  const result = await attemptPasswordLogin(cfg, {
75
77
  email,
76
78
  password,
@@ -106,7 +108,7 @@ export default class AccountSessionController {
106
108
  // Opt-in: sincroniza ctx.auth.use(cfg.adonisAuth.guard) com a mesma conta —
107
109
  // no-op sem `adonisAuth` configurado ou sem @adonisjs/auth inicializado.
108
110
  await syncAdonisAuthLogin(ctx, cfg, acc);
109
- await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip });
111
+ await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email: acc.email, ip });
110
112
  // Redireciona pro destino original (validado), ou cai no accountHome configurado.
111
113
  return ctx.response.redirect(returnTo ?? accountHome(cfg));
112
114
  }
@@ -7,6 +7,16 @@ export default class AuthInteractionController {
7
7
  * POST /auth/interaction/:uid/identifier
8
8
  * Step 1: receive email, store in session, redirect to step 2.
9
9
  * ENUMERATION-SAFE: always advances regardless of whether the email exists.
10
+ *
11
+ * Normaliza o e-mail com a MESMA regra do cadastro (`normalizeEmailIdentifier`:
12
+ * `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
13
+ * no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
14
+ *
15
+ * À PROVA DE ENUMERAÇÃO: o passo NÃO toca no account store — só normaliza e
16
+ * guarda. A resposta é o MESMO redirect incondicional, exista a conta ou não,
17
+ * e não há busca alguma aqui para diferenciar os dois casos nem no tempo de
18
+ * resposta. Quem decide se a conta existe é o passo 2, que renderiza a mesma
19
+ * tela nos dois casos.
10
20
  */
11
21
  identifier(ctx: HttpContext): Promise<void>;
12
22
  /**
@@ -4,6 +4,7 @@ import { AdminSessionsService } from '../admin_sessions_service.js';
4
4
  import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
5
5
  import { brandFor, isFirstParty } from '../branding.js';
6
6
  import { sendMagicLinkEmail, sendOtpUnlockEmail } from '../default_mailer.js';
7
+ import { normalizeEmailIdentifier } from '../email_identifier.js';
7
8
  import { translate } from '../i18n.js';
8
9
  import { assertLoginAllowed, attemptPasswordLogin, isEmailUnverifiedBlock, } from '../login_attempt.js';
9
10
  import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
@@ -36,11 +37,26 @@ function accountStatusErrorKey(reason) {
36
37
  return 'login.password_expired_intro';
37
38
  }
38
39
  }
40
+ /**
41
+ * E-mail DIGITADO no passo de identificador, já normalizado (`trim` +
42
+ * `toLowerCase`). É o que a tela SEMPRE mostra — nunca o endereço gravado —,
43
+ * para que a canonicalização não vire um oráculo de "esta conta existe".
44
+ */
39
45
  const SESSION_KEY = 'authkit_login_email';
40
46
  /** accountId aguardando o 2º fator depois da senha verificada. */
41
47
  const MFA_PENDING_KEY = 'authkit_mfa_pending';
42
48
  /** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
43
49
  const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
50
+ /**
51
+ * Esquece o e-mail do login.
52
+ *
53
+ * Helper de UMA linha de propósito: os fluxos que encerram o passo de login
54
+ * (sucesso, "usar outro e-mail", expiração) chamam ISTO em vez de `forget` da
55
+ * chave crua, para que nenhum caminho deixe o e-mail pendurado na sessão.
56
+ */
57
+ function forgetLoginEmail(ctx) {
58
+ ctx.session.forget(SESSION_KEY);
59
+ }
44
60
  export default class AuthInteractionController {
45
61
  /**
46
62
  * Métodos de login efetivos (com os pins do `cfg.authMethods`) para QUALQUER render do
@@ -215,10 +231,22 @@ export default class AuthInteractionController {
215
231
  * POST /auth/interaction/:uid/identifier
216
232
  * Step 1: receive email, store in session, redirect to step 2.
217
233
  * ENUMERATION-SAFE: always advances regardless of whether the email exists.
234
+ *
235
+ * Normaliza o e-mail com a MESMA regra do cadastro (`normalizeEmailIdentifier`:
236
+ * `trim` + `toLowerCase`) — antes disto o valor cru ia para a sessão e a busca
237
+ * no store era por igualdade exata, então nem `Davi@x.com` achava `davi@x.com`.
238
+ *
239
+ * À PROVA DE ENUMERAÇÃO: o passo NÃO toca no account store — só normaliza e
240
+ * guarda. A resposta é o MESMO redirect incondicional, exista a conta ou não,
241
+ * e não há busca alguma aqui para diferenciar os dois casos nem no tempo de
242
+ * resposta. Quem decide se a conta existe é o passo 2, que renderiza a mesma
243
+ * tela nos dois casos.
218
244
  */
219
245
  async identifier(ctx) {
220
246
  const { email } = ctx.request.only(['email']);
221
- ctx.session.put(SESSION_KEY, email);
247
+ // `request.only` não valida tipo (`email[]=x` chega array): a normalização
248
+ // devolve string vazia para não-string, e o passo segue com o redirect.
249
+ ctx.session.put(SESSION_KEY, normalizeEmailIdentifier(email));
222
250
  return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
223
251
  }
224
252
  /**
@@ -251,7 +279,10 @@ export default class AuthInteractionController {
251
279
  // credenciais. Fail-safe: erro/timeout no verify do host PERMITE o fluxo.
252
280
  // We use a cfg-compatible object: pass effectiveBotLogin merged into cfg.
253
281
  const cfgWithEffectiveBot = effectiveBotLogin !== cfg.botProtection ? { ...cfg, botProtection: effectiveBotLogin } : cfg;
254
- if (!(await guardBotProtection(ctx, cfgWithEffectiveBot, 'login', { email, clientId }))) {
282
+ if (!(await guardBotProtection(ctx, cfgWithEffectiveBot, 'login', {
283
+ email,
284
+ clientId,
285
+ }))) {
255
286
  const found = await cfg.accountStore.findByEmail(email);
256
287
  const account = found
257
288
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
@@ -407,7 +438,7 @@ export default class AuthInteractionController {
407
438
  clientId,
408
439
  trustedDevice: true,
409
440
  });
410
- ctx.session.forget(SESSION_KEY);
441
+ forgetLoginEmail(ctx);
411
442
  return;
412
443
  }
413
444
  }
@@ -447,7 +478,7 @@ export default class AuthInteractionController {
447
478
  }
448
479
  }
449
480
  await service.interactions.completeLogin(ctx, acc.id, { remember });
450
- await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
481
+ await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email: acc.email, ip, clientId });
451
482
  // single-session: após o completeLogin, revoga sessões que existiam ANTES.
452
483
  // A sessão nova foi criada pelo provider no resume — ela NÃO está em prevSessionIds.
453
484
  if (sessionPolicy.singleSession && prevSessionIds.size > 0) {
@@ -467,7 +498,7 @@ export default class AuthInteractionController {
467
498
  }
468
499
  }
469
500
  // Clean up the session key after a successful login.
470
- ctx.session.forget(SESSION_KEY);
501
+ forgetLoginEmail(ctx);
471
502
  }
472
503
  /**
473
504
  * POST /auth/interaction/:uid/mfa
@@ -554,7 +585,7 @@ export default class AuthInteractionController {
554
585
  await this.maybeTrustDevice(ctx, cfg, accountId);
555
586
  // Finaliza a interaction para o accountId pendente.
556
587
  ctx.session.forget(MFA_PENDING_KEY);
557
- ctx.session.forget(SESSION_KEY);
588
+ forgetLoginEmail(ctx);
558
589
  await notifyLoginSuccess(ctx, cfg, {
559
590
  accountId,
560
591
  ip,
@@ -738,7 +769,7 @@ export default class AuthInteractionController {
738
769
  await cfg.audit?.record({
739
770
  type: 'login.magic_link_sent',
740
771
  accountId: issued.account.id,
741
- email,
772
+ email: issued.account.email,
742
773
  ip,
743
774
  clientId,
744
775
  });
@@ -746,18 +777,19 @@ export default class AuthInteractionController {
746
777
  await cfg.audit?.record({
747
778
  type: 'login.otp_sent',
748
779
  accountId: issued.account.id,
749
- email,
780
+ email: issued.account.email,
750
781
  ip,
751
782
  clientId,
752
783
  });
753
784
  }
754
785
  const origin = authkitOrigin(cfg);
755
786
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
787
+ const to = issued.account.email;
756
788
  if (cfg.mail?.onMagicLink) {
757
- await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code, channel });
789
+ await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token, code, channel });
758
790
  }
759
791
  else {
760
- await sendMagicLinkEmail(ctx, { email, magicUrl, code, channel });
792
+ await sendMagicLinkEmail(ctx, { email: to, magicUrl, code, channel });
761
793
  }
762
794
  }
763
795
  }
@@ -895,7 +927,7 @@ export default class AuthInteractionController {
895
927
  clientId: clientId ?? null,
896
928
  metadata: { method: 'magic_link' },
897
929
  });
898
- ctx.session.forget(SESSION_KEY);
930
+ forgetLoginEmail(ctx);
899
931
  await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
900
932
  }
901
933
  /**
@@ -1008,7 +1040,7 @@ export default class AuthInteractionController {
1008
1040
  clientId: clientId ?? null,
1009
1041
  metadata: { method: 'otp' },
1010
1042
  });
1011
- ctx.session.forget(SESSION_KEY);
1043
+ forgetLoginEmail(ctx);
1012
1044
  return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
1013
1045
  }
1014
1046
  if (result.status === 'locked') {
@@ -1252,7 +1284,7 @@ export default class AuthInteractionController {
1252
1284
  // Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
1253
1285
  await this.maybeTrustDevice(ctx, cfg, accountId);
1254
1286
  ctx.session.forget(MFA_PENDING_KEY);
1255
- ctx.session.forget(SESSION_KEY);
1287
+ forgetLoginEmail(ctx);
1256
1288
  await notifyLoginSuccess(ctx, cfg, {
1257
1289
  accountId,
1258
1290
  ip,
@@ -1268,7 +1300,7 @@ export default class AuthInteractionController {
1268
1300
  * Clears the stored email and redirects back to step 1.
1269
1301
  */
1270
1302
  async switchIdentifier(ctx) {
1271
- ctx.session.forget(SESSION_KEY);
1303
+ forgetLoginEmail(ctx);
1272
1304
  return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
1273
1305
  }
1274
1306
  /**
@@ -118,6 +118,8 @@ export default class AuthRegistrationController {
118
118
  }
119
119
  const data = await ctx.request.validateUsing(signupValidator);
120
120
  const accountStore = cfg.accountStore;
121
+ // Duplicado pela MESMA forma que o login busca (`data.email` já saiu
122
+ // normalizado do validator).
121
123
  const existing = await accountStore.findByEmail(data.email);
122
124
  if (existing) {
123
125
  return render(ctx, 'signup', {
@@ -233,15 +235,18 @@ export default class AuthRegistrationController {
233
235
  });
234
236
  }
235
237
  // Emite + envia o magic link (mesma construção do login por magic link).
238
+ // `data.email` já é a forma normalizada — a mesma sob a qual a conta foi
239
+ // encontrada acima ou acabou de ser criada.
236
240
  const issued = await accountStore.issueMagicLinkToken(data.email);
237
241
  if (issued) {
238
242
  const origin = authkitOrigin(cfg);
239
243
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
244
+ const to = issued.account.email;
240
245
  if (cfg.mail?.onMagicLink) {
241
- await cfg.mail.onMagicLink({ email: data.email, magicUrl, token: issued.token });
246
+ await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
242
247
  }
243
248
  else {
244
- await sendMagicLinkEmail(ctx, { email: data.email, magicUrl });
249
+ await sendMagicLinkEmail(ctx, { email: to, magicUrl });
245
250
  }
246
251
  }
247
252
  // Resposta uniforme: "enviamos um link" (não vaza existência da conta).
@@ -322,21 +327,26 @@ export default class AuthRegistrationController {
322
327
  }
323
328
  const { email } = await ctx.request.validateUsing(forgotPasswordValidator);
324
329
  const accountStore = cfg.accountStore;
330
+ // `email` já saiu normalizado do validator (a MESMA normalização do
331
+ // cadastro e do login). Resposta uniforme: a tela abaixo é a mesma, ache
332
+ // conta ou não.
325
333
  const result = await accountStore.issuePasswordResetToken(email);
326
334
  if (result) {
327
335
  await cfg.audit?.record({
328
336
  type: 'password_reset.issued',
329
- email,
337
+ email: result.account.email,
330
338
  ip: ctx.request.ip?.() ?? null,
331
339
  });
332
340
  const origin = authkitOrigin(cfg);
333
341
  const url = `${origin}/auth/reset-password?token=${result.token}`;
334
342
  // Hook do config tem prioridade (override); senão usa o mailer default do host.
343
+ // O link vai para a caixa postal sob a qual a conta está gravada.
344
+ const to = result.account.email;
335
345
  if (cfg.mail?.onPasswordReset) {
336
- await cfg.mail.onPasswordReset({ email, resetUrl: url, token: result.token });
346
+ await cfg.mail.onPasswordReset({ email: to, resetUrl: url, token: result.token });
337
347
  }
338
348
  else {
339
- await sendPasswordResetEmail(ctx, { email, resetUrl: url });
349
+ await sendPasswordResetEmail(ctx, { email: to, resetUrl: url });
340
350
  }
341
351
  }
342
352
  return render(ctx, 'forgot', {
@@ -1,6 +1,7 @@
1
1
  import '../augmentations.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { supportsLoginMethodsPreference, supportsMagicLink, supportsPasskeys, supportsProviderIdentity, } from '../../accounts/account_store.js';
4
+ import { normalizeEmailIdentifier } from '../email_identifier.js';
4
5
  import { assertLoginAllowed } from '../login_attempt.js';
5
6
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
6
7
  import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
@@ -54,7 +55,10 @@ export default class AuthSocialController {
54
55
  const service = await ctx.containerResolver.make('authkit.server');
55
56
  const cfg = service.config;
56
57
  const store = cfg.accountStore;
57
- const email = profile.email ?? undefined;
58
+ // Normaliza o e-mail do provider com a MESMA regra do cadastro/login: sem
59
+ // isto, um provider que devolve o endereço com maiúsculas criava uma conta
60
+ // que o login (normalizado) não encontrava mais.
61
+ const email = profile.email ? normalizeEmailIdentifier(profile.email) : undefined;
58
62
  // Account linking exige a capacidade de provider-identity (model wired no store).
59
63
  // Ausente → não há como ligar a identidade; volta ao login em vez de quebrar.
60
64
  if (!supportsProviderIdentity(store)) {
@@ -67,6 +71,9 @@ export default class AuthSocialController {
67
71
  // 3. Senão → cria conta nova e liga a identidade.
68
72
  let user = await store.findByProviderIdentity(provider, profile.id);
69
73
  if (!user && email) {
74
+ // Busca pela MESMA forma normalizada que o login usa. Uma conta gravada
75
+ // com outra grafia não é encontrada aqui e a pessoa ganharia uma SEGUNDA
76
+ // conta — por isso `authkit:users:normalize-emails` é passo de upgrade.
70
77
  const byEmail = await store.findByEmail(email);
71
78
  if (byEmail) {
72
79
  await store.linkProviderIdentity({
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Normalização ÚNICA e CONSERVADORA do e-mail usado como identidade da conta.
3
+ *
4
+ * Faz `trim()` + `toLowerCase()` e MAIS NADA. O endereço que a pessoa digitou é a
5
+ * identidade dela: a lib NÃO decide que `a.b@gmail.com` e `ab@gmail.com` são a
6
+ * mesma pessoa, nem descarta o sub-endereço (`+tag`) que ela escolheu usar.
7
+ *
8
+ * POR QUE ISTO EXISTE: até a v0.68 o cadastro validava o e-mail com
9
+ * `.normalizeEmail()` do VineJS (os defaults do validator.js), que para o gmail
10
+ * REMOVE os pontos e o `+tag` do local part. A conta nascia com um endereço
11
+ * DIFERENTE do digitado (`davi.carvalho96@gmail.com` → `davicarvalho96@gmail.com`)
12
+ * enquanto o passo de identificador do login não normalizava NADA e buscava por
13
+ * igualdade exata — resultado: quem tinha ponto ou `+tag` no gmail ficava
14
+ * trancado do lado de fora, e, por o login ser à prova de enumeração, sem
15
+ * nenhuma mensagem de erro. Uma normalização só, usada nos DOIS lados, fecha a
16
+ * assimetria.
17
+ *
18
+ * Use em TODO ponto que grava ou busca a identidade: cadastro (com e sem senha),
19
+ * "esqueci a senha", troca de e-mail, criação por admin, convite de organização,
20
+ * import de usuários, cadastro social e o passo de identificador do login.
21
+ *
22
+ * As contas GRAVADAS antes disto (com o endereço mutilado, ou com maiúsculas
23
+ * vindas de import/convite/provider social) ficam inalcançáveis pelo login, que
24
+ * busca a forma normalizada. Elas pedem MIGRAÇÃO do endereço gravado — é o que o
25
+ * comando `authkit:users:normalize-emails` faz (ver `commands/normalize_emails.ts`).
26
+ * A lib não tenta adivinhar grafias no caminho do login: cada tentativa extra é
27
+ * uma query a mais por e-mail desconhecido, que é justamente o caminho de ataque.
28
+ */
29
+ export declare function normalizeEmailIdentifier(raw: unknown): string;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Normalização ÚNICA e CONSERVADORA do e-mail usado como identidade da conta.
3
+ *
4
+ * Faz `trim()` + `toLowerCase()` e MAIS NADA. O endereço que a pessoa digitou é a
5
+ * identidade dela: a lib NÃO decide que `a.b@gmail.com` e `ab@gmail.com` são a
6
+ * mesma pessoa, nem descarta o sub-endereço (`+tag`) que ela escolheu usar.
7
+ *
8
+ * POR QUE ISTO EXISTE: até a v0.68 o cadastro validava o e-mail com
9
+ * `.normalizeEmail()` do VineJS (os defaults do validator.js), que para o gmail
10
+ * REMOVE os pontos e o `+tag` do local part. A conta nascia com um endereço
11
+ * DIFERENTE do digitado (`davi.carvalho96@gmail.com` → `davicarvalho96@gmail.com`)
12
+ * enquanto o passo de identificador do login não normalizava NADA e buscava por
13
+ * igualdade exata — resultado: quem tinha ponto ou `+tag` no gmail ficava
14
+ * trancado do lado de fora, e, por o login ser à prova de enumeração, sem
15
+ * nenhuma mensagem de erro. Uma normalização só, usada nos DOIS lados, fecha a
16
+ * assimetria.
17
+ *
18
+ * Use em TODO ponto que grava ou busca a identidade: cadastro (com e sem senha),
19
+ * "esqueci a senha", troca de e-mail, criação por admin, convite de organização,
20
+ * import de usuários, cadastro social e o passo de identificador do login.
21
+ *
22
+ * As contas GRAVADAS antes disto (com o endereço mutilado, ou com maiúsculas
23
+ * vindas de import/convite/provider social) ficam inalcançáveis pelo login, que
24
+ * busca a forma normalizada. Elas pedem MIGRAÇÃO do endereço gravado — é o que o
25
+ * comando `authkit:users:normalize-emails` faz (ver `commands/normalize_emails.ts`).
26
+ * A lib não tenta adivinhar grafias no caminho do login: cada tentativa extra é
27
+ * uma query a mais por e-mail desconhecido, que é justamente o caminho de ataque.
28
+ */
29
+ export function normalizeEmailIdentifier(raw) {
30
+ return typeof raw === 'string' ? raw.trim().toLowerCase() : '';
31
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Política EFETIVA de organizações — ponto de verdade ÚNICO das duas
3
+ * superfícies member-facing.
4
+ *
5
+ * O console HTML (`AccountOrgsController`) e o espelho JSON
6
+ * (`AccountOrgsApiController`) precisam responder à MESMA pergunta — "este
7
+ * usuário pode criar org? que papéis existem? quanto tempo o convite vale?" —
8
+ * e a resposta não está no config estático: ela é resolvida na ordem setting da
9
+ * org → setting global → `config.organizations` → default da lib.
10
+ *
11
+ * Mora num módulo próprio, e não dentro de um dos controllers, porque duas
12
+ * cópias desta resolução são exatamente como o caminho JSON acaba mais frouxo
13
+ * (ou mais apertado) que o formulário que ele espelha — o bug que este pacote
14
+ * de mudanças existe para não ter.
15
+ */
16
+ import './augmentations.js';
17
+ import type { HttpContext } from '@adonisjs/core/http';
18
+ import { type OrganizationsPolicyConfigDefaults, type ResolvedOrganizationsPolicySetting } from './runtime_toggles.js';
19
+ /** Defaults estáticos da política de org (config do host) — o fallback da setting. */
20
+ export declare function orgPolicyDefaults(cfg: any): OrganizationsPolicyConfigDefaults;
21
+ /**
22
+ * Política efetiva para o `orgId` (ou global, quando ausente). `settings` nulo —
23
+ * DB fora do ar, app sem lucid — cai no config estático, fail-safe herdado de
24
+ * {@link resolveEffectiveOrganizationsPolicy}.
25
+ */
26
+ export declare function effectiveOrgPolicy(ctx: HttpContext, cfg: any, orgId?: string | null): Promise<ResolvedOrganizationsPolicySetting>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Política EFETIVA de organizações — ponto de verdade ÚNICO das duas
3
+ * superfícies member-facing.
4
+ *
5
+ * O console HTML (`AccountOrgsController`) e o espelho JSON
6
+ * (`AccountOrgsApiController`) precisam responder à MESMA pergunta — "este
7
+ * usuário pode criar org? que papéis existem? quanto tempo o convite vale?" —
8
+ * e a resposta não está no config estático: ela é resolvida na ordem setting da
9
+ * org → setting global → `config.organizations` → default da lib.
10
+ *
11
+ * Mora num módulo próprio, e não dentro de um dos controllers, porque duas
12
+ * cópias desta resolução são exatamente como o caminho JSON acaba mais frouxo
13
+ * (ou mais apertado) que o formulário que ele espelha — o bug que este pacote
14
+ * de mudanças existe para não ter.
15
+ */
16
+ import './augmentations.js';
17
+ import { resolveRuntimeSettings } from './runtime_settings.js';
18
+ import { resolveEffectiveOrganizationsPolicy, } from './runtime_toggles.js';
19
+ /** Defaults estáticos da política de org (config do host) — o fallback da setting. */
20
+ export function orgPolicyDefaults(cfg) {
21
+ return {
22
+ roles: cfg.organizations.roles,
23
+ allowSelfCreate: cfg.organizations.allowSelfCreate,
24
+ invitationTtlHours: cfg.organizations.invitationTtlHours,
25
+ };
26
+ }
27
+ /**
28
+ * Política efetiva para o `orgId` (ou global, quando ausente). `settings` nulo —
29
+ * DB fora do ar, app sem lucid — cai no config estático, fail-safe herdado de
30
+ * {@link resolveEffectiveOrganizationsPolicy}.
31
+ */
32
+ export async function effectiveOrgPolicy(ctx, cfg, orgId) {
33
+ const settings = await resolveRuntimeSettings(ctx);
34
+ return resolveEffectiveOrganizationsPolicy(settings, orgPolicyDefaults(cfg), orgId);
35
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Chave de sessão do desafio WebAuthn PENDENTE de REGISTRO de passkey.
3
+ *
4
+ * Mora num módulo próprio porque a cerimônia agora tem DOIS pares de rotas — o
5
+ * clássico (`/account/mfa/passkeys/{options,verify}`, que responde redirect numa
6
+ * navegação) e o JSON (`/account/api/mfa/passkeys/{options,verify}`, que nunca
7
+ * navega). Os dois precisam ler e escrever o MESMO slot: um `begin` feito por um
8
+ * caminho tem de poder ser finalizado pelo outro, e — mais importante — um
9
+ * desafio só pode existir UMA vez por sessão. Duas constantes com o mesmo valor
10
+ * em arquivos diferentes seriam a mesma coisa até alguém mudar uma delas.
11
+ */
12
+ export declare const PASSKEY_REG_CHALLENGE_KEY = "authkit_passkey_reg_challenge";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Chave de sessão do desafio WebAuthn PENDENTE de REGISTRO de passkey.
3
+ *
4
+ * Mora num módulo próprio porque a cerimônia agora tem DOIS pares de rotas — o
5
+ * clássico (`/account/mfa/passkeys/{options,verify}`, que responde redirect numa
6
+ * navegação) e o JSON (`/account/api/mfa/passkeys/{options,verify}`, que nunca
7
+ * navega). Os dois precisam ler e escrever o MESMO slot: um `begin` feito por um
8
+ * caminho tem de poder ser finalizado pelo outro, e — mais importante — um
9
+ * desafio só pode existir UMA vez por sessão. Duas constantes com o mesmo valor
10
+ * em arquivos diferentes seriam a mesma coisa até alguém mudar uma delas.
11
+ */
12
+ export const PASSKEY_REG_CHALLENGE_KEY = 'authkit_passkey_reg_challenge';