@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
package/build/index.d.ts CHANGED
@@ -55,7 +55,9 @@ export { resolveMessages, translate, DEFAULT_MESSAGES, PT_BR_MESSAGES, BUILTIN_M
55
55
  export type { I18nConfig, AuthMessages } from './src/host/i18n.js';
56
56
  export type { AuthHostRenderer, AuthSocialConfig } from './src/define_config.js';
57
57
  export { registerAuthHost } from './src/host/register_auth_host.js';
58
- export type { AuthHostOptions } from './src/host/register_auth_host.js';
58
+ export type { AuthHostOptions, AuthHostRouteMap, AccountScreensOptions, } from './src/host/register_auth_host.js';
59
+ export type { PolicyRouteOption } from './src/host/config_locks.js';
60
+ export { POLICY_ROUTE_OPTIONS } from './src/host/config_locks.js';
59
61
  export { getAdminPrefix, setAdminPrefix, normalizeAdminPrefix, getAdminApiPrefix, setAdminApiPrefix, normalizeAdminApiPrefix, } from './src/host/admin_prefix.js';
60
62
  /**
61
63
  * Helpers de path do console de conta (`/account/*`). Um host que precisa casar
@@ -125,7 +127,7 @@ export { SETTING_KEYS, resolveEffectiveRegistration, resolveEffectiveRequireVeri
125
127
  export type { SettingKey, RegistrationSetting, RequireVerifiedEmailSetting, MaintenanceModeSetting, ResolvedMaintenanceMode, AuthMethodsSetting, ResolvedAuthMethods, AuthMethodsCapabilities, AuthMethodsConfigOverride, } from './src/host/runtime_toggles.js';
126
128
  export { resolveRegistration } from './src/define_config.js';
127
129
  export type { RegistrationConfigInput, ResolvedRegistrationConfig, } from './src/define_config.js';
128
- export { getAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
130
+ export { getAccountId, realAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
129
131
  export { ACCOUNT_SESSION_KEY } from './src/host/middleware/account_auth.js';
130
132
  export { rememberAccessToken, startImpersonation, impersonationState, stopImpersonation, } from './src/host/impersonation_session.js';
131
133
  export type { StartImpersonationParams, ImpersonationState, } from './src/host/impersonation_session.js';
package/build/index.js CHANGED
@@ -33,6 +33,7 @@ export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
33
33
  export { brandFor, isFirstParty } from './src/host/branding.js';
34
34
  export { resolveMessages, translate, DEFAULT_MESSAGES, PT_BR_MESSAGES, BUILTIN_MESSAGES, DEFAULT_LOCALE, } from './src/host/i18n.js';
35
35
  export { registerAuthHost } from './src/host/register_auth_host.js';
36
+ export { POLICY_ROUTE_OPTIONS } from './src/host/config_locks.js';
36
37
  export { getAdminPrefix, setAdminPrefix, normalizeAdminPrefix, getAdminApiPrefix, setAdminApiPrefix, normalizeAdminApiPrefix, } from './src/host/admin_prefix.js';
37
38
  /**
38
39
  * Helpers de path do console de conta (`/account/*`). Um host que precisa casar
@@ -85,7 +86,7 @@ export { resolveEffectiveBotProtection } from './src/host/bot_protection.js';
85
86
  // Runtime toggles (registration, require_verified_email, maintenance_mode).
86
87
  export { SETTING_KEYS, resolveEffectiveRegistration, resolveEffectiveRequireVerifiedEmail, resolveEffectiveMaintenanceMode, resolveEffectiveAuthMethods, configLockedAuthMethods, } from './src/host/runtime_toggles.js';
87
88
  export { resolveRegistration } from './src/define_config.js';
88
- export { getAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
89
+ export { getAccountId, realAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
89
90
  export { ACCOUNT_SESSION_KEY } from './src/host/middleware/account_auth.js';
90
91
  // Session impersonation — RP-side glue that routes through the IdP's RFC 8693
91
92
  // token-exchange (the IdP validates the admin role + audits). See
@@ -37,11 +37,13 @@ export default class AuthkitServerProvider {
37
37
  // Config locks: trava as settings definidas explicitamente no defineConfig
38
38
  // (config vence em runtime; a UI/Admin API não pode alterá-las). Fail-safe:
39
39
  // qualquer erro → sem locks (comportamento legado).
40
+ let resolvedConfig = null;
40
41
  try {
41
42
  const value = this.app.config.get('authkit');
42
43
  if (value) {
43
44
  const config = (await configProvider.resolve(this.app, value));
44
45
  if (config) {
46
+ resolvedConfig = config;
45
47
  if (config.lockedSettingKeys?.length) {
46
48
  const { setLockedSettingKeys } = await import('../src/host/config_locks.js');
47
49
  setLockedSettingKeys(config.lockedSettingKeys);
@@ -55,6 +57,13 @@ export default class AuthkitServerProvider {
55
57
  rateLimit: config.rateLimit,
56
58
  adminEnabled: config.admin.enabled,
57
59
  adminApiEnabled: config.adminApi.enabled,
60
+ // `config.sudo.methods` passa a decidir também o que é MONTADO — sem
61
+ // isto o host teria de repetir a lista no `registerAuthHost`, e as
62
+ // duas divergiriam (tela oferecendo endpoint que dá 404).
63
+ sudoMethods: config.sudo?.methods,
64
+ // Defaults estruturais de `config.routes` (o argumento ainda vence).
65
+ routes: typeof config.routes === 'object' ? config.routes : undefined,
66
+ lockedRouteOptions: config.lockedRouteOptions,
58
67
  });
59
68
  }
60
69
  }
@@ -62,6 +71,15 @@ export default class AuthkitServerProvider {
62
71
  catch {
63
72
  /* sem locks / sem stash → registerAuthHost cai em opts/defaults */
64
73
  }
74
+ // Auto-montagem das rotas (`config.routes`). FORA do try/catch fail-safe
75
+ // acima de propósito: "as rotas não subiram" não pode degradar em silêncio —
76
+ // seria um app inteiro em 404 sem nenhuma pista. Chama a MESMA função
77
+ // exportada que o `start/routes.ts` chamaria; não há segunda implementação.
78
+ if (resolvedConfig?.routes) {
79
+ const router = await this.app.container.make('router');
80
+ const { autoMountAuthHost } = await import('../src/host/register_auth_host.js');
81
+ autoMountAuthHost(router);
82
+ }
65
83
  // Registra o disco "authkit" no edge.js para que os templates sejam referenciados
66
84
  // como `authkit::login`, `authkit::account/tokens`, etc.
67
85
  // Resolve o diretório das views tanto em produção (provider compilado em
@@ -4,6 +4,35 @@ import type { FetchLike, PwnedLogger } from '../password/pwned.js';
4
4
  import type { AccountStore } from './account_store.js';
5
5
  import { type AccountSecretEncrypter, type WebauthnCeremonies } from './lucid_store/shared.js';
6
6
  export type { AccountSecretEncrypter, WebauthnCeremonies };
7
+ /**
8
+ * TTLs dos tokens de verificação de e-mail / troca de e-mail (plan 009).
9
+ *
10
+ * Segue o mesmo padrão input/resolved/`resolveX` usado em `define_config.ts`
11
+ * (ex.: `resolveOrganizations`) — mas vive AQUI, não lá: `defineConfig` não
12
+ * constrói o `accountStore` (o host já o entrega pronto, via
13
+ * `config.accountStore`, tipicamente construído por `lucidAccountStore`/
14
+ * `lucidStores` ANTES de chamar `defineConfig`), então não há caminho para um
15
+ * valor resolvido em `define_config.ts` alcançar `buildCore` sem alterar a
16
+ * assinatura dos métodos do `AccountStore` e todos os controllers que os
17
+ * chamam (como acontece com `invitationTtlHours`/`createOrgInvitation`, que
18
+ * recebe o TTL por parâmetro em cada chamada) — fora do escopo desta mudança.
19
+ * O ponto real de wiring é aqui, na construção do store.
20
+ */
21
+ export interface EmailTokensConfigInput {
22
+ /** TTL do token de verificação de e-mail (cadastro). Default: 24h. */
23
+ verificationTtlHours?: number;
24
+ /**
25
+ * TTL do token de troca de e-mail (self-service). Default: 1h — mesma
26
+ * janela do reset de senha, porque reescreve o identificador de recovery
27
+ * da conta.
28
+ */
29
+ changeTtlHours?: number;
30
+ }
31
+ export interface ResolvedEmailTokensConfig {
32
+ verificationTtlHours: number;
33
+ changeTtlHours: number;
34
+ }
35
+ export declare function resolveEmailTokens(input?: EmailTokensConfigInput): ResolvedEmailTokensConfig;
7
36
  /**
8
37
  * Serviço de encryption do app (APP_KEY), carregado LAZY via import dinâmico.
9
38
  *
@@ -117,6 +146,12 @@ export interface LucidAccountStoreOptions {
117
146
  MemberModel: any;
118
147
  InvitationModel: any;
119
148
  };
149
+ /**
150
+ * TTLs dos tokens de verificação de e-mail / troca de e-mail. Ver
151
+ * {@link EmailTokensConfigInput}. Ausente → 24h / 1h (defaults de
152
+ * `resolveEmailTokens`).
153
+ */
154
+ emailTokens?: EmailTokensConfigInput;
120
155
  }
121
156
  /**
122
157
  * Implementação default do {@link AccountStore} sobre um model Lucid composto
@@ -8,6 +8,12 @@ import { buildProviderIdentity } from './lucid_store/provider_identity.js';
8
8
  import { hasTable, } from './lucid_store/shared.js';
9
9
  import { buildDeletion, buildEmailVerificationStatus, buildProfile, buildStatus, hasColumn, } from './lucid_store/status_profile.js';
10
10
  import { buildWebauthn } from './lucid_store/webauthn.js';
11
+ export function resolveEmailTokens(input) {
12
+ return {
13
+ verificationTtlHours: input?.verificationTtlHours ?? 24,
14
+ changeTtlHours: input?.changeTtlHours ?? 1,
15
+ };
16
+ }
11
17
  let encSvc;
12
18
  let encLoading;
13
19
  /** Resolve quando o `loadEncryption()` corrente terminar (sucesso OU falha). */
@@ -116,6 +122,7 @@ export function appKeyEncrypter() {
116
122
  export function lucidAccountStore(Model, options = {}) {
117
123
  const mfaIssuer = options.mfaIssuer ?? 'AuthKit';
118
124
  const recoveryCodeCount = options.recoveryCodeCount ?? 8;
125
+ const emailTokens = resolveEmailTokens(options.emailTokens);
119
126
  // Default seguro: encripta o TOTP com APP_KEY. `false` desliga (plaintext).
120
127
  const encrypter = options.encrypter === false ? undefined : (options.encrypter ?? appKeyEncrypter());
121
128
  const ProviderIdentityModel = options.providerIdentityModel;
@@ -145,6 +152,8 @@ export function lucidAccountStore(Model, options = {}) {
145
152
  recoveryCodeCount,
146
153
  passwords,
147
154
  audit: options.audit,
155
+ emailVerificationTtlHours: emailTokens.verificationTtlHours,
156
+ emailChangeTtlHours: emailTokens.changeTtlHours,
148
157
  // Encripta o segredo antes de persistir (no-op sem encrypter).
149
158
  sealSecret: (secret) => (encrypter ? encrypter.encrypt(secret) : secret),
150
159
  // Decripta o segredo armazenado; retorna null em falha/adulteração (no-op sem encrypter).
@@ -3,7 +3,21 @@ import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt';
3
3
  import { DateTime } from 'luxon';
4
4
  import { OTP_LOGIN_PREFIX, decodeOtpToken, encodeOtpToken, evaluateLoginOtp, generateOtpCode, hashLoginOtp, linkTokenFromOtpUrl, } from '../../host/otp_login.js';
5
5
  import { hasColumn } from './status_profile.js';
6
- /** Prefixo do token de troca de e-mail (reaproveita a coluna emailVerificationToken). */
6
+ import { generateExpiringHashedToken, generateHashedToken, parseExpiringTokenExp, rawToDbToken, rawToExpiringDbToken, sha256Hex, } from './token_hash.js';
7
+ /**
8
+ * Prefixo do token de troca de e-mail (reaproveita a coluna emailVerificationToken).
9
+ *
10
+ * Formato ARMAZENADO (plan 009): `ec:<b64email>:<exp>:sha256(<random>)` — o
11
+ * e-mail continua em base64url (inalterado, plan 007 não mexeu nisso), mas
12
+ * agora carrega uma deadline (`exp`, epoch ms) e o `random` vai HASHEADO em
13
+ * repouso (antes ia em claro). Ver a análise de segurança do `exp` embutido
14
+ * em `token_hash.ts` (`rawToExpiringDbToken`/`parseExpiringTokenExp`).
15
+ *
16
+ * Limite prático: com um e-mail codificado em base64url, o valor cabe em
17
+ * VARCHAR(255) para e-mails de até ~129 bytes (`3 + b64(129) + 1 + 13 + 1 +
18
+ * 64 = 254`); acima disso o valor gravado excederia a coluna. Nenhum e-mail
19
+ * real chega perto disso — é uma folga generosa, não um limite apertado.
20
+ */
7
21
  const EMAIL_CHANGE_PREFIX = 'ec:';
8
22
  /** Prefixo do magic link (reaproveita as colunas de reset de senha). */
9
23
  const MAGIC_LINK_PREFIX = 'ml:';
@@ -125,8 +139,11 @@ export function buildCore(ctx) {
125
139
  const row = await Model.query().where('email', email).first();
126
140
  if (!row)
127
141
  return null;
128
- const token = randomBytes(32).toString('hex');
129
- row.passwordResetToken = token;
142
+ // Token BRUTO devolvido ao chamador (vai pro e-mail); só o HASH (sha256 da
143
+ // parte aleatória) é persistido — um dump da tabela não rende um token
144
+ // usável (mesmo padrão de host/otp_lockout.ts).
145
+ const { raw: token, dbValue } = generateHashedToken();
146
+ row.passwordResetToken = dbValue;
130
147
  row.passwordResetExpiresAt = DateTime.now().plus({ hours: 1 });
131
148
  await row.save();
132
149
  return { token, account: toAccount(row) };
@@ -134,9 +151,10 @@ export function buildCore(ctx) {
134
151
  async consumePasswordResetToken(token, newPassword) {
135
152
  // Magic links (`ml:` e `ml2:` com OTP) NÃO são tokens de reset de senha —
136
153
  // só o fluxo de consumeMagicLinkToken pode consumi-los (não trocam senha).
154
+ // Checagem de prefixo acontece ANTES do hash, sobre o token BRUTO recebido.
137
155
  if (token.startsWith(MAGIC_LINK_PREFIX) || token.startsWith(OTP_LOGIN_PREFIX))
138
156
  return false;
139
- const row = await Model.query().where('passwordResetToken', token).first();
157
+ const row = await Model.query().where('passwordResetToken', rawToDbToken('', token)).first();
140
158
  if (!row)
141
159
  return false;
142
160
  if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
@@ -161,8 +179,11 @@ export function buildCore(ctx) {
161
179
  return null;
162
180
  // Token `ml:<random>` nas colunas de reset (sem migração); o prefixo o
163
181
  // distingue de um token de reset de senha. Curta duração (15 min).
164
- const token = `${MAGIC_LINK_PREFIX}${randomBytes(32).toString('hex')}`;
165
- row.passwordResetToken = token;
182
+ // O prefixo fica em CLARO no valor persistido — só a parte aleatória é
183
+ // hasheada — porque os guards de discriminação de fluxo leem esse prefixo
184
+ // direto do valor armazenado (ver consumePasswordResetToken/consumeMagicLinkToken).
185
+ const { raw: token, dbValue } = generateHashedToken(MAGIC_LINK_PREFIX);
186
+ row.passwordResetToken = dbValue;
166
187
  row.passwordResetExpiresAt = DateTime.now().plus({ minutes: 15 });
167
188
  await row.save();
168
189
  return { token, account: toAccount(row) };
@@ -178,8 +199,13 @@ export function buildCore(ctx) {
178
199
  const linkToken = linkTokenFromOtpUrl(token);
179
200
  if (!linkToken)
180
201
  return null;
202
+ // O slot armazena o HASH do link-token (não o valor bruto da URL); o
203
+ // padrão do LIKE precisa ser construído sobre o hash. `sha256Hex` sempre
204
+ // devolve hex minúsculo — sem metacaractere de LIKE, então isso não
205
+ // reabre a guarda de LIKE-injection que `linkTokenFromOtpUrl` já fecha.
206
+ const linkTokenHash = sha256Hex(linkToken);
181
207
  const row = await Model.query()
182
- .where('passwordResetToken', 'like', `${OTP_LOGIN_PREFIX}${linkToken}:%`)
208
+ .where('passwordResetToken', 'like', `${OTP_LOGIN_PREFIX}${linkTokenHash}:%`)
183
209
  .first();
184
210
  if (!row)
185
211
  return null;
@@ -192,7 +218,9 @@ export function buildCore(ctx) {
192
218
  }
193
219
  if (!token.startsWith(MAGIC_LINK_PREFIX))
194
220
  return null;
195
- const row = await Model.query().where('passwordResetToken', token).first();
221
+ const row = await Model.query()
222
+ .where('passwordResetToken', rawToDbToken(MAGIC_LINK_PREFIX, token))
223
+ .first();
196
224
  if (!row)
197
225
  return null;
198
226
  if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
@@ -208,12 +236,20 @@ export function buildCore(ctx) {
208
236
  const row = await Model.query().where('email', email).first();
209
237
  if (!row)
210
238
  return null;
239
+ // `linkToken` BRUTO vai só na URL; o slot armazena o HASH dele (NÃO o
240
+ // valor bruto) — `codeHash` já é sha256(uid:code) e NÃO é re-hasheado aqui.
211
241
  const linkToken = randomBytes(32).toString('hex');
242
+ const linkTokenHash = sha256Hex(linkToken);
212
243
  const code = generateOtpCode(opts.digits);
213
244
  const codeHash = hashLoginOtp(uid, code);
214
245
  const codeExpMs = DateTime.now().plus({ minutes: opts.ttlMinutes }).toMillis();
215
246
  // Slot `ml2:` — código + link juntos, contador em 0. Ver host/otp_login.ts.
216
- row.passwordResetToken = encodeOtpToken({ linkToken, codeHash, codeExpMs, attempts: 0 });
247
+ row.passwordResetToken = encodeOtpToken({
248
+ linkToken: linkTokenHash,
249
+ codeHash,
250
+ codeExpMs,
251
+ attempts: 0,
252
+ });
217
253
  // O LINK herda a validade padrão do magic link (15 min); o CÓDIGO carrega o
218
254
  // próprio `codeExpMs` (mais curto) embutido no slot.
219
255
  row.passwordResetExpiresAt = DateTime.now().plus({ minutes: 15 });
@@ -285,8 +321,15 @@ export function buildCore(ctx) {
285
321
  const row = await Model.query().where('email', email).first();
286
322
  if (!row)
287
323
  return null;
288
- const token = randomBytes(32).toString('hex');
289
- row.emailVerificationToken = token;
324
+ // Token `<exp>:<random>` (sem prefixo — mesma coluna de sempre); só o
325
+ // HASH da parte aleatória é persistido, e o `exp` (deadline, epoch ms)
326
+ // vai em CLARO dentro do valor gravado. Ver a análise de segurança em
327
+ // `token_hash.ts` (generateExpiringHashedToken): o `exp` só é confiável
328
+ // DEPOIS que a igualdade de hash bater — é essa igualdade que prova que
329
+ // ele não foi adulterado pelo cliente.
330
+ const expiresAt = DateTime.now().plus({ hours: ctx.emailVerificationTtlHours ?? 24 });
331
+ const { raw: token, dbValue } = generateExpiringHashedToken('', expiresAt);
332
+ row.emailVerificationToken = dbValue;
290
333
  await row.save();
291
334
  return { token, account: toAccount(row) };
292
335
  },
@@ -297,9 +340,20 @@ export function buildCore(ctx) {
297
340
  // fluxo de confirmEmailChange pode consumi-los.
298
341
  if (token.startsWith(EMAIL_CHANGE_PREFIX))
299
342
  return false;
300
- const row = await Model.query().where('emailVerificationToken', token).first();
343
+ const dbValue = rawToExpiringDbToken('', token);
344
+ const row = await Model.query().where('emailVerificationToken', dbValue).first();
301
345
  if (!row)
302
346
  return false;
347
+ // O `exp` só é lido AGORA, depois que a busca por `dbValue` já achou uma
348
+ // linha — esse match prova que este `exp` é o mesmo gravado no issue,
349
+ // não um valor que o cliente escreveu na hora. `null` (ausente/não-
350
+ // parseável — inclusive tokens gravados por uma versão anterior desta
351
+ // lib, sem `exp` nenhum) conta como EXPIRADO (fail-closed), mirando o
352
+ // mesmo formato de `!row.passwordResetExpiresAt || ... < DateTime.now()`
353
+ // usado no reset de senha.
354
+ const exp = parseExpiringTokenExp('', token);
355
+ if (exp === null || exp < DateTime.now().toMillis())
356
+ return false;
303
357
  row.emailVerifiedAt = DateTime.now();
304
358
  row.emailVerificationToken = null;
305
359
  await row.save();
@@ -361,13 +415,19 @@ export function buildCore(ctx) {
361
415
  const taken = await Model.query().where('email', newEmail).first();
362
416
  if (taken && taken.id !== row.id)
363
417
  return null;
364
- // Token = `ec:<base64url(newEmail)>:<random>`. Reaproveita a coluna
418
+ // Token = `ec:<base64url(newEmail)>:<exp>:<random>`. Reaproveita a coluna
365
419
  // emailVerificationToken (sem migração nova); o prefixo `ec:` distingue do
366
420
  // token de verificação de cadastro. O e-mail viaja codificado no próprio
367
- // token, então não precisamos de coluna extra para o "pending email".
421
+ // token (sem coluna extra para o "pending email"); o `exp` (deadline,
422
+ // epoch ms) também viaja em CLARO no token — ver a análise de segurança
423
+ // em `token_hash.ts` (generateExpiringHashedToken/rawToExpiringDbToken):
424
+ // só a parte `random` é hasheada, e é a igualdade sobre a string INTEIRA
425
+ // (prefixo + e-mail + exp + hash) que impede o cliente de adulterar o
426
+ // `exp` sem invalidar o próprio token.
368
427
  const encodedEmail = Buffer.from(newEmail, 'utf8').toString('base64url');
369
- const token = `${EMAIL_CHANGE_PREFIX}${encodedEmail}:${randomBytes(24).toString('hex')}`;
370
- row.emailVerificationToken = token;
428
+ const expiresAt = DateTime.now().plus({ hours: ctx.emailChangeTtlHours ?? 1 });
429
+ const { raw: token, dbValue } = generateExpiringHashedToken(`${EMAIL_CHANGE_PREFIX}${encodedEmail}:`, expiresAt);
430
+ row.emailVerificationToken = dbValue;
371
431
  await row.save();
372
432
  return { token, account: toAccount(row), newEmail };
373
433
  },
@@ -375,8 +435,9 @@ export function buildCore(ctx) {
375
435
  if (!token || !token.startsWith(EMAIL_CHANGE_PREFIX))
376
436
  return { ok: false };
377
437
  const parts = token.split(':');
378
- // Forma esperada: ['ec', '<b64email>', '<random>']
379
- if (parts.length !== 3)
438
+ // Forma esperada (plan 009): ['ec', '<b64email>', '<exp>', '<random>']
439
+ // 4 partes (era 3 antes de embutir o `exp`).
440
+ if (parts.length !== 4)
380
441
  return { ok: false };
381
442
  let newEmail;
382
443
  try {
@@ -387,9 +448,17 @@ export function buildCore(ctx) {
387
448
  }
388
449
  if (!newEmail)
389
450
  return { ok: false };
390
- const row = await Model.query().where('emailVerificationToken', token).first();
451
+ const prefix = `${EMAIL_CHANGE_PREFIX}${parts[1]}:`;
452
+ const dbValue = rawToExpiringDbToken(prefix, token);
453
+ const row = await Model.query().where('emailVerificationToken', dbValue).first();
391
454
  if (!row)
392
455
  return { ok: false };
456
+ // O `exp` só é lido DEPOIS do match acima (mesmo raciocínio de
457
+ // consumeEmailVerificationToken) — `null` (ausente/não-parseável) conta
458
+ // como EXPIRADO, fail-closed.
459
+ const exp = parseExpiringTokenExp(prefix, token);
460
+ if (exp === null || exp < DateTime.now().toMillis())
461
+ return { ok: false };
393
462
  // Defesa contra corrida: o e-mail pode ter sido tomado entre o pedido e a
394
463
  // confirmação por outra conta.
395
464
  const taken = await Model.query().where('email', newEmail).first();
@@ -58,6 +58,20 @@ export interface LucidStoreContext {
58
58
  * pela verificação de histórico de senhas.
59
59
  */
60
60
  nativeVerifyHash?: (hash: string, plain: string) => Promise<boolean>;
61
+ /**
62
+ * TTL (em horas) do token de verificação de e-mail (cadastro). Default: 24h
63
+ * (aplicado em `buildCore` quando ausente). Ver `resolveEmailTokens` em
64
+ * `lucid_account_store.ts` — não vem de `define_config.ts` porque o
65
+ * `defineConfig` não constrói o `accountStore` (o host já o entrega pronto
66
+ * via `config.accountStore`); o valor precisa chegar aqui na construção.
67
+ */
68
+ emailVerificationTtlHours?: number;
69
+ /**
70
+ * TTL (em horas) do token de troca de e-mail (self-service). Default: 1h —
71
+ * mesma janela do reset de senha, porque reescreve o identificador de
72
+ * recovery da conta. Ver nota de `emailVerificationTtlHours`.
73
+ */
74
+ emailChangeTtlHours?: number;
61
75
  }
62
76
  export declare const sha256: (value: string) => string;
63
77
  /** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
@@ -0,0 +1,79 @@
1
+ import type { DateTime } from 'luxon';
2
+ /**
3
+ * Hashing dos tokens armazenados nas colunas `passwordResetToken` /
4
+ * `emailVerificationToken` (reset de senha, magic link, magic link com OTP).
5
+ *
6
+ * Mesmo padrão de `host/otp_lockout.ts` (`generateOtpUnlockToken` /
7
+ * `rawToDbOtpUnlockToken`): hasheia SÓ a parte aleatória do token — o prefixo
8
+ * (`ml:`, `ml2:`, etc., ou nenhum, no caso do reset) fica em texto claro no
9
+ * valor gravado, porque os guards de discriminação de fluxo (ex.:
10
+ * `consumePasswordResetToken` recusando `ml:`/`ml2:`; `consumeMagicLinkToken`
11
+ * recusando o que não é `ml:`) leem esse prefixo direto do valor armazenado —
12
+ * hashear a string inteira (prefixo incluso) tornaria essa leitura impossível.
13
+ *
14
+ * Motivo de existir como módulo próprio (em vez de reusar `otp_lockout.ts`):
15
+ * aquele arquivo já está correto e é o EXEMPLAR, não o alvo — mantê-lo intocado
16
+ * evita qualquer risco de regressão nele por este changeset.
17
+ */
18
+ /** sha256 hex de uma string. */
19
+ export declare function sha256Hex(value: string): string;
20
+ /**
21
+ * Gera um token `prefix + <64 hex aleatórios>` e devolve tanto o valor BRUTO
22
+ * (vai para o e-mail/URL) quanto o valor a persistir no DB
23
+ * (`prefix + sha256(<parte aleatória>)`).
24
+ *
25
+ * `prefix` pode ser vazio (caso do reset de senha, que não tem prefixo).
26
+ */
27
+ export declare function generateHashedToken(prefix?: string): {
28
+ raw: string;
29
+ dbValue: string;
30
+ };
31
+ /**
32
+ * Converte um token BRUTO recebido (com o `prefix` esperado) no valor de DB
33
+ * correspondente (`prefix + sha256(<parte aleatória>)`), para lookup por
34
+ * igualdade. Não valida o prefixo — o chamador já deve ter confirmado
35
+ * `raw.startsWith(prefix)` antes de chegar aqui (guards de discriminação).
36
+ */
37
+ export declare function rawToDbToken(prefix: string, raw: string): string;
38
+ /**
39
+ * Gera um token expirável `<prefix><exp>:<random>` e devolve tanto o BRUTO
40
+ * (vai pro e-mail/URL) quanto o valor de DB (`<prefix><exp>:sha256(<random>)`).
41
+ *
42
+ * `prefix` pode incluir estrutura própria (ex.: `ec:<b64email>:` na troca de
43
+ * e-mail) — é só um prefixo literal, igual em `generateHashedToken`. `expiresAt`
44
+ * é a deadline; é serializada como epoch ms (`toMillis()`) dentro do próprio
45
+ * token — ver o comentário do bloco acima para a análise de segurança.
46
+ */
47
+ export declare function generateExpiringHashedToken(prefix: string, expiresAt: DateTime): {
48
+ raw: string;
49
+ dbValue: string;
50
+ };
51
+ /**
52
+ * Reconstrói o valor de DB (`<prefix><exp>:sha256(<random>)`) a partir de um
53
+ * token BRUTO recebido, para lookup por igualdade — mesmo padrão de
54
+ * `rawToDbToken`, mas preservando o segmento `<exp>` em claro (ele faz parte
55
+ * da string comparada; não é hasheado). Usa o `exp` EXATAMENTE como veio no
56
+ * token recebido (mesmo que adulterado/ilegível) — é a comparação por
57
+ * igualdade no banco que decide se ele é válido, não este helper.
58
+ *
59
+ * Se o token não tiver o separador esperado, devolve um valor que não pode
60
+ * bater com nada gerado por `generateExpiringHashedToken` (fail-closed: a
61
+ * query simplesmente não encontra linha).
62
+ */
63
+ export declare function rawToExpiringDbToken(prefix: string, raw: string): string;
64
+ /**
65
+ * Extrai o `exp` (epoch ms) de um token BRUTO recebido, sem consultar o banco.
66
+ *
67
+ * ⚠️ O valor devolvido só deve ser CONSULTADO pelo chamador depois que a busca
68
+ * por `rawToExpiringDbToken` já tiver encontrado uma linha — é esse match que
69
+ * prova que este `exp` é o mesmo que foi gravado no `issue*`, não um valor que
70
+ * o cliente inventou. Ler o `exp` antes do match (ou usá-lo quando a busca não
71
+ * achou linha nenhuma) não prova nada.
72
+ *
73
+ * Devolve `null` quando o segmento `exp` está ausente (sem separador) OU não é
74
+ * uma sequência de dígitos — o chamador DEVE tratar `null` como EXPIRADO
75
+ * (fail-closed), nunca como "sem prazo". Isso cobre tanto tokens adulterados
76
+ * quanto tokens gravados por uma versão anterior desta lib (sem `exp`
77
+ * nenhum).
78
+ */
79
+ export declare function parseExpiringTokenExp(prefix: string, raw: string): number | null;
@@ -0,0 +1,145 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ /**
3
+ * Hashing dos tokens armazenados nas colunas `passwordResetToken` /
4
+ * `emailVerificationToken` (reset de senha, magic link, magic link com OTP).
5
+ *
6
+ * Mesmo padrão de `host/otp_lockout.ts` (`generateOtpUnlockToken` /
7
+ * `rawToDbOtpUnlockToken`): hasheia SÓ a parte aleatória do token — o prefixo
8
+ * (`ml:`, `ml2:`, etc., ou nenhum, no caso do reset) fica em texto claro no
9
+ * valor gravado, porque os guards de discriminação de fluxo (ex.:
10
+ * `consumePasswordResetToken` recusando `ml:`/`ml2:`; `consumeMagicLinkToken`
11
+ * recusando o que não é `ml:`) leem esse prefixo direto do valor armazenado —
12
+ * hashear a string inteira (prefixo incluso) tornaria essa leitura impossível.
13
+ *
14
+ * Motivo de existir como módulo próprio (em vez de reusar `otp_lockout.ts`):
15
+ * aquele arquivo já está correto e é o EXEMPLAR, não o alvo — mantê-lo intocado
16
+ * evita qualquer risco de regressão nele por este changeset.
17
+ */
18
+ /** sha256 hex de uma string. */
19
+ export function sha256Hex(value) {
20
+ return createHash('sha256').update(value).digest('hex');
21
+ }
22
+ /**
23
+ * Gera um token `prefix + <64 hex aleatórios>` e devolve tanto o valor BRUTO
24
+ * (vai para o e-mail/URL) quanto o valor a persistir no DB
25
+ * (`prefix + sha256(<parte aleatória>)`).
26
+ *
27
+ * `prefix` pode ser vazio (caso do reset de senha, que não tem prefixo).
28
+ */
29
+ export function generateHashedToken(prefix = '') {
30
+ const random = randomBytes(32).toString('hex');
31
+ return { raw: `${prefix}${random}`, dbValue: `${prefix}${sha256Hex(random)}` };
32
+ }
33
+ /**
34
+ * Converte um token BRUTO recebido (com o `prefix` esperado) no valor de DB
35
+ * correspondente (`prefix + sha256(<parte aleatória>)`), para lookup por
36
+ * igualdade. Não valida o prefixo — o chamador já deve ter confirmado
37
+ * `raw.startsWith(prefix)` antes de chegar aqui (guards de discriminação).
38
+ */
39
+ export function rawToDbToken(prefix, raw) {
40
+ const random = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
41
+ return `${prefix}${sha256Hex(random)}`;
42
+ }
43
+ // ─────────────────────────────────────────────────────────────────────────
44
+ // Variante EXPIRÁVEL (plan 009): `emailVerificationToken` (verificação de
45
+ // cadastro, sem prefixo) e `ec:` (troca de e-mail) embutem uma deadline
46
+ // (`exp`, epoch ms) dentro do PRÓPRIO token — não numa coluna separada.
47
+ //
48
+ // Por quê: esta lib não é dona da tabela `users` (ver header de
49
+ // `src/schema/ensure.ts`), então não pode adicionar uma coluna
50
+ // `emailVerificationExpiresAt` como o reset de senha tem. A alternativa óbvia
51
+ // — embutir o `exp` no valor que o cliente devolve — parecia insegura antes de
52
+ // 007: um valor sob controle do cliente poderia ter o `exp` adulterado.
53
+ //
54
+ // 007 fechou essa brecha ao fazer o LOOKUP reconstruir o valor de DB a partir
55
+ // do token bruto recebido e buscar por IGUALDADE (em vez de ler a linha
56
+ // primeiro e comparar depois). Isso vale integralmente aqui: o `exp` fica em
57
+ // CLARO dentro do valor gravado (`prefix<exp>:sha256(<random>)`), mas ele faz
58
+ // parte da STRING inteira que é comparada por igualdade na query. Se o
59
+ // cliente reescrever o `exp` no token que devolve, a reconstrução
60
+ // (`prefix<expAdulterado>:sha256(<random>)`) produz uma string DIFERENTE da
61
+ // que está gravada — a query não encontra NENHUMA linha, e o token é
62
+ // simplesmente rejeitado.
63
+ //
64
+ // Ou seja: o MATCH em si (achar a linha) já prova que o `exp` que acabou de
65
+ // ser lido é exatamente o mesmo que foi gerado no `issue*` — nenhuma
66
+ // assinatura/HMAC separada é necessária. É só DEPOIS desse match que o `exp`
67
+ // pode ser avaliado contra o relógio com confiança. NÃO SIMPLIFIQUE isso lendo
68
+ // o `exp` do token ANTES do match, nem tratando um `exp` ausente/inválido como
69
+ // "sem prazo" — as duas coisas reabririam a adulteração que este desenho
70
+ // fecha (ver testes de mutação no plano 009).
71
+ //
72
+ // Este raciocínio SÓ é válido enquanto o token continuar hasheado em repouso
73
+ // (a parte `random` do valor). Se algum dia alguém reverter o hashing, o `exp`
74
+ // embutido volta a ficar sob controle total do cliente.
75
+ // ─────────────────────────────────────────────────────────────────────────
76
+ /**
77
+ * Gera um token expirável `<prefix><exp>:<random>` e devolve tanto o BRUTO
78
+ * (vai pro e-mail/URL) quanto o valor de DB (`<prefix><exp>:sha256(<random>)`).
79
+ *
80
+ * `prefix` pode incluir estrutura própria (ex.: `ec:<b64email>:` na troca de
81
+ * e-mail) — é só um prefixo literal, igual em `generateHashedToken`. `expiresAt`
82
+ * é a deadline; é serializada como epoch ms (`toMillis()`) dentro do próprio
83
+ * token — ver o comentário do bloco acima para a análise de segurança.
84
+ */
85
+ export function generateExpiringHashedToken(prefix, expiresAt) {
86
+ const exp = expiresAt.toMillis();
87
+ const random = randomBytes(24).toString('hex');
88
+ return {
89
+ raw: `${prefix}${exp}:${random}`,
90
+ dbValue: `${prefix}${exp}:${sha256Hex(random)}`,
91
+ };
92
+ }
93
+ /**
94
+ * Separa `<exp>:<random>` de um token BRUTO `<prefix><exp>:<random>` (depois de
95
+ * remover o `prefix`). `null` se não há `:` após o prefixo (token mal formado —
96
+ * nem `exp` nem `random` são extraíveis).
97
+ */
98
+ function splitExpiringRaw(prefix, raw) {
99
+ const rest = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
100
+ const sepIdx = rest.indexOf(':');
101
+ if (sepIdx === -1)
102
+ return null;
103
+ return { expPart: rest.slice(0, sepIdx), random: rest.slice(sepIdx + 1) };
104
+ }
105
+ /**
106
+ * Reconstrói o valor de DB (`<prefix><exp>:sha256(<random>)`) a partir de um
107
+ * token BRUTO recebido, para lookup por igualdade — mesmo padrão de
108
+ * `rawToDbToken`, mas preservando o segmento `<exp>` em claro (ele faz parte
109
+ * da string comparada; não é hasheado). Usa o `exp` EXATAMENTE como veio no
110
+ * token recebido (mesmo que adulterado/ilegível) — é a comparação por
111
+ * igualdade no banco que decide se ele é válido, não este helper.
112
+ *
113
+ * Se o token não tiver o separador esperado, devolve um valor que não pode
114
+ * bater com nada gerado por `generateExpiringHashedToken` (fail-closed: a
115
+ * query simplesmente não encontra linha).
116
+ */
117
+ export function rawToExpiringDbToken(prefix, raw) {
118
+ const parts = splitExpiringRaw(prefix, raw);
119
+ if (!parts) {
120
+ const rest = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
121
+ return `${prefix}${sha256Hex(rest)}`;
122
+ }
123
+ return `${prefix}${parts.expPart}:${sha256Hex(parts.random)}`;
124
+ }
125
+ /**
126
+ * Extrai o `exp` (epoch ms) de um token BRUTO recebido, sem consultar o banco.
127
+ *
128
+ * ⚠️ O valor devolvido só deve ser CONSULTADO pelo chamador depois que a busca
129
+ * por `rawToExpiringDbToken` já tiver encontrado uma linha — é esse match que
130
+ * prova que este `exp` é o mesmo que foi gravado no `issue*`, não um valor que
131
+ * o cliente inventou. Ler o `exp` antes do match (ou usá-lo quando a busca não
132
+ * achou linha nenhuma) não prova nada.
133
+ *
134
+ * Devolve `null` quando o segmento `exp` está ausente (sem separador) OU não é
135
+ * uma sequência de dígitos — o chamador DEVE tratar `null` como EXPIRADO
136
+ * (fail-closed), nunca como "sem prazo". Isso cobre tanto tokens adulterados
137
+ * quanto tokens gravados por uma versão anterior desta lib (sem `exp`
138
+ * nenhum).
139
+ */
140
+ export function parseExpiringTokenExp(prefix, raw) {
141
+ const parts = splitExpiringRaw(prefix, raw);
142
+ if (!parts)
143
+ return null;
144
+ return /^\d+$/.test(parts.expPart) ? Number(parts.expPart) : null;
145
+ }