@adonis-agora/authkit-server 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -190,14 +190,19 @@
190
190
  @end
191
191
  @end
192
192
 
193
- {{-- Passwordless: confirmação de magic link enviado (anti-enumeração). --}}
193
+ {{-- Passwordless: confirmação de magic link enviado (anti-enumeração).
194
+ `magicChannel` (choose-first) escolhe a sub-view: 'code' = só o campo de
195
+ código, 'link' = só o aviso de link, 'both'/ausente = ambos (histórico). --}}
194
196
  @if(magicLinkSent)
195
- <p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
197
+ @if(magicChannel !== 'code')
198
+ <p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
199
+ @end
196
200
 
197
201
  {{-- Login por OTP: campo de código digitável (mesmo e-mail carrega link E código). --}}
198
- @if(otpEnabled)
202
+ @if(otpEnabled && magicChannel !== 'link')
199
203
  <form method="POST" action="/auth/interaction/{{ uid }}/otp-verify" class="mt-4">
200
204
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
205
+ <input type="hidden" name="channel" value="code">
201
206
  <label for="otp-code" class="block text-sm font-medium text-gray-700">{{ t('login.otp_label') }}</label>
202
207
  <input id="otp-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code"
203
208
  pattern="[0-9]*" placeholder="{{ t('login.otp_placeholder') }}"
@@ -47,6 +47,13 @@ export interface MailHooks {
47
47
  * fluxo só-magic-link (back-compat).
48
48
  */
49
49
  code?: string;
50
+ /**
51
+ * Canal escolhido no seletor "choose-first": `'code'` = o host deveria
52
+ * renderizar SÓ o código, `'link'` = SÓ o link. Ausente = ambos (histórico).
53
+ * Puramente de superfície — os dois tokens continuam emitidos co-locados;
54
+ * hosts existentes simplesmente ignoram este campo (back-compat).
55
+ */
56
+ channel?: 'code' | 'link';
50
57
  }) => Promise<void>;
51
58
  /**
52
59
  * Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo). Distinto de
@@ -7,6 +7,7 @@ import { sendMagicLinkEmail } from '../default_mailer.js';
7
7
  import { sendOtpUnlockEmail } from '../default_mailer.js';
8
8
  import { translate } from '../i18n.js';
9
9
  import { attemptPasswordLogin, isEmailUnverifiedBlock } from '../login_attempt.js';
10
+ import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
10
11
  import { notifyLoginSuccess } from '../login_notify.js';
11
12
  import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
12
13
  import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
@@ -612,6 +613,10 @@ export default class AuthInteractionController {
612
613
  // Login por OTP: liga o campo de código na tela "link enviado" quando a config
613
614
  // está ligada E o store suporta a capacidade.
614
615
  const otpEnabled = cfg.login.otp.enabled && supportsOtpLogin(cfg.accountStore);
616
+ // Seletor "choose-first": o host pode POSTar `channel=code|link` para pedir que
617
+ // o e-mail e a tela mostrem SÓ aquele método. Ausente/ inválido = both (histórico).
618
+ // NÃO condiciona a emissão de token — os dois continuam saindo co-locados.
619
+ const channel = normalizeLoginChannel(ctx.request.input('channel'));
615
620
  if (cfg.passwordless.magicLink && supportsMagicLink(cfg.accountStore) && email) {
616
621
  const ip = ctx.request.ip?.() ?? null;
617
622
  const clientId = details.params.client_id ?? null;
@@ -644,10 +649,10 @@ export default class AuthInteractionController {
644
649
  const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
645
650
  const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
646
651
  if (cfg.mail?.onMagicLink) {
647
- await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code });
652
+ await cfg.mail.onMagicLink({ email, magicUrl, token: issued.token, code, channel });
648
653
  }
649
654
  else {
650
- await sendMagicLinkEmail(ctx, { email, magicUrl, code });
655
+ await sendMagicLinkEmail(ctx, { email, magicUrl, code, channel });
651
656
  }
652
657
  }
653
658
  }
@@ -662,6 +667,10 @@ export default class AuthInteractionController {
662
667
  brand,
663
668
  magicLinkSent: true,
664
669
  otpEnabled,
670
+ // Prop da tela: qual sub-view do estado `magicLinkSent` mostrar —
671
+ // 'code' (só o campo de código), 'link' (só o aviso de link) ou 'both'
672
+ // (ambos, quando o host não escolheu canal). Back-compat: ausente = 'both'.
673
+ magicChannel: magicChannelProp(channel),
665
674
  });
666
675
  }
667
676
  /**
@@ -748,6 +757,9 @@ export default class AuthInteractionController {
748
757
  }
749
758
  const code = String(ctx.request.input('code', '') ?? '').trim();
750
759
  const brand = brandFor(cfg.branding, clientId ?? undefined, undefined);
760
+ // Mantém a sub-view do seletor no re-render de erro (o form de código pode
761
+ // POSTar `channel=code`). Ausente = both (histórico).
762
+ const channel = normalizeLoginChannel(ctx.request.input('channel'));
751
763
  const result = await cfg.accountStore.verifyLoginCode(email, uid, code, {
752
764
  maxAttempts: cfg.login.otp.maxAttempts,
753
765
  });
@@ -762,6 +774,7 @@ export default class AuthInteractionController {
762
774
  brand,
763
775
  magicLinkSent: true,
764
776
  otpEnabled: true,
777
+ magicChannel: magicChannelProp(channel),
765
778
  otpError: translate(cfg.messages, messageKey),
766
779
  });
767
780
  if (result.status === 'ok') {
@@ -63,6 +63,7 @@ export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
63
63
  email: string;
64
64
  magicUrl: string;
65
65
  code?: string;
66
+ channel?: 'code' | 'link';
66
67
  }): Promise<void>;
67
68
  /**
68
69
  * Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de
@@ -249,20 +249,41 @@ export async function sendMagicLinkEmail(ctx, data) {
249
249
  try {
250
250
  const brand = resolveBrand(ctx);
251
251
  const { messages: t, locale } = resolveMailMessages(ctx);
252
- const content = renderTransactionalEmail({
253
- brand,
254
- locale,
255
- linkFallback: translate(t, 'mail.common.link_fallback'),
256
- subject: translate(t, 'mail.magic_link.subject'),
257
- heading: translate(t, 'mail.magic_link.heading'),
258
- intro: translate(t, 'mail.magic_link.intro'),
259
- ctaLabel: translate(t, 'mail.magic_link.cta'),
260
- ctaUrl: data.magicUrl,
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'),
265
- });
252
+ // Login choose-first: o `channel` decide o que o e-mail SURFA (os dois tokens
253
+ // continuam emitidos co-locados a montante — isto é só renderização).
254
+ // - 'code' → e-mail SÓ com o código (sem botão/link), quando há código;
255
+ // - 'link' → e-mail SÓ com o link (código suprimido);
256
+ // - ausente → ambos (comportamento histórico — back-compat).
257
+ // Degradação limpa: `channel: 'code'` sem código emitido (OTP desligado) cai
258
+ // no e-mail de link — não dá pra mostrar um código inexistente.
259
+ const codeOnly = data.channel === 'code' && !!data.code;
260
+ const linkOnly = data.channel === 'link';
261
+ const content = codeOnly
262
+ ? renderTransactionalEmail({
263
+ brand,
264
+ locale,
265
+ subject: translate(t, 'mail.magic_link.code_subject'),
266
+ heading: translate(t, 'mail.magic_link.heading'),
267
+ intro: translate(t, 'mail.magic_link.code_intro'),
268
+ footnote: translate(t, 'mail.magic_link.fallback'),
269
+ // Sem `ctaUrl`: e-mail sem botão nem fallback de link — só o código.
270
+ code: data.code,
271
+ codeLabel: translate(t, 'mail.magic_link.code_only_label'),
272
+ })
273
+ : renderTransactionalEmail({
274
+ brand,
275
+ locale,
276
+ linkFallback: translate(t, 'mail.common.link_fallback'),
277
+ subject: translate(t, 'mail.magic_link.subject'),
278
+ heading: translate(t, 'mail.magic_link.heading'),
279
+ intro: translate(t, 'mail.magic_link.intro'),
280
+ ctaLabel: translate(t, 'mail.magic_link.cta'),
281
+ ctaUrl: data.magicUrl,
282
+ footnote: translate(t, 'mail.magic_link.fallback'),
283
+ // Código em destaque quando presente — suprimido no canal 'link'.
284
+ code: linkOnly ? undefined : data.code,
285
+ codeLabel: translate(t, 'mail.magic_link.code_label'),
286
+ });
266
287
  const sent = await sendEmail(ctx, data.email, content);
267
288
  if (!sent) {
268
289
  ctx.logger.info({ magicUrl: data.magicUrl, email: data.email }, 'authkit: magic link de login (dev — @adonisjs/mail ausente)');
@@ -24,10 +24,17 @@ interface EmailTemplateInput {
24
24
  heading: string;
25
25
  /** Parágrafo de introdução (texto puro, será escapado). */
26
26
  intro: string;
27
- /** Rótulo do botão de CTA. */
28
- ctaLabel: string;
29
- /** URL do CTA. */
30
- ctaUrl: string;
27
+ /**
28
+ * Rótulo do botão de CTA. Opcional junto de `ctaUrl`: e-mails "só código"
29
+ * (login por OTP com `channel: 'code'`) não têm botão nem link.
30
+ */
31
+ ctaLabel?: string;
32
+ /**
33
+ * URL do CTA. Quando ausente, o e-mail é renderizado SEM botão e SEM o
34
+ * fallback de link (usado no e-mail "só código"). Todos os callers históricos
35
+ * passam este campo — byte-parity preservado.
36
+ */
37
+ ctaUrl?: string;
31
38
  /** Linha auxiliar abaixo do botão (ex.: validade do link). */
32
39
  footnote?: string;
33
40
  /** Texto que precede o link de fallback (i18n). Default em inglês. */
@@ -51,11 +51,13 @@ export function renderTransactionalEmail(input) {
51
51
  <tr><td style="padding:32px 28px 8px;">
52
52
  <h1 style="margin:0 0 12px;font-size:20px;line-height:1.3;color:#111827;">${esc(input.heading)}</h1>
53
53
  <p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#374151;">${esc(input.intro)}</p>
54
- ${codeBlock}<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
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>
56
- </td></tr></table>
54
+ ${codeBlock}${input.ctaUrl
55
+ ? `<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:8px;background:${esc(accent)};">
56
+ <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>
57
+ </td></tr></table>`
58
+ : ''}
57
59
  ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(input.footnote)}</p>` : ''}
58
- <p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(linkFallback)}<br><a href="${esc(input.ctaUrl)}" style="color:${esc(accent)};word-break:break-all;">${esc(input.ctaUrl)}</a></p>
60
+ ${input.ctaUrl ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#6b7280;">${esc(linkFallback)}<br><a href="${esc(input.ctaUrl)}" style="color:${esc(accent)};word-break:break-all;">${esc(input.ctaUrl)}</a></p>` : ''}
59
61
  </td></tr>
60
62
  <tr><td style="padding:24px 28px 28px;border-top:1px solid #f3f4f6;">
61
63
  <p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;">${esc(company)} ${year}</p>
@@ -70,8 +72,7 @@ ${input.footnote ? `<p style="margin:24px 0 0;font-size:13px;line-height:1.5;col
70
72
  '',
71
73
  input.intro,
72
74
  ...(input.code ? ['', `${codeLabel} ${input.code}`] : []),
73
- '',
74
- `${input.ctaLabel}: ${input.ctaUrl}`,
75
+ ...(input.ctaUrl ? ['', `${input.ctaLabel}: ${input.ctaUrl}`] : []),
75
76
  ...(input.footnote ? ['', input.footnote] : []),
76
77
  '',
77
78
  `— ${company}`,
@@ -518,6 +518,9 @@ export declare const DEFAULT_MESSAGES: {
518
518
  'mail.magic_link.cta': string;
519
519
  'mail.magic_link.fallback': string;
520
520
  'mail.magic_link.code_label': string;
521
+ 'mail.magic_link.code_subject': string;
522
+ 'mail.magic_link.code_intro': string;
523
+ 'mail.magic_link.code_only_label': string;
521
524
  'mail.new_login.subject': string;
522
525
  'mail.new_login.heading': string;
523
526
  'mail.new_login.intro': string;
@@ -1219,6 +1222,9 @@ export declare const PT_BR_MESSAGES: {
1219
1222
  'mail.magic_link.cta': string;
1220
1223
  'mail.magic_link.fallback': string;
1221
1224
  'mail.magic_link.code_label': string;
1225
+ 'mail.magic_link.code_subject': string;
1226
+ 'mail.magic_link.code_intro': string;
1227
+ 'mail.magic_link.code_only_label': string;
1222
1228
  'mail.new_login.subject': string;
1223
1229
  'mail.new_login.heading': string;
1224
1230
  'mail.new_login.intro': string;
@@ -558,6 +558,10 @@ export const DEFAULT_MESSAGES = {
558
558
  'mail.magic_link.cta': 'Sign in',
559
559
  'mail.magic_link.fallback': 'If you did not request this, you can ignore this email.',
560
560
  'mail.magic_link.code_label': 'Or enter this code to sign in:',
561
+ // E-mail "só código" (login choose-first com channel: 'code'): sem botão/link.
562
+ 'mail.magic_link.code_subject': 'Your login code',
563
+ 'mail.magic_link.code_intro': 'Use the code below to sign in. It expires shortly and can be used once.',
564
+ 'mail.magic_link.code_only_label': 'Enter this code to sign in:',
561
565
  'mail.new_login.subject': 'New login to your account',
562
566
  'mail.new_login.heading': 'New login detected',
563
567
  'mail.new_login.intro': 'We detected a new login to your account.',
@@ -1335,6 +1339,10 @@ export const PT_BR_MESSAGES = {
1335
1339
  'mail.magic_link.cta': 'Entrar',
1336
1340
  'mail.magic_link.fallback': 'Se você não solicitou isso, pode ignorar este e-mail.',
1337
1341
  'mail.magic_link.code_label': 'Ou digite este código para entrar:',
1342
+ // E-mail "só código" (login choose-first com channel: 'code'): sem botão/link.
1343
+ 'mail.magic_link.code_subject': 'Seu código de login',
1344
+ 'mail.magic_link.code_intro': 'Use o código abaixo para entrar. Ele expira em instantes e serve para um único acesso.',
1345
+ 'mail.magic_link.code_only_label': 'Digite este código para entrar:',
1338
1346
  'mail.new_login.subject': 'Novo login na sua conta',
1339
1347
  'mail.new_login.heading': 'Novo login detectado',
1340
1348
  'mail.new_login.intro': 'Detectamos um novo login na sua conta.',
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Canal de login escolhido no seletor "choose-first" (modelo GitHub): o usuário
3
+ * decide PRIMEIRO como quer entrar e só então o método é executado.
4
+ *
5
+ * IMPORTANTE — o `channel` é puramente de SUPERFÍCIE: NÃO condiciona a emissão de
6
+ * token nem toca no codec `ml2:` / lockout / single-use-conjunto. A lib continua
7
+ * emitindo link E código co-locados (quando `login.otp.enabled`); o `channel` só
8
+ * decide o que o E-MAIL renderiza e qual sub-view a TELA mostra no estado
9
+ * `magicLinkSent`. Ausente/ inválido = comportamento histórico ("both").
10
+ */
11
+ /** Método escolhido no seletor. `passkey` é slot documentado (ainda não emitido). */
12
+ export type LoginChannel = 'code' | 'link';
13
+ /**
14
+ * Valor da prop de render que a tela usa para escolher a sub-view do estado
15
+ * `magicLinkSent`: `'code'` = só campo de código, `'link'` = só aviso de link,
16
+ * `'both'` = ambos (comportamento histórico, quando o host não manda `channel`).
17
+ */
18
+ export type MagicChannelProp = 'code' | 'link' | 'both';
19
+ /**
20
+ * Lê e valida o campo `channel` do body do POST `/magic`. Só `'code'` e `'link'`
21
+ * são aceitos; qualquer outro valor (ausente, vazio, lixo) vira `undefined`, que
22
+ * a lib trata como "both" — garantindo back-compat total com hosts que ainda
23
+ * POSTam sem o campo.
24
+ */
25
+ export declare function normalizeLoginChannel(raw: unknown): LoginChannel | undefined;
26
+ /**
27
+ * Mapeia o `channel` do body para a prop de render `magicChannel`. Ausente
28
+ * (`undefined`) → `'both'`: a tela mostra as duas sub-views, como hoje.
29
+ */
30
+ export declare function magicChannelProp(channel: LoginChannel | undefined): MagicChannelProp;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Canal de login escolhido no seletor "choose-first" (modelo GitHub): o usuário
3
+ * decide PRIMEIRO como quer entrar e só então o método é executado.
4
+ *
5
+ * IMPORTANTE — o `channel` é puramente de SUPERFÍCIE: NÃO condiciona a emissão de
6
+ * token nem toca no codec `ml2:` / lockout / single-use-conjunto. A lib continua
7
+ * emitindo link E código co-locados (quando `login.otp.enabled`); o `channel` só
8
+ * decide o que o E-MAIL renderiza e qual sub-view a TELA mostra no estado
9
+ * `magicLinkSent`. Ausente/ inválido = comportamento histórico ("both").
10
+ */
11
+ /**
12
+ * Lê e valida o campo `channel` do body do POST `/magic`. Só `'code'` e `'link'`
13
+ * são aceitos; qualquer outro valor (ausente, vazio, lixo) vira `undefined`, que
14
+ * a lib trata como "both" — garantindo back-compat total com hosts que ainda
15
+ * POSTam sem o campo.
16
+ */
17
+ export function normalizeLoginChannel(raw) {
18
+ return raw === 'code' || raw === 'link' ? raw : undefined;
19
+ }
20
+ /**
21
+ * Mapeia o `channel` do body para a prop de render `magicChannel`. Ausente
22
+ * (`undefined`) → `'both'`: a tela mostra as duas sub-views, como hoje.
23
+ */
24
+ export function magicChannelProp(channel) {
25
+ return channel ?? 'both';
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.50.0",
3
+ "version": "0.51.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",