@adonis-agora/authkit-server 0.48.0 → 0.50.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 (32) hide show
  1. package/build/host/views/login.edge +18 -0
  2. package/build/index.d.ts +18 -2
  3. package/build/index.js +16 -1
  4. package/build/src/accounts/account_store.d.ts +59 -1
  5. package/build/src/accounts/account_store.js +5 -0
  6. package/build/src/accounts/lucid_store/core.d.ts +2 -2
  7. package/build/src/accounts/lucid_store/core.js +105 -4
  8. package/build/src/audit/audit_sink.d.ts +1 -1
  9. package/build/src/define_config.d.ts +22 -0
  10. package/build/src/define_config.js +5 -0
  11. package/build/src/host/account_screen_props.d.ts +163 -0
  12. package/build/src/host/account_screen_props.js +23 -0
  13. package/build/src/host/controllers/account_confirm_controller.js +3 -2
  14. package/build/src/host/controllers/account_mfa_controller.js +9 -6
  15. package/build/src/host/controllers/account_security_controller.js +3 -2
  16. package/build/src/host/controllers/account_session_controller.js +8 -3
  17. package/build/src/host/controllers/interaction_controller.d.ts +11 -0
  18. package/build/src/host/controllers/interaction_controller.js +136 -6
  19. package/build/src/host/default_mailer.d.ts +1 -0
  20. package/build/src/host/default_mailer.js +3 -0
  21. package/build/src/host/email_templates.d.ts +8 -0
  22. package/build/src/host/email_templates.js +12 -1
  23. package/build/src/host/i18n.d.ts +14 -0
  24. package/build/src/host/i18n.js +16 -0
  25. package/build/src/host/otp_login.d.ts +155 -0
  26. package/build/src/host/otp_login.js +206 -0
  27. package/build/src/host/rate_limit.d.ts +6 -0
  28. package/build/src/host/rate_limit.js +3 -0
  29. package/build/src/host/register_auth_host.js +8 -0
  30. package/build/src/host/renderers/inertia_renderer.d.ts +23 -66
  31. package/build/src/host/renderers/inertia_renderer.js +23 -66
  32. package/package.json +2 -2
@@ -193,6 +193,24 @@
193
193
  {{-- Passwordless: confirmação de magic link enviado (anti-enumeração). --}}
194
194
  @if(magicLinkSent)
195
195
  <p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
196
+
197
+ {{-- Login por OTP: campo de código digitável (mesmo e-mail carrega link E código). --}}
198
+ @if(otpEnabled)
199
+ <form method="POST" action="/auth/interaction/{{ uid }}/otp-verify" class="mt-4">
200
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
201
+ <label for="otp-code" class="block text-sm font-medium text-gray-700">{{ t('login.otp_label') }}</label>
202
+ <input id="otp-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code"
203
+ pattern="[0-9]*" placeholder="{{ t('login.otp_placeholder') }}"
204
+ class="mt-2 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-center text-lg tracking-[0.4em] font-mono focus:border-gray-900 focus:ring-gray-900" />
205
+ @if(otpError)
206
+ <p class="mt-2 text-sm text-red-600">{{ otpError }}</p>
207
+ @end
208
+ <button type="submit"
209
+ class="mt-4 w-full rounded-lg bg-gray-900 py-2.5 text-sm font-semibold text-white transition hover:opacity-90">
210
+ {{ t('login.otp_submit') }}
211
+ </button>
212
+ </form>
213
+ @end
196
214
  @end
197
215
 
198
216
  {{-- Passwordless: "me envie um link de login" (mesma sessão/e-mail; não pede senha). --}}
package/build/index.d.ts CHANGED
@@ -23,8 +23,9 @@ export { lucidAccountStore, appKeyEncrypter } from './src/accounts/lucid_account
23
23
  export { lucidStores } from './src/accounts/lucid_stores.js';
24
24
  export type { LucidStoresModels, LucidStoresOptions, LucidStoresResult, } from './src/accounts/lucid_stores.js';
25
25
  export type { LucidAccountStoreOptions, AccountSecretEncrypter, } from './src/accounts/lucid_account_store.js';
26
- export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, ProviderIdentitySummary, AccountSecurityCapability, AccountStatusCapability, ProfileCapability, MagicLinkCapability, EmailVerificationStatusCapability, AccountDeletionCapability, AccountImportCapability, ImportAccountInput, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
27
- export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
26
+ export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, ProviderIdentitySummary, AccountSecurityCapability, AccountStatusCapability, ProfileCapability, MagicLinkCapability, OtpLoginCapability, OtpLoginVerifyResult, EmailVerificationStatusCapability, AccountDeletionCapability, AccountImportCapability, ImportAccountInput, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
27
+ export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
28
+ export { type OtpLoginConfigInput, type ResolvedOtpLoginConfig, type OtpVerifyOutcome, resolveOtpLoginConfig, generateOtpCode, evaluateLoginOtp, OTP_LOGIN_DEFAULTS, } from './src/host/otp_login.js';
28
29
  export { PasswordManager, PasswordPolicyError, } from './src/password/password_manager.js';
29
30
  export type { PasswordConfigInput, LegacyPasswordVerifier, PasswordVerifyResult, } from './src/password/password_manager.js';
30
31
  export { checkPasswordPolicy, policyViolationParams, DEFAULT_PWNED_TIMEOUT_MS, } from './src/password/policy.js';
@@ -46,6 +47,7 @@ export type { EventsConfigInput, ResolvedEventsConfig } from './src/events/dispa
46
47
  export { inertiaRenderer } from './src/host/renderers/inertia_renderer.js';
47
48
  export type { AuthkitScreen } from './src/host/renderers/inertia_renderer.js';
48
49
  export type { InertiaRendererOptions } from './src/host/renderers/inertia_renderer.js';
50
+ export type { AccountLoginProps, AccountSecurityProps, AccountMfaProps, AccountConfirmProps, AccountConfirmMethod, AccountEmailConfirmedProps, } from './src/host/account_screen_props.js';
49
51
  export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
50
52
  export { brandFor, isFirstParty } from './src/host/branding.js';
51
53
  export type { BrandingConfig, ClientBrand } from './src/host/branding.js';
@@ -55,6 +57,20 @@ export type { AuthHostRenderer, AuthSocialConfig } from './src/define_config.js'
55
57
  export { registerAuthHost } from './src/host/register_auth_host.js';
56
58
  export type { AuthHostOptions } from './src/host/register_auth_host.js';
57
59
  export { getAdminPrefix, setAdminPrefix, normalizeAdminPrefix, getAdminApiPrefix, setAdminApiPrefix, normalizeAdminApiPrefix, } from './src/host/admin_prefix.js';
60
+ /**
61
+ * Helpers de path do console de conta (`/account/*`). Um host que precisa casar
62
+ * um middleware ou link com uma rota do console (ex.: `GET
63
+ * {accountPath('security')}/export`) deriva o path daqui em vez de hardcodar,
64
+ * respeitando os overrides de `accountRoutes`.
65
+ *
66
+ * ⚠️ Estes helpers leem um singleton de processo que só reflete os overrides
67
+ * DEPOIS que `registerAuthHost` roda (é ele quem chama `setAccountPaths` com a
68
+ * opção `accountRoutes`, no boot). Chamados antes disso, devolvem os defaults
69
+ * (`/account/*`). Componha URLs em runtime (dentro de handlers/factories de
70
+ * middleware), não em tempo de import de módulo.
71
+ */
72
+ export { accountPath, joinAccountPath, accountPrefix, } from './src/host/account_paths.js';
73
+ export type { AccountPathsOptions, AccountPathKey } from './src/host/account_paths.js';
58
74
  export { resolveRateLimit, resolveNotifications } from './src/define_config.js';
59
75
  export type { ResolvedNotificationsConfig } from './src/define_config.js';
60
76
  export type { RateLimitConfigInput, RateLimitBucket, ResolvedRateLimitConfig, } from './src/define_config.js';
package/build/index.js CHANGED
@@ -14,7 +14,9 @@ export { resolveTrustedDevices, isTrustedDeviceValid, buildTrustedDevicePayload,
14
14
  export { resolveBotProtection, botProtectionApplies, extractBotToken, verifyBotProtection, guardBotProtection, DEFAULT_BOT_TOKEN_FIELDS, } from './src/host/bot_protection.js';
15
15
  export { lucidAccountStore, appKeyEncrypter } from './src/accounts/lucid_account_store.js';
16
16
  export { lucidStores } from './src/accounts/lucid_stores.js';
17
- export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
17
+ export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
18
+ // Login por OTP (código digitável): config + helpers puros.
19
+ export { resolveOtpLoginConfig, generateOtpCode, evaluateLoginOtp, OTP_LOGIN_DEFAULTS, } from './src/host/otp_login.js';
18
20
  // Gerência de senha: lazy rehash + legacy verifier, política e checagem de vazamento.
19
21
  export { PasswordManager, PasswordPolicyError, } from './src/password/password_manager.js';
20
22
  export { checkPasswordPolicy, policyViolationParams, DEFAULT_PWNED_TIMEOUT_MS, } from './src/password/policy.js';
@@ -32,6 +34,19 @@ export { brandFor, isFirstParty } from './src/host/branding.js';
32
34
  export { resolveMessages, translate, DEFAULT_MESSAGES, PT_BR_MESSAGES, BUILTIN_MESSAGES, DEFAULT_LOCALE, } from './src/host/i18n.js';
33
35
  export { registerAuthHost } from './src/host/register_auth_host.js';
34
36
  export { getAdminPrefix, setAdminPrefix, normalizeAdminPrefix, getAdminApiPrefix, setAdminApiPrefix, normalizeAdminApiPrefix, } from './src/host/admin_prefix.js';
37
+ /**
38
+ * Helpers de path do console de conta (`/account/*`). Um host que precisa casar
39
+ * um middleware ou link com uma rota do console (ex.: `GET
40
+ * {accountPath('security')}/export`) deriva o path daqui em vez de hardcodar,
41
+ * respeitando os overrides de `accountRoutes`.
42
+ *
43
+ * ⚠️ Estes helpers leem um singleton de processo que só reflete os overrides
44
+ * DEPOIS que `registerAuthHost` roda (é ele quem chama `setAccountPaths` com a
45
+ * opção `accountRoutes`, no boot). Chamados antes disso, devolvem os defaults
46
+ * (`/account/*`). Componha URLs em runtime (dentro de handlers/factories de
47
+ * middleware), não em tempo de import de módulo.
48
+ */
49
+ export { accountPath, joinAccountPath, accountPrefix, } from './src/host/account_paths.js';
35
50
  export { resolveRateLimit, resolveNotifications } from './src/define_config.js';
36
51
  export { createAuthThrottles } from './src/host/rate_limit.js';
37
52
  /**
@@ -421,6 +421,62 @@ export interface MagicLinkCapability {
421
421
  */
422
422
  consumeMagicLinkToken(token: string): Promise<AuthAccount | null>;
423
423
  }
424
+ /** Resultado tipado da verificação de um código OTP de login. */
425
+ export type OtpLoginVerifyResult = {
426
+ status: 'ok';
427
+ account: AuthAccount;
428
+ }
429
+ /** Código errado, tentativa contabilizada (ainda NÃO travado). */
430
+ | {
431
+ status: 'invalid';
432
+ }
433
+ /** Tentativas esgotadas → código invalidado (o link continua válido). */
434
+ | {
435
+ status: 'locked';
436
+ }
437
+ /** TTL do código expirou. */
438
+ | {
439
+ status: 'expired';
440
+ }
441
+ /** Nenhum código pendente para esta interaction/conta. */
442
+ | {
443
+ status: 'no_code';
444
+ };
445
+ /**
446
+ * Login por OTP (código digitável) — extensão do magic link. CAPACIDADE
447
+ * opcional: quando ausente (ou `login.otp.enabled` desligado) o comportamento é
448
+ * exatamente o de antes (só magic link).
449
+ *
450
+ * O store default (Lucid) CO-LOCALIZA o código com o magic link no MESMO slot
451
+ * (`passwordResetToken`, prefixo `ml2:`), de modo que consumir um mata o outro
452
+ * (single-use conjunto) e o contador de tentativas fica PERSISTIDO junto do
453
+ * código (lockout fail-closed, sem depender de limiter). Ver `host/otp_login.ts`
454
+ * para a decisão de armazenamento completa.
455
+ */
456
+ export interface OtpLoginCapability {
457
+ /**
458
+ * Emite o magic link E um código OTP de uma vez (mesmo disparo/e-mail). O
459
+ * `token` retornado vai na URL do link; o `code` (dígitos) vai no corpo do
460
+ * e-mail. Retorna null se a conta não existe (o controller sempre responde
461
+ * "enviado", anti-enumeração). O código fica atrelado ao `uid` da interaction.
462
+ */
463
+ issueMagicLinkWithCode(email: string, uid: string, opts: {
464
+ digits: number;
465
+ ttlMinutes: number;
466
+ }): Promise<{
467
+ token: string;
468
+ code: string;
469
+ account: AuthAccount;
470
+ } | null>;
471
+ /**
472
+ * Verifica um código para a interaction `uid`. Em sucesso consome o código E o
473
+ * magic link (single-use conjunto). Falha incrementa o contador persistido; ao
474
+ * esgotar `maxAttempts` invalida o código mantendo o link válido.
475
+ */
476
+ verifyLoginCode(email: string, uid: string, code: string, opts: {
477
+ maxAttempts: number;
478
+ }): Promise<OtpLoginVerifyResult>;
479
+ }
424
480
  /** DTO público de uma organização. */
425
481
  export interface OrgSummary {
426
482
  id: string;
@@ -543,7 +599,7 @@ export type AccountStore = CoreAccountStore & {
543
599
  * blocos `Partial<...>` de capacidades probáveis.
544
600
  */
545
601
  readonly connectionName?: string;
546
- } & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability>;
602
+ } & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability>;
547
603
  /** Type guard: o store implementa a capacidade de MFA / TOTP. */
548
604
  export declare function supportsMfa(store: AccountStore): store is AccountStore & MfaCapability;
549
605
  /**
@@ -566,6 +622,8 @@ export declare function supportsAccountStatus(store: AccountStore): store is Acc
566
622
  export declare function supportsProfile(store: AccountStore): store is AccountStore & ProfileCapability;
567
623
  /** Type guard: o store implementa login por magic link (passwordless). */
568
624
  export declare function supportsMagicLink(store: AccountStore): store is AccountStore & MagicLinkCapability;
625
+ /** Type guard: o store implementa o login por OTP (código digitável). */
626
+ export declare function supportsOtpLogin(store: AccountStore): store is AccountStore & OtpLoginCapability;
569
627
  /** Type guard: o store consegue dizer se o e-mail de uma conta está verificado. */
570
628
  export declare function supportsEmailVerificationStatus(store: AccountStore): store is AccountStore & EmailVerificationStatusCapability;
571
629
  /** Type guard: o store implementa a deleção (hard delete) da conta. */
@@ -38,6 +38,11 @@ export function supportsProfile(store) {
38
38
  export function supportsMagicLink(store) {
39
39
  return typeof store.issueMagicLinkToken === 'function';
40
40
  }
41
+ /** Type guard: o store implementa o login por OTP (código digitável). */
42
+ export function supportsOtpLogin(store) {
43
+ return (typeof store.issueMagicLinkWithCode === 'function' &&
44
+ typeof store.verifyLoginCode === 'function');
45
+ }
41
46
  /** Type guard: o store consegue dizer se o e-mail de uma conta está verificado. */
42
47
  export function supportsEmailVerificationStatus(store) {
43
48
  return typeof store.isEmailVerified === 'function';
@@ -1,4 +1,4 @@
1
- import type { AccountImportCapability, AccountSecurityCapability, CoreAccountStore, MagicLinkCapability } from '../account_store.js';
1
+ import type { AccountImportCapability, AccountSecurityCapability, CoreAccountStore, MagicLinkCapability, OtpLoginCapability } from '../account_store.js';
2
2
  import type { LucidStoreContext } from './shared.js';
3
3
  /**
4
4
  * Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
@@ -6,4 +6,4 @@ import type { LucidStoreContext } from './shared.js';
6
6
  * (listagem paginada + roles globais) e o self-service de segurança
7
7
  * ({@link AccountSecurityCapability}: trocar senha/e-mail).
8
8
  */
9
- export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability & MagicLinkCapability & AccountImportCapability;
9
+ export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability & MagicLinkCapability & OtpLoginCapability & AccountImportCapability;
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt';
3
3
  import { DateTime } from 'luxon';
4
+ import { OTP_LOGIN_PREFIX, decodeOtpToken, encodeOtpToken, evaluateLoginOtp, generateOtpCode, hashLoginOtp, linkTokenFromOtpUrl, } from '../../host/otp_login.js';
4
5
  import { hasColumn } from './status_profile.js';
5
6
  /** Prefixo do token de troca de e-mail (reaproveita a coluna emailVerificationToken). */
6
7
  const EMAIL_CHANGE_PREFIX = 'ec:';
@@ -131,9 +132,9 @@ export function buildCore(ctx) {
131
132
  return { token, account: toAccount(row) };
132
133
  },
133
134
  async consumePasswordResetToken(token, newPassword) {
134
- // Magic links (`ml:`) NÃO são tokens de reset de senha — só o fluxo de
135
- // consumeMagicLinkToken pode consumi-los (não trocam senha).
136
- if (token.startsWith(MAGIC_LINK_PREFIX))
135
+ // Magic links (`ml:` e `ml2:` com OTP) NÃO são tokens de reset de senha —
136
+ // só o fluxo de consumeMagicLinkToken pode consumi-los (não trocam senha).
137
+ if (token.startsWith(MAGIC_LINK_PREFIX) || token.startsWith(OTP_LOGIN_PREFIX))
137
138
  return false;
138
139
  const row = await Model.query().where('passwordResetToken', token).first();
139
140
  if (!row)
@@ -167,7 +168,29 @@ export function buildCore(ctx) {
167
168
  return { token, account: toAccount(row) };
168
169
  },
169
170
  async consumeMagicLinkToken(token) {
170
- if (!token || !token.startsWith(MAGIC_LINK_PREFIX))
171
+ if (!token)
172
+ return null;
173
+ // Magic link com OTP ativo: o slot guarda `ml2:<linkToken>:<...>` mas a URL
174
+ // carrega só `ml2:<linkToken>`. Busca pelo prefixo do link (linkToken é hex
175
+ // validado — sem metacaractere de LIKE) e consome o slot inteiro (mata o
176
+ // código junto — single-use conjunto).
177
+ if (token.startsWith(OTP_LOGIN_PREFIX)) {
178
+ const linkToken = linkTokenFromOtpUrl(token);
179
+ if (!linkToken)
180
+ return null;
181
+ const row = await Model.query()
182
+ .where('passwordResetToken', 'like', `${OTP_LOGIN_PREFIX}${linkToken}:%`)
183
+ .first();
184
+ if (!row)
185
+ return null;
186
+ if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
187
+ return null;
188
+ row.passwordResetToken = null;
189
+ row.passwordResetExpiresAt = null;
190
+ await row.save();
191
+ return toAccount(row);
192
+ }
193
+ if (!token.startsWith(MAGIC_LINK_PREFIX))
171
194
  return null;
172
195
  const row = await Model.query().where('passwordResetToken', token).first();
173
196
  if (!row)
@@ -180,6 +203,84 @@ export function buildCore(ctx) {
180
203
  await row.save();
181
204
  return toAccount(row);
182
205
  },
206
+ // ----- Login por OTP (código digitável — extensão do magic link) -----
207
+ async issueMagicLinkWithCode(email, uid, opts) {
208
+ const row = await Model.query().where('email', email).first();
209
+ if (!row)
210
+ return null;
211
+ const linkToken = randomBytes(32).toString('hex');
212
+ const code = generateOtpCode(opts.digits);
213
+ const codeHash = hashLoginOtp(uid, code);
214
+ const codeExpMs = DateTime.now().plus({ minutes: opts.ttlMinutes }).toMillis();
215
+ // Slot `ml2:` — código + link juntos, contador em 0. Ver host/otp_login.ts.
216
+ row.passwordResetToken = encodeOtpToken({ linkToken, codeHash, codeExpMs, attempts: 0 });
217
+ // O LINK herda a validade padrão do magic link (15 min); o CÓDIGO carrega o
218
+ // próprio `codeExpMs` (mais curto) embutido no slot.
219
+ row.passwordResetExpiresAt = DateTime.now().plus({ minutes: 15 });
220
+ await row.save();
221
+ return { token: `${OTP_LOGIN_PREFIX}${linkToken}`, code, account: toAccount(row) };
222
+ },
223
+ async verifyLoginCode(email, uid, code, opts) {
224
+ // ── Atomicidade do contador de lockout (barreira PRIMÁRIA, fail-closed) ──
225
+ // O contador de tentativas vive DENTRO do slot `ml2:` e é a única barreira
226
+ // contra brute-force do código curto (o throttle de rota é camada EXTRA e
227
+ // pode estar ausente). Um read-modify-write ingênuo (first→avaliar→save) é
228
+ // derrotável por concorrência: N requests leem o MESMO contador, todos
229
+ // gravam `attempts+1` (last-write-wins) e o lockout nunca dispara — pior,
230
+ // como a COMPARAÇÃO do código acontece após a leitura, N requests
231
+ // concorrentes conseguem N comparações contra o MESMO valor do contador,
232
+ // varrendo o espaço de 10^6 dentro do TTL.
233
+ //
234
+ // Correção: serializa o read-compare-write numa TRANSAÇÃO com row-lock
235
+ // (`forUpdate`). Cada tentativa lê o estado JÁ commitado pela anterior, o
236
+ // contador avança 1-a-1 e — porque a comparação vive DENTRO da seção
237
+ // crítica — o total de comparações contra um mesmo código fica limitado a
238
+ // `maxAttempts` (garantia DURA, não probabilística). No Postgres o lock é
239
+ // por linha; no sqlite a própria transação serializa. Os demais caminhos
240
+ // que tocam o slot (`consumeMagicLinkToken`, sucesso do OTP) só gravam
241
+ // `null` (terminal) — não regridem contador — e ainda serializam atrás
242
+ // deste lock (todo UPDATE trava a linha), então não podem ressuscitar um
243
+ // slot já consumido nem apagar um incremento.
244
+ const trx = await Model.query().client.transaction();
245
+ try {
246
+ const row = await Model.query({ client: trx }).where('email', email).forUpdate().first();
247
+ if (!row) {
248
+ await trx.commit();
249
+ return { status: 'no_code' };
250
+ }
251
+ const parsed = decodeOtpToken(row.passwordResetToken);
252
+ const evaluation = evaluateLoginOtp({
253
+ parsed,
254
+ uid,
255
+ code,
256
+ nowMs: DateTime.now().toMillis(),
257
+ maxAttempts: opts.maxAttempts,
258
+ });
259
+ // Efeito de persistência: `undefined` = não escreve; `null` = limpa o slot
260
+ // (sucesso, mata o link junto); string = novo slot (contador++/invalidação).
261
+ // A escrita ocorre DENTRO da mesma transação/lock da leitura.
262
+ if (evaluation.nextToken === null) {
263
+ row.useTransaction(trx);
264
+ row.passwordResetToken = null;
265
+ row.passwordResetExpiresAt = null;
266
+ await row.save();
267
+ }
268
+ else if (typeof evaluation.nextToken === 'string') {
269
+ // Contador/invalidação: preserva a validade do LINK (só o código muda).
270
+ row.useTransaction(trx);
271
+ row.passwordResetToken = evaluation.nextToken;
272
+ await row.save();
273
+ }
274
+ await trx.commit();
275
+ if (evaluation.result === 'ok')
276
+ return { status: 'ok', account: toAccount(row) };
277
+ return { status: evaluation.result };
278
+ }
279
+ catch (error) {
280
+ await trx.rollback();
281
+ throw error;
282
+ }
283
+ },
183
284
  async issueEmailVerificationToken(email) {
184
285
  const row = await Model.query().where('email', email).first();
185
286
  if (!row)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP.
3
3
  */
4
- export type AuditEventType = 'login.success' | 'login.failure' | 'signup' | 'password_reset.issued' | 'password_reset.consumed' | 'pat.issued' | 'pat.revoked' | 'pat.used' | 'impersonation' | 'impersonation.started' | 'mfa.enabled' | 'mfa.disabled' | 'account.locked' | 'passkey.registered' | 'passkey.removed' | 'email_verification.issued' | 'email_verification.consumed' | 'client.created' | 'client.updated' | 'client.deleted' | 'session.revoked_all' | 'password.changed' | 'password.rehashed' | 'email.change_requested' | 'email.changed' | 'login.new_ip_notified' | 'login.new_device' | 'bot_protection.rejected' | 'grant.revoked_by_user' | 'user.created' | 'user.password_reset_sent' | 'user.disabled' | 'user.enabled' | 'user.deleted' | 'profile.updated' | 'account.deleted' | 'account.exported' | 'keys.rotated' | 'organization.created' | 'organization.updated' | 'organization.deleted' | 'organization.member_added' | 'organization.member_removed' | 'organization.member_role_changed' | 'organization.member_role_updated' | 'organization.switched' | 'organization.deactivated' | 'organization.invitation_sent' | 'organization.invitation_accepted' | 'organization.invitation_revoked' | 'email_change.requested' | 'email_change.confirmed' | 'email_change.cancelled' | 'security_notice.sent' | 'settings.updated' | 'maintenance.enabled' | 'maintenance.disabled' | 'trusted_device.revoked' | 'password.expired_change_forced' | 'otp.locked' | 'otp.unlocked' | 'otp.unlock_failed' | 'sudo.confirmed' | 'session.single_enforced' | 'account.expired_login_blocked' | 'account.expiration_warned';
4
+ export type AuditEventType = 'login.success' | 'login.failure' | 'signup' | 'password_reset.issued' | 'password_reset.consumed' | 'pat.issued' | 'pat.revoked' | 'pat.used' | 'impersonation' | 'impersonation.started' | 'mfa.enabled' | 'mfa.disabled' | 'account.locked' | 'passkey.registered' | 'passkey.removed' | 'email_verification.issued' | 'email_verification.consumed' | 'client.created' | 'client.updated' | 'client.deleted' | 'session.revoked_all' | 'password.changed' | 'password.rehashed' | 'email.change_requested' | 'email.changed' | 'login.new_ip_notified' | 'login.new_device' | 'login.otp_sent' | 'login.otp_verified' | 'login.otp_failed' | 'login.otp_invalidated' | 'bot_protection.rejected' | 'grant.revoked_by_user' | 'user.created' | 'user.password_reset_sent' | 'user.disabled' | 'user.enabled' | 'user.deleted' | 'profile.updated' | 'account.deleted' | 'account.exported' | 'keys.rotated' | 'organization.created' | 'organization.updated' | 'organization.deleted' | 'organization.member_added' | 'organization.member_removed' | 'organization.member_role_changed' | 'organization.member_role_updated' | 'organization.switched' | 'organization.deactivated' | 'organization.invitation_sent' | 'organization.invitation_accepted' | 'organization.invitation_revoked' | 'email_change.requested' | 'email_change.confirmed' | 'email_change.cancelled' | 'security_notice.sent' | 'settings.updated' | 'maintenance.enabled' | 'maintenance.disabled' | 'trusted_device.revoked' | 'password.expired_change_forced' | 'otp.locked' | 'otp.unlocked' | 'otp.unlock_failed' | 'sudo.confirmed' | 'session.single_enforced' | 'account.expired_login_blocked' | 'account.expiration_warned';
5
5
  /**
6
6
  * Evento de auditoria a registrar. O timestamp é definido pelo sink (não aqui).
7
7
  */
@@ -8,6 +8,7 @@ import { type BotProtectionConfigInput, type ResolvedBotProtectionConfig } from
8
8
  import type { BrandingConfig } from './host/branding.js';
9
9
  import type { ResolveGeo } from './host/geo.js';
10
10
  import { type AuthMessages, type I18nConfig } from './host/i18n.js';
11
+ import { type OtpLoginConfigInput, type ResolvedOtpLoginConfig } from './host/otp_login.js';
11
12
  import type { SudoMethod } from './host/sudo/types.js';
12
13
  import { type ResolvedTrustedDevicesConfig, type TrustedDevicesConfigInput } from './host/trusted_device.js';
13
14
  import type { PatStore } from './pat/pat_store.js';
@@ -40,6 +41,12 @@ export interface MailHooks {
40
41
  email: string;
41
42
  magicUrl: string;
42
43
  token: string;
44
+ /**
45
+ * Código OTP de login, presente APENAS quando `login.otp.enabled` está
46
+ * ligado. O host pode montar o próprio e-mail com link E código. Ausente no
47
+ * fluxo só-magic-link (back-compat).
48
+ */
49
+ code?: string;
43
50
  }) => Promise<void>;
44
51
  /**
45
52
  * Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo). Distinto de
@@ -191,6 +198,13 @@ export interface ResolvedRateLimitConfig {
191
198
  * afrouxar — o ponto é separar a CONTAGEM, não o teto.
192
199
  */
193
200
  sudo: RateLimitBucket;
201
+ /**
202
+ * Bucket da verificação de código OTP de login (`/auth/interaction/:uid/otp-verify`),
203
+ * keyed por IP. MAIS APERTADO que o login (5/min vs 10/min): um código de 6
204
+ * dígitos é adivinhável, então o teto por IP é a primeira barreira anti-brute
205
+ * force ANTES do lockout por interaction (contador persistido no slot do código).
206
+ */
207
+ otpLogin: RateLimitBucket;
194
208
  store?: string;
195
209
  }
196
210
  export declare function resolveRateLimit(input?: RateLimitConfigInput): ResolvedRateLimitConfig;
@@ -440,9 +454,17 @@ export declare function resolveAuthMethodsConfig(input?: AuthMethodsConfigInput)
440
454
  export interface LoginConfigInput {
441
455
  /** Exige e-mail verificado para autenticar (senha/magic link/passkey-first). Default: false. */
442
456
  requireVerifiedEmail?: boolean;
457
+ /**
458
+ * Login por OTP (código digitável) — extensão do magic link. Quando ligado, o
459
+ * MESMO e-mail passa a carregar link E código, os dois completando a mesma
460
+ * interaction. Default: **desligado** (opt-in; sem a config o comportamento é
461
+ * idêntico ao de antes, e-mail idêntico). Ver `host/otp_login.ts`.
462
+ */
463
+ otp?: OtpLoginConfigInput;
443
464
  }
444
465
  export interface ResolvedLoginConfig {
445
466
  requireVerifiedEmail: boolean;
467
+ otp: ResolvedOtpLoginConfig;
446
468
  }
447
469
  export declare function resolveLogin(input?: LoginConfigInput): ResolvedLoginConfig;
448
470
  /**
@@ -4,6 +4,7 @@ import { composeAuditSink, resolveEvents } from './events/dispatcher.js';
4
4
  import { resolveBotProtection, } from './host/bot_protection.js';
5
5
  import { deriveLockedSettingKeys } from './host/config_locks.js';
6
6
  import { resolveMessages } from './host/i18n.js';
7
+ import { resolveOtpLoginConfig, } from './host/otp_login.js';
7
8
  import { edgeRenderer } from './host/renderers/edge_renderer.js';
8
9
  import { resolveTrustedDevices, } from './host/trusted_device.js';
9
10
  import { generateJwks } from './keys/jwks_manager.js';
@@ -17,6 +18,8 @@ const RATE_LIMIT_DEFAULTS = {
17
18
  adminIp: { points: 30, duration: '1 min' },
18
19
  // Mesmos limites do login, bucket separado — ver `ResolvedRateLimitConfig.sudo`.
19
20
  sudo: { points: 10, duration: '1 min' },
21
+ // Mais apertado que o login — verificação de código adivinhável.
22
+ otpLogin: { points: 5, duration: '1 min' },
20
23
  };
21
24
  export function resolveRateLimit(input) {
22
25
  const enabled = input?.enabled ?? true;
@@ -26,6 +29,7 @@ export function resolveRateLimit(input) {
26
29
  introspection: RATE_LIMIT_DEFAULTS.introspection,
27
30
  adminIp: RATE_LIMIT_DEFAULTS.adminIp,
28
31
  sudo: RATE_LIMIT_DEFAULTS.sudo,
32
+ otpLogin: RATE_LIMIT_DEFAULTS.otpLogin,
29
33
  store: input?.store,
30
34
  };
31
35
  }
@@ -119,6 +123,7 @@ export function resolveAuthMethodsConfig(input) {
119
123
  export function resolveLogin(input) {
120
124
  return {
121
125
  requireVerifiedEmail: input?.requireVerifiedEmail ?? false,
126
+ otp: resolveOtpLoginConfig(input?.otp),
122
127
  };
123
128
  }
124
129
  export function resolveRegistration(input) {
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Tipos de props das telas do console de conta (`/account/*`) — a FONTE ÚNICA da
3
+ * verdade sobre o shape que cada página React recebe.
4
+ *
5
+ * ── Por que esses tipos existem ──────────────────────────────────────────────
6
+ * Um host que cria as telas do console em React próprio (via `inertiaRenderer`,
7
+ * em vez das views Edge built-in) precisa tipar o componente da página. Antes
8
+ * disso, o único "contrato" era o DOCBLOCK do `inertiaRenderer`, copiado à mão —
9
+ * frágil: um docblock desatualizado já enganou um host. Agora o shape vem daqui,
10
+ * e os CONTROLLERS REAIS (`account_session_controller`, `account_security_-
11
+ * controller`, `account_mfa_controller`, `account_confirm_controller`) satisfazem
12
+ * (`satisfies Omit<…, 'messages'>`) exatamente estes tipos ao chamar `render()`.
13
+ * Se o payload de um controller divergir do tipo — campo a mais, a menos, tipo
14
+ * trocado — o `tsc` do pacote quebra. Esse é o objetivo: um único ponto muda, e
15
+ * o compilador força o outro a acompanhar.
16
+ *
17
+ * ── Sobre a prop `messages` ──────────────────────────────────────────────────
18
+ * `messages` (catálogo i18n) é injetada pelo `inertiaRenderer` como shared prop,
19
+ * NÃO pelos controllers. Por isso ela faz parte destes tipos (é o que a página
20
+ * React recebe), mas os controllers satisfazem `Omit<…, 'messages'>` — eles nunca
21
+ * passam `messages` no literal do `render()`.
22
+ */
23
+ import type { PasskeySummary } from '../accounts/account_store.js';
24
+ import type { AuthMessages } from './i18n.js';
25
+ import type { SudoMethodDescriptor } from './sudo/types.js';
26
+ /**
27
+ * Props da tela `account/login` (tela de login do console de conta).
28
+ *
29
+ * - `csrfToken`: token para o campo `_csrf` do formulário.
30
+ * - `returnTo`: caminho interno de destino pós-login (já validado pelo servidor —
31
+ * só caminhos internos) ou `null`. Quando presente, o formulário deve incluir
32
+ * `<input type="hidden" name="return_to" value={returnTo} />`; o servidor
33
+ * revalida no POST.
34
+ * - `error`: mensagem de erro de autenticação localizada (credenciais inválidas,
35
+ * conta bloqueada/desabilitada). Ausente quando não há erro.
36
+ * - `messages`: catálogo i18n (injetado pelo renderer).
37
+ */
38
+ export interface AccountLoginProps {
39
+ csrfToken: string;
40
+ returnTo: string | null;
41
+ error?: string;
42
+ messages: AuthMessages;
43
+ }
44
+ /**
45
+ * Props da tela `account/security` (perfil, senha, e-mail, sessões, export,
46
+ * danger-zone). Todas as flags de `*Supported` degradam a UI quando o store não
47
+ * suporta a capacidade correspondente.
48
+ *
49
+ * - `supported`: `false` quando o store não suporta o self-service de segurança.
50
+ * - `profileSupported`: `true` quando dá para editar nome/avatar (`updateProfile`).
51
+ * - `avatarUploadSupported`: `true` quando algum backend (drive OU media) armazena o upload.
52
+ * - `email` / `name` / `avatarUrl`: valores atuais da conta (`''` se ausentes).
53
+ * - `passwordChanged` / `emailChangeRequested` / `emailChanged` / `profileUpdated`
54
+ * / `error` / `trustedDevicesRevoked` / `deleteError`: flashes localizados ou `null`.
55
+ * - `trustedDevicesEnabled`: recurso de dispositivos confiáveis ligado.
56
+ * - `sessionsSupported`: `true` quando o adapter OIDC enumera as sessões ativas.
57
+ * - `sessions`: sessões ativas da própria conta (vazio quando não suportado).
58
+ * `loginTs` é ISO ou `''`.
59
+ * - `exportSupported`: sempre `true` (portabilidade/LGPD para a conta logada).
60
+ * - `deletionSupported`: `true` quando o store suporta hard delete.
61
+ * - `messages`: catálogo i18n (injetado pelo renderer).
62
+ */
63
+ export interface AccountSecurityProps {
64
+ csrfToken: string;
65
+ supported: boolean;
66
+ profileSupported: boolean;
67
+ avatarUploadSupported: boolean;
68
+ email: string;
69
+ name: string;
70
+ avatarUrl: string;
71
+ passwordChanged: string | null;
72
+ emailChangeRequested: string | null;
73
+ emailChanged: string | null;
74
+ profileUpdated: string | null;
75
+ error: string | null;
76
+ trustedDevicesEnabled: boolean;
77
+ trustedDevicesRevoked: string | null;
78
+ sessionsSupported: boolean;
79
+ sessions: Array<{
80
+ loginTs: string;
81
+ browser: string;
82
+ os: string;
83
+ ip: string;
84
+ location: string;
85
+ }>;
86
+ exportSupported: boolean;
87
+ deletionSupported: boolean;
88
+ deleteError: string | null;
89
+ messages: AuthMessages;
90
+ }
91
+ /**
92
+ * Props da tela `account/mfa` (TOTP + passkeys). As props VARIAM por action, e é
93
+ * por isso que as específicas de passo são opcionais:
94
+ *
95
+ * - `index` manda o estado base + a lista de passkeys (`passkeysSupported` /
96
+ * `passkeys`).
97
+ * - `enroll` (após `POST /mfa/enroll`) acrescenta o passo do QR: `enrolling: true`,
98
+ * `secret` (base32 para entrada manual) e `qrDataUrl` (data-URL do `otpauth://`).
99
+ * - `confirm` com código inválido reexibe `enrolling: true` + `error`, com
100
+ * `secret`/`qrDataUrl` `null` (o segredo pendente NÃO é regenerado).
101
+ *
102
+ * - `enabled`: `true` quando o TOTP já está confirmado.
103
+ * - `recoveryCodes`: códigos recém-gerados (exibidos UMA vez) ou `null`.
104
+ * - `messages`: catálogo i18n (injetado pelo renderer).
105
+ */
106
+ export interface AccountMfaProps {
107
+ csrfToken: string;
108
+ enabled: boolean;
109
+ recoveryCodes: string[] | null;
110
+ /** Presente em `index`: `true` quando o store persiste credenciais WebAuthn. */
111
+ passkeysSupported?: boolean;
112
+ /** Presente em `index`: passkeys cadastradas (vazio quando não suportado). */
113
+ passkeys?: PasskeySummary[];
114
+ /** Passo de enroll/confirm: `true` mostra QR/segredo + campo de código. */
115
+ enrolling?: boolean;
116
+ /** Segredo TOTP (base32) no passo de enroll; `null` na reexibição do confirm. */
117
+ secret?: string | null;
118
+ /** QR do `otpauth://` como data-URL no passo de enroll; `null` na reexibição. */
119
+ qrDataUrl?: string | null;
120
+ /** Erro localizado (ex.: código TOTP inválido no confirm). */
121
+ error?: string;
122
+ messages: AuthMessages;
123
+ }
124
+ /**
125
+ * Um método de sudo, como a tela `account/confirm` o recebe: o descritor do SPI
126
+ * (`SudoMethodDescriptor`) acrescido do `id` estável do método. A tela renderiza
127
+ * por `kind` (`form`/`action`/`redirect`/`webauthn`); `endpoint` é o POST de
128
+ * verificação (para `webauthn`, as options ficam em `${endpoint}/options`).
129
+ */
130
+ export type AccountConfirmMethod = {
131
+ id: string;
132
+ } & SudoMethodDescriptor;
133
+ /**
134
+ * Props da tela `account/confirm` (sudo — confirmar identidade).
135
+ *
136
+ * - `csrfToken`: token para o POST de cada método.
137
+ * - `returnTo`: caminho interno de destino após confirmar (validado) ou `null`.
138
+ * - `error`: flash de erro da última tentativa ou `null`.
139
+ * - `notice`: flash informativo (ex.: "link de confirmação enviado") ou `null`.
140
+ * - `methods`: métodos de sudo disponíveis para a conta (ver {@link AccountConfirmMethod}).
141
+ * - `preferredId`: `id` do último método usado (destaque na UI) ou `null`.
142
+ * - `messages`: catálogo i18n (injetado pelo renderer).
143
+ */
144
+ export interface AccountConfirmProps {
145
+ csrfToken: string;
146
+ returnTo: string | null;
147
+ error: string | null;
148
+ notice: string | null;
149
+ methods: AccountConfirmMethod[];
150
+ preferredId: string | null;
151
+ messages: AuthMessages;
152
+ }
153
+ /**
154
+ * Props da tela `account/email-confirmed` (terminal do link de troca de e-mail).
155
+ *
156
+ * - `ok`: `true` quando o token era válido e o novo e-mail foi aplicado; `false`
157
+ * para token inválido/expirado ou store sem suporte.
158
+ * - `messages`: catálogo i18n (injetado pelo renderer).
159
+ */
160
+ export interface AccountEmailConfirmedProps {
161
+ ok: boolean;
162
+ messages: AuthMessages;
163
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Tipos de props das telas do console de conta (`/account/*`) — a FONTE ÚNICA da
3
+ * verdade sobre o shape que cada página React recebe.
4
+ *
5
+ * ── Por que esses tipos existem ──────────────────────────────────────────────
6
+ * Um host que cria as telas do console em React próprio (via `inertiaRenderer`,
7
+ * em vez das views Edge built-in) precisa tipar o componente da página. Antes
8
+ * disso, o único "contrato" era o DOCBLOCK do `inertiaRenderer`, copiado à mão —
9
+ * frágil: um docblock desatualizado já enganou um host. Agora o shape vem daqui,
10
+ * e os CONTROLLERS REAIS (`account_session_controller`, `account_security_-
11
+ * controller`, `account_mfa_controller`, `account_confirm_controller`) satisfazem
12
+ * (`satisfies Omit<…, 'messages'>`) exatamente estes tipos ao chamar `render()`.
13
+ * Se o payload de um controller divergir do tipo — campo a mais, a menos, tipo
14
+ * trocado — o `tsc` do pacote quebra. Esse é o objetivo: um único ponto muda, e
15
+ * o compilador força o outro a acompanhar.
16
+ *
17
+ * ── Sobre a prop `messages` ──────────────────────────────────────────────────
18
+ * `messages` (catálogo i18n) é injetada pelo `inertiaRenderer` como shared prop,
19
+ * NÃO pelos controllers. Por isso ela faz parte destes tipos (é o que a página
20
+ * React recebe), mas os controllers satisfazem `Omit<…, 'messages'>` — eles nunca
21
+ * passam `messages` no literal do `render()`.
22
+ */
23
+ export {};