@adonis-agora/authkit-server 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/build/host/views/login.edge +18 -0
  2. package/build/index.d.ts +18 -2
  3. package/build/index.js +16 -1
  4. package/build/src/accounts/account_store.d.ts +59 -1
  5. package/build/src/accounts/account_store.js +5 -0
  6. package/build/src/accounts/lucid_store/core.d.ts +2 -2
  7. package/build/src/accounts/lucid_store/core.js +105 -4
  8. package/build/src/audit/audit_sink.d.ts +1 -1
  9. package/build/src/define_config.d.ts +22 -0
  10. package/build/src/define_config.js +5 -0
  11. package/build/src/host/account_screen_props.d.ts +163 -0
  12. package/build/src/host/account_screen_props.js +23 -0
  13. package/build/src/host/controllers/account_confirm_controller.js +3 -2
  14. package/build/src/host/controllers/account_mfa_controller.js +9 -6
  15. package/build/src/host/controllers/account_security_controller.js +3 -2
  16. package/build/src/host/controllers/account_session_controller.js +8 -3
  17. package/build/src/host/controllers/interaction_controller.d.ts +11 -0
  18. package/build/src/host/controllers/interaction_controller.js +136 -6
  19. package/build/src/host/default_mailer.d.ts +1 -0
  20. package/build/src/host/default_mailer.js +3 -0
  21. package/build/src/host/email_templates.d.ts +8 -0
  22. package/build/src/host/email_templates.js +12 -1
  23. package/build/src/host/i18n.d.ts +14 -0
  24. package/build/src/host/i18n.js +16 -0
  25. package/build/src/host/otp_login.d.ts +155 -0
  26. package/build/src/host/otp_login.js +206 -0
  27. package/build/src/host/rate_limit.d.ts +6 -0
  28. package/build/src/host/rate_limit.js +3 -0
  29. package/build/src/host/register_auth_host.js +8 -0
  30. package/build/src/host/renderers/inertia_renderer.d.ts +23 -66
  31. package/build/src/host/renderers/inertia_renderer.js +23 -66
  32. package/package.json +2 -2
@@ -37,7 +37,7 @@ export default class AccountConfirmController {
37
37
  ctx.logger?.warn({ method: m.id }, `authkit: método de sudo "${m.id}" está em config.sudo.methods mas não teve rotas montadas por registerAuthHost — a tela vai oferecer uma opção cujo endpoint não existe`);
38
38
  }
39
39
  }
40
- return c.cfg.render(ctx, 'account/confirm', {
40
+ const props = {
41
41
  csrfToken: ctx.request.csrfToken,
42
42
  returnTo: c.returnTo,
43
43
  error: ctx.session.flashMessages.get('confirmError') ?? null,
@@ -47,6 +47,7 @@ export default class AccountConfirmController {
47
47
  notice: ctx.session.flashMessages.get('confirmNotice') ?? null,
48
48
  methods,
49
49
  preferredId: ctx.session.get(LAST_METHOD_SESSION_KEY) ?? null,
50
- });
50
+ };
51
+ return c.cfg.render(ctx, 'account/confirm', props);
51
52
  }
52
53
  }
@@ -40,13 +40,14 @@ export default class AccountMfaController {
40
40
  // Passkeys disponíveis quando o store as suporta (model de credenciais wired).
41
41
  const passkeysSupported = supportsPasskeys(cfg.accountStore);
42
42
  const passkeys = passkeysSupported ? await cfg.accountStore.listPasskeys(userId) : [];
43
- return render(ctx, 'account/mfa', {
43
+ const props = {
44
44
  csrfToken: ctx.request.csrfToken,
45
45
  enabled: state.enabled,
46
46
  recoveryCodes: recoveryCodes ?? null,
47
47
  passkeysSupported,
48
48
  passkeys,
49
- });
49
+ };
50
+ return render(ctx, 'account/mfa', props);
50
51
  }
51
52
  /**
52
53
  * POST /account/mfa/passkeys/options — gera as opções de registro de passkey
@@ -178,14 +179,15 @@ export default class AccountMfaController {
178
179
  }
179
180
  // QR renderizado server-side como data-URL e passado como prop.
180
181
  const qrDataUrl = await QRCode.toDataURL(started.otpauthUri);
181
- return render(ctx, 'account/mfa', {
182
+ const props = {
182
183
  csrfToken: ctx.request.csrfToken,
183
184
  enabled: false,
184
185
  enrolling: true,
185
186
  secret: started.secret,
186
187
  qrDataUrl,
187
188
  recoveryCodes: null,
188
- });
189
+ };
190
+ return render(ctx, 'account/mfa', props);
189
191
  }
190
192
  /** POST /account/mfa/confirm — confirma o código; sucesso = ativa e mostra recovery codes. */
191
193
  async confirm(ctx) {
@@ -199,7 +201,7 @@ export default class AccountMfaController {
199
201
  // Reenvia o passo de confirmação com erro SEM regenerar o segredo pendente
200
202
  // (o usuário já escaneou o QR; um novo segredo invalidaria o app autenticador).
201
203
  // Mostra só o campo de código para nova tentativa.
202
- return render(ctx, 'account/mfa', {
204
+ const props = {
203
205
  csrfToken: ctx.request.csrfToken,
204
206
  enabled: false,
205
207
  enrolling: true,
@@ -207,7 +209,8 @@ export default class AccountMfaController {
207
209
  qrDataUrl: null,
208
210
  error: translate(cfg.messages, 'errors.invalid_code'),
209
211
  recoveryCodes: null,
210
- });
212
+ };
213
+ return render(ctx, 'account/mfa', props);
211
214
  }
212
215
  await cfg.audit?.record({
213
216
  type: 'mfa.enabled',
@@ -65,7 +65,7 @@ export default class AccountSecurityController {
65
65
  const ownSessions = sessionsSupported
66
66
  ? await enrichSessionsWithContext(cfg, userId, await adminSessions.listSessions(userId))
67
67
  : [];
68
- return render(ctx, 'account/security', {
68
+ const props = {
69
69
  csrfToken: ctx.request.csrfToken,
70
70
  supported: supportsAccountSecurity(cfg.accountStore),
71
71
  profileSupported: supportsProfile(cfg.accountStore),
@@ -94,7 +94,8 @@ export default class AccountSecurityController {
94
94
  // Deleção de conta (LGPD): só quando o store suporta hard delete.
95
95
  deletionSupported: supportsAccountDeletion(cfg.accountStore),
96
96
  deleteError: ctx.session.flashMessages.get('deleteError') ?? null,
97
- });
97
+ };
98
+ return render(ctx, 'account/security', props);
98
99
  }
99
100
  /**
100
101
  * GET /account/security/export — baixa um JSON com os dados da conta logada
@@ -48,7 +48,11 @@ export default class AccountSessionController {
48
48
  // Lê e valida o return_to da query-string — descarta valores inválidos (open-redirect).
49
49
  const rawReturnTo = ctx.request.qs?.()?.return_to ?? ctx.request.input?.('return_to');
50
50
  const returnTo = validateReturnTo(rawReturnTo);
51
- return render(ctx, 'account/login', { csrfToken: ctx.request.csrfToken, returnTo });
51
+ const props = {
52
+ csrfToken: ctx.request.csrfToken,
53
+ returnTo,
54
+ };
55
+ return render(ctx, 'account/login', props);
52
56
  }
53
57
  async login(ctx) {
54
58
  const service = await ctx.containerResolver.make('authkit.server');
@@ -74,7 +78,7 @@ export default class AccountSessionController {
74
78
  settings: settings ?? undefined,
75
79
  });
76
80
  if (!result.ok) {
77
- return render(ctx, 'account/login', {
81
+ const props = {
78
82
  csrfToken: ctx.request.csrfToken,
79
83
  returnTo,
80
84
  error: result.locked
@@ -84,7 +88,8 @@ export default class AccountSessionController {
84
88
  : result.disabled
85
89
  ? translate(cfg.messages, 'errors.account_disabled')
86
90
  : translate(cfg.messages, 'errors.invalid_credentials'),
87
- });
91
+ };
92
+ return render(ctx, 'account/login', props);
88
93
  }
89
94
  const acc = result.account;
90
95
  // M5 (session fixation): regenera a sessão IMEDIATAMENTE após autenticar e
@@ -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;