@adonis-agora/authkit-server 0.49.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.
@@ -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';
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,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) {
@@ -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';
@@ -609,23 +609,45 @@ export default class AuthInteractionController {
609
609
  const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
610
610
  const email = ctx.session.get(SESSION_KEY);
611
611
  const uid = ctx.request.param('uid');
612
+ // Login por OTP: liga o campo de código na tela "link enviado" quando a config
613
+ // está ligada E o store suporta a capacidade.
614
+ const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
612
615
  if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
613
- const issued = await cfg.accountStore.issueMagicLinkToken(email);
616
+ const ip = ctx.request.ip?.() ?? null;
617
+ const clientId = details.params.client_id ?? null;
618
+ // Com OTP ligado, emite link E código no MESMO disparo (issueMagicLinkWithCode);
619
+ // senão, o magic link puro de sempre.
620
+ const issued = otpEnabled
621
+ ? await cfg.accountStore.issueMagicLinkWithCode(email, uid, {
622
+ digits: cfg.login.otp.digits,
623
+ ttlMinutes: cfg.login.otp.ttlMinutes,
624
+ })
625
+ : await cfg.accountStore.issueMagicLinkToken(email);
614
626
  if (issued) {
627
+ const code = 'code' in issued ? issued.code : undefined;
615
628
  await cfg.audit?.record({
616
629
  type: 'login.magic_link_sent',
617
630
  accountId: issued.account.id,
618
631
  email,
619
- ip: ctx.request.ip?.() ?? null,
620
- clientId: details.params.client_id ?? null,
632
+ ip,
633
+ clientId,
621
634
  });
635
+ if (code) {
636
+ await cfg.audit?.record({
637
+ type: 'login.otp_sent',
638
+ accountId: issued.account.id,
639
+ email,
640
+ ip,
641
+ clientId,
642
+ });
643
+ }
622
644
  const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
623
645
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
624
646
  if (cfg.mail?.onMagicLink) {
625
- await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token });
647
+ await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code });
626
648
  }
627
649
  else {
628
- await sendMagicLinkEmail(ctx, { email, magicUrl });
650
+ await sendMagicLinkEmail(ctx, { email, magicUrl, code });
629
651
  }
630
652
  }
631
653
  }
@@ -639,6 +661,7 @@ export default class AuthInteractionController {
639
661
  account: null,
640
662
  brand,
641
663
  magicLinkSent: true,
664
+ otpEnabled,
642
665
  });
643
666
  }
644
667
  /**
@@ -700,6 +723,113 @@ export default class AuthInteractionController {
700
723
  ctx.session.forget(SESSION_KEY);
701
724
  await service.interactions.completeLogin(ctx, acc.id, { amr: ['email'] });
702
725
  }
726
+ /**
727
+ * POST /auth/interaction/:uid/otp-verify
728
+ *
729
+ * Verifica o CÓDIGO OTP de login (o mesmo e-mail carrega link E código). Roda
730
+ * atrás do throttle dedicado `authkit_otp_login` (por IP, mais apertado que o
731
+ * login). A ordem das checagens de segurança — lockout (contador persistido no
732
+ * slot) → TTL → comparação constant-time — vive no store (`verifyLoginCode` →
733
+ * `evaluateLoginOtp`). Em sucesso, completa a MESMA interaction que o link
734
+ * completaria (amr `['email']`), consumindo código E link (single-use conjunto).
735
+ */
736
+ async otpVerify(ctx) {
737
+ const service = await ctx.containerResolver.make('authkit.server');
738
+ const cfg = service.config;
739
+ const render = cfg.render;
740
+ const uid = ctx.request.param('uid');
741
+ const ip = ctx.request.ip?.() ?? null;
742
+ const clientId = (await service.interactions.details(ctx)).params.client_id;
743
+ const email = ctx.session.get(SESSION_KEY);
744
+ // Guardas: OTP desligado, store sem suporte ou sem e-mail na sessão → volta ao login.
745
+ const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
746
+ if (!otpEnabled || !email) {
747
+ return ctx.response.redirect(`/auth/interaction/${uid}`);
748
+ }
749
+ const code = String(ctx.request.input('code', '') ?? '').trim();
750
+ const brand = brandFor(cfg.branding, clientId ?? undefined, undefined);
751
+ const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
752
+ maxAttempts: cfg.login.otp.maxAttempts,
753
+ });
754
+ // Re-render da tela "link enviado" com o campo de código + erro localizado.
755
+ const renderOtpError = async (messageKey) => render(ctx, 'login', {
756
+ ...(await this.#loginMethods(ctx, cfg)),
757
+ uid,
758
+ csrfToken: ctx.request.csrfToken,
759
+ step: 'password',
760
+ email,
761
+ account: null,
762
+ brand,
763
+ magicLinkSent: true,
764
+ otpEnabled: true,
765
+ otpError: translate(cfg.messages, messageKey),
766
+ });
767
+ if (result.status === 'ok') {
768
+ // E-mail não verificado (LGPD): mesmo com código válido, não materializa a
769
+ // sessão se a política exige verificação. Espelha o magicLinkConsume.
770
+ const runtimeSettings = await getRuntimeSettings(ctx);
771
+ if (await isEmailUnverifiedBlock(cfg, result.account.id, runtimeSettings)) {
772
+ await cfg.audit?.record({
773
+ type: 'login.failure',
774
+ accountId: result.account.id,
775
+ email: result.account.email,
776
+ ip,
777
+ clientId,
778
+ metadata: { stage: 'otp', reason: 'unverified' },
779
+ });
780
+ return render(ctx, 'login', {
781
+ ...(await this.#loginMethods(ctx, cfg)),
782
+ uid,
783
+ csrfToken: ctx.request.csrfToken,
784
+ step: 'password',
785
+ email: result.account.email,
786
+ account: null,
787
+ brand,
788
+ error: translate(cfg.messages, 'errors.email_unverified'),
789
+ });
790
+ }
791
+ await cfg.audit?.record({
792
+ type: 'login.otp_verified',
793
+ accountId: result.account.id,
794
+ email: result.account.email,
795
+ ip,
796
+ clientId,
797
+ });
798
+ await notifyLoginSuccess(ctx, cfg, {
799
+ accountId: result.account.id,
800
+ email: result.account.email,
801
+ ip,
802
+ clientId: clientId ?? null,
803
+ metadata: { method: 'otp' },
804
+ });
805
+ ctx.session.forget(SESSION_KEY);
806
+ return service.interactions.completeLogin(ctx, result.account.id, { amr: ['email'] });
807
+ }
808
+ if (result.status === 'locked') {
809
+ // 5ª falha (ou já travado): código invalidado, o LINK continua válido.
810
+ await cfg.audit?.record({ type: 'login.otp_invalidated', email, ip, clientId });
811
+ return renderOtpError('login.otp_locked');
812
+ }
813
+ if (result.status === 'expired') {
814
+ await cfg.audit?.record({
815
+ type: 'login.otp_failed',
816
+ email,
817
+ ip,
818
+ clientId,
819
+ metadata: { reason: 'expired' },
820
+ });
821
+ return renderOtpError('login.otp_expired');
822
+ }
823
+ // 'invalid' (tentativa contabilizada) ou 'no_code'.
824
+ await cfg.audit?.record({
825
+ type: 'login.otp_failed',
826
+ email,
827
+ ip,
828
+ clientId,
829
+ metadata: { reason: result.status },
830
+ });
831
+ return renderOtpError('login.otp_invalid');
832
+ }
703
833
  /**
704
834
  * POST /auth/interaction/:uid/passkey/options
705
835
  *
@@ -62,6 +62,7 @@ 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;
65
66
  }): Promise<void>;
66
67
  /**
67
68
  * Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de
@@ -259,6 +259,9 @@ export async function sendMagicLinkEmail(ctx, data) {
259
259
  ctaLabel: translate(t, 'mail.magic_link.cta'),
260
260
  ctaUrl: data.magicUrl,
261
261
  footnote: translate(t, 'mail.magic_link.fallback'),
262
+ // Login por OTP: quando o código é fornecido, renderiza-o em destaque.
263
+ code: data.code,
264
+ codeLabel: translate(t, 'mail.magic_link.code_label'),
262
265
  });
263
266
  const sent = await sendEmail(ctx, data.email, content);
264
267
  if (!sent) {
@@ -34,6 +34,14 @@ interface EmailTemplateInput {
34
34
  linkFallback?: string;
35
35
  /** Locale do documento HTML (atributo `lang`). Default: 'en'. */
36
36
  locale?: string;
37
+ /**
38
+ * Código OTP de login (dígitos). Quando presente, é renderizado em destaque
39
+ * (grande, monoespaçado) acima do CTA, com um rótulo. Usado pelo login por OTP
40
+ * (o mesmo e-mail carrega link E código). Ausente = e-mail idêntico ao de antes.
41
+ */
42
+ code?: string;
43
+ /** Rótulo acima do código (i18n). Default em inglês. */
44
+ codeLabel?: string;
37
45
  }
38
46
  export declare function renderTransactionalEmail(input: EmailTemplateInput): EmailContent;
39
47
  export {};
@@ -24,6 +24,16 @@ export function renderTransactionalEmail(input) {
24
24
  const lang = input.locale || 'en';
25
25
  const linkFallback = input.linkFallback ||
26
26
  'If the button does not work, copy and paste this link into your browser:';
27
+ const codeLabel = input.codeLabel || 'Or enter this code:';
28
+ // Bloco do código OTP (grande/monoespaçado), renderizado só quando há código.
29
+ // Termina em '\n' quando presente para manter o <table> seguinte em linha própria;
30
+ // vazio quando ausente (sem linha em branco extra — byte-parity com o e-mail
31
+ // pré-OTP).
32
+ const codeBlock = input.code
33
+ ? `<p style="margin:0 0 8px;font-size:13px;line-height:1.5;color:#6b7280;">${esc(codeLabel)}</p>
34
+ <p style="margin:0 0 24px;font-size:32px;font-weight:700;letter-spacing:6px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;color:#111827;">${esc(input.code)}</p>
35
+ `
36
+ : '';
27
37
  const html = `<!doctype html>
28
38
  <html lang="${esc(lang)}">
29
39
  <head>
@@ -41,7 +51,7 @@ export function renderTransactionalEmail(input) {
41
51
  <tr><td style="padding:32px 28px 8px;">
42
52
  <h1 style="margin:0 0 12px;font-size:20px;line-height:1.3;color:#111827;">${esc(input.heading)}</h1>
43
53
  <p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#374151;">${esc(input.intro)}</p>
44
- <table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
54
+ ${codeBlock}<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
45
55
  <a href="${esc(input.ctaUrl)}" style="display:inline-block;padding:12px 24px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">${esc(input.ctaLabel)}</a>
46
56
  </td></tr></table>
47
57
  ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(input.footnote)}</p>` : ''}
@@ -59,6 +69,7 @@ ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;col
59
69
  input.heading,
60
70
  '',
61
71
  input.intro,
72
+ ...(input.code ? ['', `${codeLabel} ${input.code}`] : []),
62
73
  '',
63
74
  `${input.ctaLabel}: ${input.ctaUrl}`,
64
75
  ...(input.footnote ? ['', input.footnote] : []),
@@ -50,6 +50,12 @@ export declare const DEFAULT_MESSAGES: {
50
50
  'login.magic_link_sent': string;
51
51
  'signup.magic_link_sent': string;
52
52
  'login.passkey_button': string;
53
+ 'login.otp_label': string;
54
+ 'login.otp_placeholder': string;
55
+ 'login.otp_submit': string;
56
+ 'login.otp_invalid': string;
57
+ 'login.otp_expired': string;
58
+ 'login.otp_locked': string;
53
59
  'signup.page_title': string;
54
60
  'signup.title': string;
55
61
  'signup.intro': string;
@@ -511,6 +517,7 @@ export declare const DEFAULT_MESSAGES: {
511
517
  'mail.magic_link.intro': string;
512
518
  'mail.magic_link.cta': string;
513
519
  'mail.magic_link.fallback': string;
520
+ 'mail.magic_link.code_label': string;
514
521
  'mail.new_login.subject': string;
515
522
  'mail.new_login.heading': string;
516
523
  'mail.new_login.intro': string;
@@ -744,6 +751,12 @@ export declare const PT_BR_MESSAGES: {
744
751
  'login.magic_link_sent': string;
745
752
  'signup.magic_link_sent': string;
746
753
  'login.passkey_button': string;
754
+ 'login.otp_label': string;
755
+ 'login.otp_placeholder': string;
756
+ 'login.otp_submit': string;
757
+ 'login.otp_invalid': string;
758
+ 'login.otp_expired': string;
759
+ 'login.otp_locked': string;
747
760
  'signup.page_title': string;
748
761
  'signup.title': string;
749
762
  'signup.intro': string;
@@ -1205,6 +1218,7 @@ export declare const PT_BR_MESSAGES: {
1205
1218
  'mail.magic_link.intro': string;
1206
1219
  'mail.magic_link.cta': string;
1207
1220
  'mail.magic_link.fallback': string;
1221
+ 'mail.magic_link.code_label': string;
1208
1222
  'mail.new_login.subject': string;
1209
1223
  'mail.new_login.heading': string;
1210
1224
  'mail.new_login.intro': string;
@@ -41,6 +41,13 @@ export const DEFAULT_MESSAGES = {
41
41
  'login.magic_link_sent': 'If the account exists, we sent you a login link.',
42
42
  'signup.magic_link_sent': 'Check your email — we sent you a link to finish creating your account.',
43
43
  'login.passkey_button': 'Sign in with a passkey',
44
+ // Login por OTP (código digitável).
45
+ 'login.otp_label': 'Enter the login code from the email',
46
+ 'login.otp_placeholder': '000000',
47
+ 'login.otp_submit': 'Sign in with the code',
48
+ 'login.otp_invalid': 'Invalid code. Please try again.',
49
+ 'login.otp_expired': 'This code has expired. Use the login link or request a new one.',
50
+ 'login.otp_locked': 'Too many attempts. The code was disabled — use the login link instead.',
44
51
  // Tela de cadastro (signup).
45
52
  'signup.page_title': 'Create account',
46
53
  'signup.title': 'Create account',
@@ -550,6 +557,7 @@ export const DEFAULT_MESSAGES = {
550
557
  'mail.magic_link.intro': 'Click the button below to sign in. The link expires shortly and can be used once.',
551
558
  'mail.magic_link.cta': 'Sign in',
552
559
  'mail.magic_link.fallback': 'If you did not request this, you can ignore this email.',
560
+ 'mail.magic_link.code_label': 'Or enter this code to sign in:',
553
561
  'mail.new_login.subject': 'New login to your account',
554
562
  'mail.new_login.heading': 'New login detected',
555
563
  'mail.new_login.intro': 'We detected a new login to your account.',
@@ -814,6 +822,13 @@ export const PT_BR_MESSAGES = {
814
822
  'login.magic_link_sent': 'Se a conta existir, enviamos um link de login.',
815
823
  'signup.magic_link_sent': 'Enviamos um link para o seu e-mail. Abra-o para concluir o cadastro.',
816
824
  'login.passkey_button': 'Entrar com passkey',
825
+ // Login por OTP (código digitável).
826
+ 'login.otp_label': 'Digite o código de login do e-mail',
827
+ 'login.otp_placeholder': '000000',
828
+ 'login.otp_submit': 'Entrar com o código',
829
+ 'login.otp_invalid': 'Código inválido. Tente novamente.',
830
+ 'login.otp_expired': 'Este código expirou. Use o link de login ou peça um novo.',
831
+ 'login.otp_locked': 'Tentativas demais. O código foi desativado — use o link de login.',
817
832
  // Tela de cadastro (signup).
818
833
  'signup.page_title': 'Criar conta',
819
834
  'signup.title': 'Criar conta',
@@ -1319,6 +1334,7 @@ export const PT_BR_MESSAGES = {
1319
1334
  'mail.magic_link.intro': 'Clique no botão abaixo para entrar. O link expira em breve e pode ser usado uma vez.',
1320
1335
  'mail.magic_link.cta': 'Entrar',
1321
1336
  'mail.magic_link.fallback': 'Se você não solicitou isso, pode ignorar este e-mail.',
1337
+ 'mail.magic_link.code_label': 'Ou digite este código para entrar:',
1322
1338
  'mail.new_login.subject': 'Novo login na sua conta',
1323
1339
  'mail.new_login.heading': 'Novo login detectado',
1324
1340
  'mail.new_login.intro': 'Detectamos um novo login na sua conta.',
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Login por OTP (código digitável) — helpers puros + máquina de estados da
3
+ * verificação.
4
+ *
5
+ * ── Por que este módulo existe (e o porquê da decisão de armazenamento) ───────
6
+ * O host passwordless já tem magic link (token de 256 bits, IMPOSSÍVEL de
7
+ * adivinhar). O código de 6 dígitos é ADIVINHÁVEL: exige lockout dedicado +
8
+ * throttle — segurança que não se reimplementa por host. O mesmo e-mail passa a
9
+ * carregar LINK e CÓDIGO; os dois completam a MESMA interaction OIDC.
10
+ *
11
+ * ── Decisão de armazenamento (investigação registrada no código) ─────────────
12
+ * O SPEC ranqueia três opções e manda a investigação decidir. Resultado:
13
+ *
14
+ * 1. (preferida no spec) Guardar `otpHash`/`otpExpiresAt`/`otpAttempts` no
15
+ * REGISTRO DA INTERACTION do oidc-provider — **INVIÁVEL**. O modelo
16
+ * `Interaction` do oidc-provider só persiste os campos listados em
17
+ * `IN_PAYLOAD` (`base_model.js` filtra o payload por
18
+ * `IN_PAYLOAD.includes(key)` no construtor; `save()` chama
19
+ * `getValueAndPayload`). Campos custom de topo são DESCARTADOS ao persistir.
20
+ * O único slot livre persistido é `lastSubmission`, dono do mecanismo
21
+ * `mergeWithLastSubmission` — sequestrá-lo é frágil. Ver
22
+ * `node_modules/oidc-provider/lib/models/interaction.js:57` e
23
+ * `.../base_model.js:34`.
24
+ *
25
+ * 2. (ESCOLHIDA) Formato composto no slot já existente do token de magic link
26
+ * (`passwordResetToken`, hoje `ml:<token>`). Passa a `ml2:<...>` quando o
27
+ * OTP está ligado. Esta opção resolve os TRÊS requisitos duros de uma vez:
28
+ * • **Single-use conjunto** — código e link vivem no MESMO slot da MESMA
29
+ * linha: consumir qualquer um limpa o slot → o outro morre junto, sem
30
+ * coordenação entre stores.
31
+ * • **Contador de tentativas persistido SEM limiter** — o contador vive
32
+ * DENTRO do slot. O lockout é imposto pelo próprio contador persistido
33
+ * (fail-CLOSED: não depende do `@adonisjs/limiter`), ao contrário do
34
+ * `otp_lockout.ts`, que vira no-op sem limiter — perigoso para um código
35
+ * curto. O throttle de rota (`authkit_otp_login`) é camada EXTRA por IP.
36
+ * • **TTL herdado** — a coluna `passwordResetExpiresAt` já dá validade ao
37
+ * link; o código carrega o próprio `codeExpMs` embutido (mais curto).
38
+ *
39
+ * 3. Coluna nova via ensure-schema — desnecessária (a opção 2 não exige
40
+ * migração), então descartada.
41
+ *
42
+ * ── Formato do slot (`ml2:`) ─────────────────────────────────────────────────
43
+ * Armazenado: `ml2:<linkToken>:<codeHash>:<codeExpMs>:<attempts>`
44
+ * Na URL: `ml2:<linkToken>` (SÓ o token do link — o código, o hash e o
45
+ * contador NUNCA saem no e-mail/URL, então o atacante não tem como
46
+ * zerar o contador manipulando o que ele recebe).
47
+ *
48
+ * • `linkToken` — 32 bytes hex; é o token do magic link (mesma força de antes).
49
+ * • `codeHash` — `sha256(<uid>:<code>)` em hex, ou VAZIO quando o código foi
50
+ * invalidado por lockout (o link continua válido e localizável).
51
+ * Atrelar ao `uid` da interaction honra o escopo "por
52
+ * interaction" do spec: um código emitido numa interaction não
53
+ * verifica em outra, mesmo para o mesmo e-mail.
54
+ * • `codeExpMs` — epoch ms de expiração DO CÓDIGO (TTL curto, default 10 min).
55
+ * • `attempts` — contador server-side de tentativas erradas (começa em 0).
56
+ *
57
+ * Segurança do contador: como o link e o código compartilham o slot mas o
58
+ * LOCKOUT do código NÃO pode matar o link (spec), a invalidação por lockout zera
59
+ * o `codeHash` (mantendo `linkToken`) em vez de limpar o slot inteiro.
60
+ */
61
+ /** Config de entrada do login por OTP (`login.otp` no config/authkit.ts). */
62
+ export interface OtpLoginConfigInput {
63
+ /** Liga o login por código. Default: **false** (opt-in, back-compat total). */
64
+ enabled?: boolean;
65
+ /** Número de dígitos do código. Default: 6. Faixa aceita: 4–10. */
66
+ digits?: number;
67
+ /** Validade do código em minutos. Default: 10. Mínimo: 1. */
68
+ ttlMinutes?: number;
69
+ /** Tentativas erradas antes de invalidar o código. Default: 5. Mínimo: 1. */
70
+ maxAttempts?: number;
71
+ }
72
+ export interface ResolvedOtpLoginConfig {
73
+ enabled: boolean;
74
+ digits: number;
75
+ ttlMinutes: number;
76
+ maxAttempts: number;
77
+ }
78
+ export declare const OTP_LOGIN_DEFAULTS: ResolvedOtpLoginConfig;
79
+ /** Resolve/normaliza a config `login.otp` com os defaults e limites de sanidade. */
80
+ export declare function resolveOtpLoginConfig(input?: OtpLoginConfigInput): ResolvedOtpLoginConfig;
81
+ /**
82
+ * Gera um código numérico de `digits` dígitos, zero-padded, SEM viés de módulo.
83
+ *
84
+ * Usa `crypto.randomInt(0, 10 ** digits)` — o `randomInt` do Node faz rejection
85
+ * sampling internamente, então a distribuição é uniforme (nada de `% 10`, que
86
+ * enviesaria os dígitos baixos). Para `digits=6` o teto é 1_000_000, bem abaixo
87
+ * do limite de `randomInt` (2**48).
88
+ */
89
+ export declare function generateOtpCode(digits: number): string;
90
+ /**
91
+ * Hash do código atrelado ao `uid` da interaction: `sha256(<uid>:<code>)` em hex.
92
+ * Atrelar ao uid escopa o código à interaction que o emitiu.
93
+ */
94
+ export declare function hashLoginOtp(uid: string, code: string): string;
95
+ /**
96
+ * Comparação constant-time de dois digests hex de MESMO tamanho.
97
+ *
98
+ * `timingSafeEqual` exige buffers de tamanho igual — comprimentos diferentes
99
+ * lançam. Por isso a guarda de tamanho vem antes (retorno `false` sem vazar
100
+ * timing útil: o atacante não controla o tamanho do digest server-side, que é
101
+ * sempre 64 hex de um sha256).
102
+ */
103
+ export declare function safeEqualHex(a: string, b: string): boolean;
104
+ /** Prefixo do slot `passwordResetToken` quando o login por OTP está ativo. */
105
+ export declare const OTP_LOGIN_PREFIX = "ml2:";
106
+ /** Estado decodificado do slot `ml2:`. */
107
+ export interface ParsedOtpToken {
108
+ linkToken: string;
109
+ /** `sha256(<uid>:<code>)` hex; vazio quando o código foi invalidado (lockout). */
110
+ codeHash: string;
111
+ codeExpMs: number;
112
+ attempts: number;
113
+ }
114
+ /** Serializa o estado do OTP no formato de slot `ml2:...`. */
115
+ export declare function encodeOtpToken(state: ParsedOtpToken): string;
116
+ /**
117
+ * Decodifica o valor ARMAZENADO no slot (`ml2:<linkToken>:<codeHash>:<exp>:<att>`).
118
+ * Retorna `null` se não for um slot `ml2:` bem-formado.
119
+ */
120
+ export declare function decodeOtpToken(value: string | null | undefined): ParsedOtpToken | null;
121
+ /**
122
+ * Extrai o `linkToken` de uma URL de magic link `ml2:<linkToken>` (a forma que
123
+ * vai no e-mail, SEM o estado do código). Retorna `null` se não casar o formato
124
+ * ou se o token não for hex de 64 (guarda contra LIKE injection na busca).
125
+ */
126
+ export declare function linkTokenFromOtpUrl(urlToken: string): string | null;
127
+ export type OtpVerifyOutcome = 'ok' | 'invalid' | 'locked' | 'expired' | 'no_code';
128
+ export interface OtpVerifyEvaluation {
129
+ result: OtpVerifyOutcome;
130
+ /**
131
+ * O que persistir no slot `passwordResetToken` como efeito:
132
+ * • `undefined` — não escrever (nada mudou: expired/no_code/locked-já-travado).
133
+ * • `null` — LIMPAR o slot (sucesso: mata o link junto — single-use conjunto).
134
+ * • string — novo valor `ml2:` (falha: contador++ ou código invalidado).
135
+ */
136
+ nextToken?: string | null;
137
+ }
138
+ /**
139
+ * Avalia UMA tentativa de código, na ORDEM travada pelo spec:
140
+ * lockout (contador/estado do código) → TTL do código → comparação constant-time.
141
+ *
142
+ * O throttle de rota e a validade da interaction são resolvidos ANTES, no
143
+ * controller. Aqui mora só a lógica que precisa do estado persistido do código.
144
+ *
145
+ * IMPORTANTE (prova de mutação): a checagem de LOCKOUT é a primeira guarda. Se
146
+ * removida, um atacante que já esgotou as tentativas volta a poder chutar — o
147
+ * teste `remove-lockout` cobre exatamente isso.
148
+ */
149
+ export declare function evaluateLoginOtp(input: {
150
+ parsed: ParsedOtpToken | null;
151
+ uid: string;
152
+ code: string;
153
+ nowMs: number;
154
+ maxAttempts: number;
155
+ }): OtpVerifyEvaluation;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Login por OTP (código digitável) — helpers puros + máquina de estados da
3
+ * verificação.
4
+ *
5
+ * ── Por que este módulo existe (e o porquê da decisão de armazenamento) ───────
6
+ * O host passwordless já tem magic link (token de 256 bits, IMPOSSÍVEL de
7
+ * adivinhar). O código de 6 dígitos é ADIVINHÁVEL: exige lockout dedicado +
8
+ * throttle — segurança que não se reimplementa por host. O mesmo e-mail passa a
9
+ * carregar LINK e CÓDIGO; os dois completam a MESMA interaction OIDC.
10
+ *
11
+ * ── Decisão de armazenamento (investigação registrada no código) ─────────────
12
+ * O SPEC ranqueia três opções e manda a investigação decidir. Resultado:
13
+ *
14
+ * 1. (preferida no spec) Guardar `otpHash`/`otpExpiresAt`/`otpAttempts` no
15
+ * REGISTRO DA INTERACTION do oidc-provider — **INVIÁVEL**. O modelo
16
+ * `Interaction` do oidc-provider só persiste os campos listados em
17
+ * `IN_PAYLOAD` (`base_model.js` filtra o payload por
18
+ * `IN_PAYLOAD.includes(key)` no construtor; `save()` chama
19
+ * `getValueAndPayload`). Campos custom de topo são DESCARTADOS ao persistir.
20
+ * O único slot livre persistido é `lastSubmission`, dono do mecanismo
21
+ * `mergeWithLastSubmission` — sequestrá-lo é frágil. Ver
22
+ * `node_modules/oidc-provider/lib/models/interaction.js:57` e
23
+ * `.../base_model.js:34`.
24
+ *
25
+ * 2. (ESCOLHIDA) Formato composto no slot já existente do token de magic link
26
+ * (`passwordResetToken`, hoje `ml:<token>`). Passa a `ml2:<...>` quando o
27
+ * OTP está ligado. Esta opção resolve os TRÊS requisitos duros de uma vez:
28
+ * • **Single-use conjunto** — código e link vivem no MESMO slot da MESMA
29
+ * linha: consumir qualquer um limpa o slot → o outro morre junto, sem
30
+ * coordenação entre stores.
31
+ * • **Contador de tentativas persistido SEM limiter** — o contador vive
32
+ * DENTRO do slot. O lockout é imposto pelo próprio contador persistido
33
+ * (fail-CLOSED: não depende do `@adonisjs/limiter`), ao contrário do
34
+ * `otp_lockout.ts`, que vira no-op sem limiter — perigoso para um código
35
+ * curto. O throttle de rota (`authkit_otp_login`) é camada EXTRA por IP.
36
+ * • **TTL herdado** — a coluna `passwordResetExpiresAt` já dá validade ao
37
+ * link; o código carrega o próprio `codeExpMs` embutido (mais curto).
38
+ *
39
+ * 3. Coluna nova via ensure-schema — desnecessária (a opção 2 não exige
40
+ * migração), então descartada.
41
+ *
42
+ * ── Formato do slot (`ml2:`) ─────────────────────────────────────────────────
43
+ * Armazenado: `ml2:<linkToken>:<codeHash>:<codeExpMs>:<attempts>`
44
+ * Na URL: `ml2:<linkToken>` (SÓ o token do link — o código, o hash e o
45
+ * contador NUNCA saem no e-mail/URL, então o atacante não tem como
46
+ * zerar o contador manipulando o que ele recebe).
47
+ *
48
+ * • `linkToken` — 32 bytes hex; é o token do magic link (mesma força de antes).
49
+ * • `codeHash` — `sha256(<uid>:<code>)` em hex, ou VAZIO quando o código foi
50
+ * invalidado por lockout (o link continua válido e localizável).
51
+ * Atrelar ao `uid` da interaction honra o escopo "por
52
+ * interaction" do spec: um código emitido numa interaction não
53
+ * verifica em outra, mesmo para o mesmo e-mail.
54
+ * • `codeExpMs` — epoch ms de expiração DO CÓDIGO (TTL curto, default 10 min).
55
+ * • `attempts` — contador server-side de tentativas erradas (começa em 0).
56
+ *
57
+ * Segurança do contador: como o link e o código compartilham o slot mas o
58
+ * LOCKOUT do código NÃO pode matar o link (spec), a invalidação por lockout zera
59
+ * o `codeHash` (mantendo `linkToken`) em vez de limpar o slot inteiro.
60
+ */
61
+ import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
62
+ export const OTP_LOGIN_DEFAULTS = {
63
+ enabled: false,
64
+ digits: 6,
65
+ ttlMinutes: 10,
66
+ maxAttempts: 5,
67
+ };
68
+ /** Resolve/normaliza a config `login.otp` com os defaults e limites de sanidade. */
69
+ export function resolveOtpLoginConfig(input) {
70
+ const digitsRaw = input?.digits;
71
+ const digits = typeof digitsRaw === 'number' && digitsRaw >= 4 && digitsRaw <= 10
72
+ ? Math.floor(digitsRaw)
73
+ : OTP_LOGIN_DEFAULTS.digits;
74
+ const ttlRaw = input?.ttlMinutes;
75
+ const ttlMinutes = typeof ttlRaw === 'number' && ttlRaw >= 1 ? Math.floor(ttlRaw) : OTP_LOGIN_DEFAULTS.ttlMinutes;
76
+ const maxRaw = input?.maxAttempts;
77
+ const maxAttempts = typeof maxRaw === 'number' && maxRaw >= 1 ? Math.floor(maxRaw) : OTP_LOGIN_DEFAULTS.maxAttempts;
78
+ return {
79
+ enabled: input?.enabled ?? OTP_LOGIN_DEFAULTS.enabled,
80
+ digits,
81
+ ttlMinutes,
82
+ maxAttempts,
83
+ };
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Geração e hashing do código
87
+ // ---------------------------------------------------------------------------
88
+ /**
89
+ * Gera um código numérico de `digits` dígitos, zero-padded, SEM viés de módulo.
90
+ *
91
+ * Usa `crypto.randomInt(0, 10 ** digits)` — o `randomInt` do Node faz rejection
92
+ * sampling internamente, então a distribuição é uniforme (nada de `% 10`, que
93
+ * enviesaria os dígitos baixos). Para `digits=6` o teto é 1_000_000, bem abaixo
94
+ * do limite de `randomInt` (2**48).
95
+ */
96
+ export function generateOtpCode(digits) {
97
+ const max = 10 ** digits;
98
+ const n = randomInt(0, max);
99
+ return String(n).padStart(digits, '0');
100
+ }
101
+ /**
102
+ * Hash do código atrelado ao `uid` da interaction: `sha256(<uid>:<code>)` em hex.
103
+ * Atrelar ao uid escopa o código à interaction que o emitiu.
104
+ */
105
+ export function hashLoginOtp(uid, code) {
106
+ return createHash('sha256').update(`${uid}:${code}`).digest('hex');
107
+ }
108
+ /**
109
+ * Comparação constant-time de dois digests hex de MESMO tamanho.
110
+ *
111
+ * `timingSafeEqual` exige buffers de tamanho igual — comprimentos diferentes
112
+ * lançam. Por isso a guarda de tamanho vem antes (retorno `false` sem vazar
113
+ * timing útil: o atacante não controla o tamanho do digest server-side, que é
114
+ * sempre 64 hex de um sha256).
115
+ */
116
+ export function safeEqualHex(a, b) {
117
+ if (a.length !== b.length || a.length === 0)
118
+ return false;
119
+ const bufA = Buffer.from(a, 'hex');
120
+ const bufB = Buffer.from(b, 'hex');
121
+ if (bufA.length !== bufB.length)
122
+ return false;
123
+ return timingSafeEqual(bufA, bufB);
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Codec do slot composto `ml2:`
127
+ // ---------------------------------------------------------------------------
128
+ /** Prefixo do slot `passwordResetToken` quando o login por OTP está ativo. */
129
+ export const OTP_LOGIN_PREFIX = 'ml2:';
130
+ /** Só hex minúsculo (64 chars = sha256). Guard contra metacaracteres de LIKE. */
131
+ const HEX_64 = /^[0-9a-f]{64}$/;
132
+ /** Serializa o estado do OTP no formato de slot `ml2:...`. */
133
+ export function encodeOtpToken(state) {
134
+ return `${OTP_LOGIN_PREFIX}${state.linkToken}:${state.codeHash}:${state.codeExpMs}:${state.attempts}`;
135
+ }
136
+ /**
137
+ * Decodifica o valor ARMAZENADO no slot (`ml2:<linkToken>:<codeHash>:<exp>:<att>`).
138
+ * Retorna `null` se não for um slot `ml2:` bem-formado.
139
+ */
140
+ export function decodeOtpToken(value) {
141
+ if (!value || !value.startsWith(OTP_LOGIN_PREFIX))
142
+ return null;
143
+ const rest = value.slice(OTP_LOGIN_PREFIX.length);
144
+ const parts = rest.split(':');
145
+ if (parts.length !== 4)
146
+ return null;
147
+ const [linkToken, codeHash, expStr, attStr] = parts;
148
+ if (!HEX_64.test(linkToken))
149
+ return null;
150
+ if (codeHash !== '' && !HEX_64.test(codeHash))
151
+ return null;
152
+ const codeExpMs = Number(expStr);
153
+ const attempts = Number(attStr);
154
+ if (!Number.isFinite(codeExpMs) || !Number.isInteger(attempts) || attempts < 0)
155
+ return null;
156
+ return { linkToken, codeHash, codeExpMs, attempts };
157
+ }
158
+ /**
159
+ * Extrai o `linkToken` de uma URL de magic link `ml2:<linkToken>` (a forma que
160
+ * vai no e-mail, SEM o estado do código). Retorna `null` se não casar o formato
161
+ * ou se o token não for hex de 64 (guarda contra LIKE injection na busca).
162
+ */
163
+ export function linkTokenFromOtpUrl(urlToken) {
164
+ if (!urlToken.startsWith(OTP_LOGIN_PREFIX))
165
+ return null;
166
+ const linkToken = urlToken.slice(OTP_LOGIN_PREFIX.length);
167
+ return HEX_64.test(linkToken) ? linkToken : null;
168
+ }
169
+ /**
170
+ * Avalia UMA tentativa de código, na ORDEM travada pelo spec:
171
+ * lockout (contador/estado do código) → TTL do código → comparação constant-time.
172
+ *
173
+ * O throttle de rota e a validade da interaction são resolvidos ANTES, no
174
+ * controller. Aqui mora só a lógica que precisa do estado persistido do código.
175
+ *
176
+ * IMPORTANTE (prova de mutação): a checagem de LOCKOUT é a primeira guarda. Se
177
+ * removida, um atacante que já esgotou as tentativas volta a poder chutar — o
178
+ * teste `remove-lockout` cobre exatamente isso.
179
+ */
180
+ export function evaluateLoginOtp(input) {
181
+ const { parsed, uid, code, nowMs, maxAttempts } = input;
182
+ // Sem código pendente (slot vazio, `ml:` legado ou token de reset).
183
+ if (!parsed)
184
+ return { result: 'no_code' };
185
+ // LOCKOUT: código já invalidado (hash vazio) OU tentativas esgotadas.
186
+ // Fail-CLOSED — imposto pelo contador PERSISTIDO, sem depender de limiter.
187
+ if (parsed.codeHash === '' || parsed.attempts >= maxAttempts) {
188
+ return { result: 'locked' };
189
+ }
190
+ // TTL do código (mais curto que o do link).
191
+ if (parsed.codeExpMs < nowMs)
192
+ return { result: 'expired' };
193
+ // Comparação constant-time do hash atrelado ao uid.
194
+ const candidate = hashLoginOtp(uid, code);
195
+ if (safeEqualHex(candidate, parsed.codeHash)) {
196
+ // Sucesso: limpa o slot → mata o magic link junto (single-use conjunto).
197
+ return { result: 'ok', nextToken: null };
198
+ }
199
+ // Falha: incrementa o contador.
200
+ const attempts = parsed.attempts + 1;
201
+ if (attempts >= maxAttempts) {
202
+ // Última tentativa: INVALIDA o código (zera o hash) mas PRESERVA o link.
203
+ return { result: 'locked', nextToken: encodeOtpToken({ ...parsed, codeHash: '', attempts }) };
204
+ }
205
+ return { result: 'invalid', nextToken: encodeOtpToken({ ...parsed, attempts }) };
206
+ }
@@ -30,6 +30,12 @@ export interface AuthThrottles {
30
30
  * orçamentos não poderem se consumir.
31
31
  */
32
32
  sudo: ThrottleMiddleware;
33
+ /**
34
+ * Throttle da verificação de código OTP de login, keyed por IP em bucket
35
+ * PRÓPRIO (`authkit_otp_login`) e mais apertado que o `login`. Primeira barreira
36
+ * anti-brute-force do código adivinhável, ANTES do lockout por interaction.
37
+ */
38
+ otpLogin: ThrottleMiddleware;
33
39
  }
34
40
  /**
35
41
  * Service do `@adonisjs/limiter` resolvido de forma preguiçosa. Tipado como `any`
@@ -96,5 +96,8 @@ export function createAuthThrottles(config) {
96
96
  // mesmo vindo do mesmo IP. Sem `usingKey` próprio de propósito: inventar uma
97
97
  // key aqui seria mudar o EIXO da contagem, e o eixo certo continua sendo o IP.
98
98
  sudo: buildThrottle('authkit_sudo', config.sudo, config.store),
99
+ // Verificação de código OTP: keyed por IP (default), bucket próprio e mais
100
+ // apertado que o login. O namespace do nome mantém a contagem separada.
101
+ otpLogin: buildThrottle('authkit_otp_login', config.otpLogin, config.store),
99
102
  };
100
103
  }
@@ -256,6 +256,11 @@ export function registerAuthHost(router, opts = {}) {
256
256
  if (throttles)
257
257
  route.use([throttles.sudo]);
258
258
  };
259
+ // Bucket PRÓPRIO da verificação de código OTP: mais apertado que o login, por IP.
260
+ const withOtpLogin = (route) => {
261
+ if (throttles)
262
+ route.use([throttles.otpLogin]);
263
+ };
259
264
  // ─── Assets estáticos do host-kit (públicos, sem autenticação) ─────────────
260
265
  // Bundle do @simplewebauthn/browser servido pelo próprio app, no lugar do
261
266
  // import de CDN público que as views de login/MFA/confirm faziam.
@@ -286,6 +291,9 @@ export function registerAuthHost(router, opts = {}) {
286
291
  // Magic link (passwordless): POST emite (throttled), GET consome o token do link.
287
292
  withLogin(router.post('/auth/interaction/:uid/magic', [C.interaction, 'magicLinkRequest']));
288
293
  router.get('/auth/interaction/:uid/magic', [C.interaction, 'magicLinkConsume']);
294
+ // Login por OTP (código digitável): verifica o código no bucket dedicado
295
+ // `authkit_otp_login` (por IP, mais apertado que o login).
296
+ withOtpLogin(router.post('/auth/interaction/:uid/otp-verify', [C.interaction, 'otpVerify']));
289
297
  router.post('/auth/interaction/:uid/consent', [C.interaction, 'consent']);
290
298
  router.get('/auth/interaction/:uid/switch', [C.interaction, 'switchIdentifier']);
291
299
  // OTP unlock: link enviado por e-mail quando o fator TOTP/recovery é travado.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",