@adonis-agora/authkit-server 0.49.0 → 0.51.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.
@@ -190,9 +190,32 @@
190
190
  @end
191
191
  @end
192
192
 
193
- {{-- Passwordless: confirmação de magic link enviado (anti-enumeração). --}}
193
+ {{-- Passwordless: confirmação de magic link enviado (anti-enumeração).
194
+ `magicChannel` (choose-first) escolhe a sub-view: 'code' = só o campo de
195
+ código, 'link' = só o aviso de link, 'both'/ausente = ambos (histórico). --}}
194
196
  @if(magicLinkSent)
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>
197
+ @if(magicChannel !== 'code')
198
+ <p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
199
+ @end
200
+
201
+ {{-- Login por OTP: campo de código digitável (mesmo e-mail carrega link E código). --}}
202
+ @if(otpEnabled && magicChannel !== 'link')
203
+ <form method="POST" action="/auth/interaction/{{ uid }}/otp-verify" class="mt-4">
204
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
205
+ <input type="hidden" name="channel" value="code">
206
+ <label for="otp-code" class="block text-sm font-medium text-gray-700">{{ t('login.otp_label') }}</label>
207
+ <input id="otp-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code"
208
+ pattern="[0-9]*" placeholder="{{ t('login.otp_placeholder') }}"
209
+ 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" />
210
+ @if(otpError)
211
+ <p class="mt-2 text-sm text-red-600">{{ otpError }}</p>
212
+ @end
213
+ <button type="submit"
214
+ class="mt-4 w-full rounded-lg bg-gray-900 py-2.5 text-sm font-semibold text-white transition hover:opacity-90">
215
+ {{ t('login.otp_submit') }}
216
+ </button>
217
+ </form>
218
+ @end
196
219
  @end
197
220
 
198
221
  {{-- 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';
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';
@@ -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,19 @@ 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;
50
+ /**
51
+ * Canal escolhido no seletor "choose-first": `'code'` = o host deveria
52
+ * renderizar SÓ o código, `'link'` = SÓ o link. Ausente = ambos (histórico).
53
+ * Puramente de superfície — os dois tokens continuam emitidos co-locados;
54
+ * hosts existentes simplesmente ignoram este campo (back-compat).
55
+ */
56
+ channel?: 'code' | 'link';
43
57
  }) => Promise<void>;
44
58
  /**
45
59
  * Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo). Distinto de
@@ -191,6 +205,13 @@ export interface ResolvedRateLimitConfig {
191
205
  * afrouxar — o ponto é separar a CONTAGEM, não o teto.
192
206
  */
193
207
  sudo: RateLimitBucket;
208
+ /**
209
+ * Bucket da verificação de código OTP de login (`/auth/interaction/:uid/otp-verify`),
210
+ * keyed por IP. MAIS APERTADO que o login (5/min vs 10/min): um código de 6
211
+ * dígitos é adivinhável, então o teto por IP é a primeira barreira anti-brute
212
+ * force ANTES do lockout por interaction (contador persistido no slot do código).
213
+ */
214
+ otpLogin: RateLimitBucket;
194
215
  store?: string;
195
216
  }
196
217
  export declare function resolveRateLimit(input?: RateLimitConfigInput): ResolvedRateLimitConfig;
@@ -440,9 +461,17 @@ export declare function resolveAuthMethodsConfig(input?: AuthMethodsConfigInput)
440
461
  export interface LoginConfigInput {
441
462
  /** Exige e-mail verificado para autenticar (senha/magic link/passkey-first). Default: false. */
442
463
  requireVerifiedEmail?: boolean;
464
+ /**
465
+ * Login por OTP (código digitável) — extensão do magic link. Quando ligado, o
466
+ * MESMO e-mail passa a carregar link E código, os dois completando a mesma
467
+ * interaction. Default: **desligado** (opt-in; sem a config o comportamento é
468
+ * idêntico ao de antes, e-mail idêntico). Ver `host/otp_login.ts`.
469
+ */
470
+ otp?: OtpLoginConfigInput;
443
471
  }
444
472
  export interface ResolvedLoginConfig {
445
473
  requireVerifiedEmail: boolean;
474
+ otp: ResolvedOtpLoginConfig;
446
475
  }
447
476
  export declare function resolveLogin(input?: LoginConfigInput): ResolvedLoginConfig;
448
477
  /**
@@ -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) {
@@ -70,6 +70,17 @@ export default class AuthInteractionController {
70
70
  * inválido/expirado volta ao início do login.
71
71
  */
72
72
  magicLinkConsume(ctx: HttpContext): Promise<any>;
73
+ /**
74
+ * POST /auth/interaction/:uid/otp-verify
75
+ *
76
+ * Verifica o CÓDIGO OTP de login (o mesmo e-mail carrega link E código). Roda
77
+ * atrás do throttle dedicado `authkit_otp_login` (por IP, mais apertado que o
78
+ * login). A ordem das checagens de segurança — lockout (contador persistido no
79
+ * slot) → TTL → comparação constant-time — vive no store (`verifyLoginCode` →
80
+ * `evaluateLoginOtp`). Em sucesso, completa a MESMA interaction que o link
81
+ * completaria (amr `['email']`), consumindo código E link (single-use conjunto).
82
+ */
83
+ otpVerify(ctx: HttpContext): Promise<any>;
73
84
  /**
74
85
  * POST /auth/interaction/:uid/passkey/options
75
86
  *
@@ -1,5 +1,5 @@
1
1
  import '../augmentations.js';
2
- import { supportsMagicLink, supportsPasskeys } from '../../accounts/account_store.js';
2
+ import { supportsMagicLink, supportsOtpLogin, supportsPasskeys, } from '../../accounts/account_store.js';
3
3
  import { AdminSessionsService } from '../admin_sessions_service.js';
4
4
  import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
5
5
  import { brandFor, isFirstParty } from '../branding.js';
@@ -7,6 +7,7 @@ import { sendMagicLinkEmail } from '../default_mailer.js';
7
7
  import { sendOtpUnlockEmail } from '../default_mailer.js';
8
8
  import { translate } from '../i18n.js';
9
9
  import { attemptPasswordLogin, isEmailUnverifiedBlock } from '../login_attempt.js';
10
+ import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
10
11
  import { notifyLoginSuccess } from '../login_notify.js';
11
12
  import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
12
13
  import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
@@ -609,23 +610,49 @@ export default class AuthInteractionController {
609
610
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
610
611
  const email = ctx.session.get(SESSION_KEY);
611
612
  const uid = ctx.request.param('uid');
613
+ // Login por OTP: liga o campo de código na tela "link enviado" quando a config
614
+ // está ligada E o store suporta a capacidade.
615
+ const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
616
+ // Seletor "choose-first": o host pode POSTar `channel=code|link` para pedir que
617
+ // o e-mail e a tela mostrem SÓ aquele método. Ausente/ inválido = both (histórico).
618
+ // NÃO condiciona a emissão de token — os dois continuam saindo co-locados.
619
+ const channel = normalizeLoginChannel(ctx.request.input('channel'));
612
620
  if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
613
- const issued = await cfg.accountStore.issueMagicLinkToken(email);
621
+ const ip = ctx.request.ip?.() ?? null;
622
+ const clientId = details.params.client_id ?? null;
623
+ // Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
624
+ // senão, o magic link puro de sempre.
625
+ const issued = otpEnabled
626
+ ? await cfg.accountStore.issueMagicLinkWithCode(email, uid, {
627
+ digits: cfg.login.otp.digits,
628
+ ttlMinutes: cfg.login.otp.ttlMinutes,
629
+ })
630
+ : await cfg.accountStore.issueMagicLinkToken(email);
614
631
  if (issued) {
632
+ const code = 'code' in issued ? issued.code : undefined;
615
633
  await cfg.audit?.record({
616
634
  type: 'login.magic_link_sent',
617
635
  accountId: issued.account.id,
618
636
  email,
619
- ip: ctx.request.ip?.() ?? null,
620
- clientId: details.params.client_id ?? null,
637
+ ip,
638
+ clientId,
621
639
  });
640
+ if (code) {
641
+ await cfg.audit?.record({
642
+ type: 'login.otp_sent',
643
+ accountId: issued.account.id,
644
+ email,
645
+ ip,
646
+ clientId,
647
+ });
648
+ }
622
649
  const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
623
650
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
624
651
  if (cfg.mail?.onMagicLink) {
625
- await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token });
652
+ await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code, channel });
626
653
  }
627
654
  else {
628
- await sendMagicLinkEmail(ctx, { email, magicUrl });
655
+ await sendMagicLinkEmail(ctx, { email, magicUrl, code, channel });
629
656
  }
630
657
  }
631
658
  }
@@ -639,6 +666,11 @@ export default class AuthInteractionController {
639
666
  account: null,
640
667
  brand,
641
668
  magicLinkSent: true,
669
+ otpEnabled,
670
+ // Prop da tela: qual sub-view do estado `magicLinkSent` mostrar —
671
+ // 'code' (só o campo de código), 'link' (só o aviso de link) ou 'both'
672
+ // (ambos, quando o host não escolheu canal). Back-compat: ausente = 'both'.
673
+ magicChannel: magicChannelProp(channel),
642
674
  });
643
675
  }
644
676
  /**
@@ -700,6 +732,117 @@ export default class AuthInteractionController {
700
732
  ctx.session.forget(SESSION_KEY);
701
733
  await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
702
734
  }
735
+ /**
736
+ * POST /auth/interaction/:uid/otp-verify
737
+ *
738
+ * Verifica o CÓDIGO OTP de login (o mesmo e-mail carrega link E código). Roda
739
+ * atrás do throttle dedicado `authkit_otp_login` (por IP, mais apertado que o
740
+ * login). A ordem das checagens de segurança — lockout (contador persistido no
741
+ * slot) → TTL → comparação constant-time — vive no store (`verifyLoginCode` →
742
+ * `evaluateLoginOtp`). Em sucesso, completa a MESMA interaction que o link
743
+ * completaria (amr `['email']`), consumindo código E link (single-use conjunto).
744
+ */
745
+ async otpVerify(ctx) {
746
+ const service = await ctx.containerResolver.make('authkit.server');
747
+ const cfg = service.config;
748
+ const render = cfg.render;
749
+ const uid = ctx.request.param('uid');
750
+ const ip = ctx.request.ip?.() ?? null;
751
+ const clientId = (await service.interactions.details(ctx)).params.client_id;
752
+ const email = ctx.session.get(SESSION_KEY);
753
+ // Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
754
+ const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
755
+ if (!otpEnabled || !email) {
756
+ return ctx.response.redirect(`/auth/interaction/${uid}`);
757
+ }
758
+ const code = String(ctx.request.input('code', '') ?? '').trim();
759
+ const brand = brandFor(cfg.branding, clientId ?? undefined, undefined);
760
+ // Mantém a sub-view do seletor no re-render de erro (o form de código pode
761
+ // POSTar `channel=code`). Ausente = both (histórico).
762
+ const channel = normalizeLoginChannel(ctx.request.input('channel'));
763
+ const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
764
+ maxAttempts: cfg.login.otp.maxAttempts,
765
+ });
766
+ // Re-render da tela "link enviado" com o campo de código + erro localizado.
767
+ const renderOtpError = async (messageKey) => render(ctx, 'login', {
768
+ ...(await this.#loginMethods(ctx, cfg)),
769
+ uid,
770
+ csrfToken: ctx.request.csrfToken,
771
+ step: 'password',
772
+ email,
773
+ account: null,
774
+ brand,
775
+ magicLinkSent: true,
776
+ otpEnabled: true,
777
+ magicChannel: magicChannelProp(channel),
778
+ otpError: translate(cfg.messages, messageKey),
779
+ });
780
+ if (result.status === 'ok') {
781
+ // E-mail não verificado (LGPD): mesmo com código válido, não materializa a
782
+ // sessão se a política exige verificação. Espelha o magicLinkConsume.
783
+ const runtimeSettings = await getRuntimeSettings(ctx);
784
+ if (await isEmailUnverifiedBlock(cfg, result.account.id, runtimeSettings)) {
785
+ await cfg.audit?.record({
786
+ type: 'login.failure',
787
+ accountId: result.account.id,
788
+ email: result.account.email,
789
+ ip,
790
+ clientId,
791
+ metadata: { stage: 'otp', reason: 'unverified' },
792
+ });
793
+ return render(ctx, 'login', {
794
+ ...(await this.#loginMethods(ctx, cfg)),
795
+ uid,
796
+ csrfToken: ctx.request.csrfToken,
797
+ step: 'password',
798
+ email: result.account.email,
799
+ account: null,
800
+ brand,
801
+ error: translate(cfg.messages, 'errors.email_unverified'),
802
+ });
803
+ }
804
+ await cfg.audit?.record({
805
+ type: 'login.otp_verified',
806
+ accountId: result.account.id,
807
+ email: result.account.email,
808
+ ip,
809
+ clientId,
810
+ });
811
+ await notifyLoginSuccess(ctx, cfg, {
812
+ accountId: result.account.id,
813
+ email: result.account.email,
814
+ ip,
815
+ clientId: clientId ?? null,
816
+ metadata: { method: 'otp' },
817
+ });
818
+ ctx.session.forget(SESSION_KEY);
819
+ return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
820
+ }
821
+ if (result.status === 'locked') {
822
+ // 5ª falha (ou já travado): código invalidado, o LINK continua válido.
823
+ await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
824
+ return renderOtpError('login.otp_locked');
825
+ }
826
+ if (result.status === 'expired') {
827
+ await cfg.audit?.record({
828
+ type: 'login.otp_failed',
829
+ email,
830
+ ip,
831
+ clientId,
832
+ metadata: { reason: 'expired' },
833
+ });
834
+ return renderOtpError('login.otp_expired');
835
+ }
836
+ // 'invalid' (tentativa contabilizada) ou 'no_code'.
837
+ await cfg.audit?.record({
838
+ type: 'login.otp_failed',
839
+ email,
840
+ ip,
841
+ clientId,
842
+ metadata: { reason: result.status },
843
+ });
844
+ return renderOtpError('login.otp_invalid');
845
+ }
703
846
  /**
704
847
  * POST /auth/interaction/:uid/passkey/options
705
848
  *
@@ -62,6 +62,8 @@ export declare function sendNewDeviceLoginEmail(ctx: HttpContext, data: {
62
62
  export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
63
63
  email: string;
64
64
  magicUrl: string;
65
+ code?: string;
66
+ channel?: 'code' | 'link';
65
67
  }): Promise<void>;
66
68
  /**
67
69
  * Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de