@adonis-agora/authkit-server 0.52.1 → 0.53.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.
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.apps.page_title') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen bg-gray-100 p-4">
6
7
  <div class="mx-auto max-w-2xl">
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.confirm.page_title') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
6
7
  <div class="w-full max-w-sm">
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.login.page_title') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen flex items-center justify-center bg-gray-100 p-4">
6
7
  <form method="POST" action="{{ accountPaths?.login ?? '/account/login' }}"
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.mfa.page_title') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen bg-gray-100 p-4">
6
7
  <div class="mx-auto max-w-2xl">
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <title>{{ t('account.orgs.page_title') }}</title>
6
+ @include('authkit::partials/submit_lock')
6
7
  </head>
7
8
  <body>
8
9
  @if(!supported)
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.security.page_title') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen bg-gray-100 p-4">
6
7
  <div class="mx-auto max-w-2xl">
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.tokens.page_title') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen bg-gray-100 p-4">
6
7
  <div class="mx-auto max-w-2xl">
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('consent.page_title') }} — {{ brand && brand.appName ? brand.appName : t('common.app_fallback') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen flex items-center justify-center bg-gray-50">
6
7
  <div class="w-full max-w-sm bg-white p-8 rounded-2xl shadow-xl ring-1 ring-black/5">
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('forgot.page_title') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  @if(botProtection && botProtection.scriptUrl)
5
6
  <script src="{{ botProtection.scriptUrl }}" async defer></script>
6
7
  @end
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('login.page_title') }} — {{ brand && brand.appName ? brand.appName : t('common.app_fallback') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  @if(botProtection && botProtection.scriptUrl)
5
6
  <script src="{{ botProtection.scriptUrl }}" async defer></script>
6
7
  @end
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('maintenance.title') }} — {{ brand && brand.appName ? brand.appName : t('common.app_fallback') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen flex items-center justify-center bg-gray-50">
6
7
  <div class="w-full max-w-sm bg-white p-8 rounded-2xl shadow-xl ring-1 ring-black/5 text-center">
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('mfa_challenge.page_title') }} — {{ brand && brand.appName ? brand.appName : t('common.app_fallback') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen flex items-center justify-center bg-gray-50">
6
7
  <div class="w-full max-w-sm bg-white p-8 rounded-2xl shadow-xl ring-1 ring-black/5">
@@ -0,0 +1,49 @@
1
+ {{--
2
+ Trava anti-duplo-submit dos forms clássicos (`method="POST"` com navegação).
3
+
4
+ Por quê: os endpoints de interaction/conta disparam ações custosas (envio de
5
+ e-mail, cerimônia WebAuthn, verificação de código) e, sem feedback, o usuário
6
+ clica de novo — cada clique vira um POST duplicado e, nas rotas throttled,
7
+ derruba o usuário no 429 por mera impaciência.
8
+
9
+ Como:
10
+ - Listener DELEGADO no `document` — cobre todo form, inclusive os montados
11
+ depois (sem precisar marcar forms um a um).
12
+ - A trava é agendada com `setTimeout(0)`: roda DEPOIS do dispatch do `submit`,
13
+ quando o entry-list do form já foi construído (um botão de submit NOMEADO
14
+ preserva seu valor no POST) e handlers que chamam `preventDefault` já
15
+ rodaram (aí `defaultPrevented` evita travar à toa — sem navegação, sem
16
+ botão morto).
17
+ - Validação nativa que barra o submit nem dispara o evento — a trava nunca
18
+ liga num form inválido.
19
+ - `form.submit()` (JS puro, ex.: auto-submit de passkey) NÃO dispara `submit`
20
+ — esses forms seguem destravados de propósito.
21
+ - Não há "destravar": a resposta (redirect ou re-render de erro) chega como
22
+ documento NOVO e a página remonta.
23
+
24
+ Incluído view a view (não no partial de styles, que é GERADO por
25
+ scripts/build_host_css.mjs e sobrescreveria a linha).
26
+ --}}
27
+ <script>
28
+ document.addEventListener('submit', function (event) {
29
+ const form = event.target
30
+ if (!(form instanceof HTMLFormElement)) return
31
+ setTimeout(function () {
32
+ if (event.defaultPrevented) return
33
+ form
34
+ .querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]')
35
+ .forEach(function (el) {
36
+ el.disabled = true
37
+ })
38
+ form.setAttribute('aria-busy', 'true')
39
+ }, 0)
40
+ })
41
+ </script>
42
+ <style>
43
+ form[aria-busy] button[type='submit'],
44
+ form[aria-busy] button:not([type]),
45
+ form[aria-busy] input[type='submit'] {
46
+ opacity: 0.6;
47
+ cursor: not-allowed;
48
+ }
49
+ </style>
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('reset.page_title') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  </head>
5
6
  <body class="min-h-screen flex items-center justify-center bg-gray-50">
6
7
  <div class="w-full max-w-sm bg-white p-8 rounded-2xl shadow-xl ring-1 ring-black/5">
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('signup.page_title') }} — {{ brand && brand.appName ? brand.appName : t('common.app_fallback') }}</title>
3
3
  @include('authkit::partials/styles')
4
+ @include('authkit::partials/submit_lock')
4
5
  @if(botProtection && botProtection.scriptUrl)
5
6
  <script src="{{ botProtection.scriptUrl }}" async defer></script>
6
7
  @end
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('throttled.page_title') }} — {{ brand && brand.appName ? brand.appName : t('common.app_fallback') }}</title>
3
+ @include('authkit::partials/styles')
4
+ </head>
5
+ <body class="min-h-screen flex items-center justify-center bg-gray-50 p-4">
6
+ <div class="w-full max-w-sm bg-white p-8 rounded-2xl shadow-xl ring-1 ring-black/5 text-center">
7
+ <div class="mb-4 flex justify-center">
8
+ <span class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 text-amber-600">
9
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
10
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
11
+ </svg>
12
+ </span>
13
+ </div>
14
+ <h1 class="text-xl font-semibold text-gray-900">{{ t('throttled.title') }}</h1>
15
+ <p class="mt-3 text-sm text-gray-600">
16
+ @if(retryAfter)
17
+ {{ t('throttled.body_seconds', { seconds: retryAfter }) }}
18
+ @else
19
+ {{ t('throttled.body') }}
20
+ @end
21
+ </p>
22
+ <a href="{{ retryUrl ?? '/' }}"
23
+ class="mt-6 inline-block rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-gray-700">
24
+ {{ t('throttled.retry_link') }}
25
+ </a>
26
+ </div>
27
+ </body></html>
@@ -478,6 +478,11 @@ export declare const DEFAULT_MESSAGES: {
478
478
  'session_expired.title': string;
479
479
  'session_expired.body': string;
480
480
  'session_expired.login_link': string;
481
+ 'throttled.page_title': string;
482
+ 'throttled.title': string;
483
+ 'throttled.body_seconds': string;
484
+ 'throttled.body': string;
485
+ 'throttled.retry_link': string;
481
486
  'password.policy.min_length': string;
482
487
  'password.policy.uppercase': string;
483
488
  'password.policy.lowercase': string;
@@ -1186,6 +1191,11 @@ export declare const PT_BR_MESSAGES: {
1186
1191
  'session_expired.title': string;
1187
1192
  'session_expired.body': string;
1188
1193
  'session_expired.login_link': string;
1194
+ 'throttled.page_title': string;
1195
+ 'throttled.title': string;
1196
+ 'throttled.body_seconds': string;
1197
+ 'throttled.body': string;
1198
+ 'throttled.retry_link': string;
1189
1199
  'password.policy.min_length': string;
1190
1200
  'password.policy.uppercase': string;
1191
1201
  'password.policy.lowercase': string;
@@ -514,6 +514,12 @@ export const DEFAULT_MESSAGES = {
514
514
  'session_expired.title': 'Your session expired',
515
515
  'session_expired.body': 'Your login session expired or could not be found. This can happen if you left the page open too long. Please start again.',
516
516
  'session_expired.login_link': 'Back to login',
517
+ // Tela amigável de throttle (429) nas rotas de browser — ver `withFriendly429`.
518
+ 'throttled.page_title': 'Too many attempts',
519
+ 'throttled.title': 'Too many attempts',
520
+ 'throttled.body_seconds': 'You have made too many attempts. Please wait {seconds} seconds and try again.',
521
+ 'throttled.body': 'You have made too many attempts. Please wait a moment and try again.',
522
+ 'throttled.retry_link': 'Back and try again',
517
523
  // Política de senha (validação ao definir uma senha nova) + vazamento (HIBP) + histórico + expiração.
518
524
  'password.policy.min_length': 'Password must be at least {min} characters long.',
519
525
  'password.policy.uppercase': 'Password must contain at least one uppercase letter.',
@@ -1300,6 +1306,12 @@ export const PT_BR_MESSAGES = {
1300
1306
  'session_expired.title': 'Sua sessão expirou',
1301
1307
  'session_expired.body': 'Sua sessão de login expirou ou não foi encontrada. Isso pode acontecer se a página ficou aberta por muito tempo. Recomece o login.',
1302
1308
  'session_expired.login_link': 'Voltar ao login',
1309
+ // Tela amigável de throttle (429) nas rotas de browser — ver `withFriendly429`.
1310
+ 'throttled.page_title': 'Muitas tentativas',
1311
+ 'throttled.title': 'Muitas tentativas',
1312
+ 'throttled.body_seconds': 'Você fez tentativas demais. Aguarde {seconds} segundos e tente de novo.',
1313
+ 'throttled.body': 'Você fez tentativas demais. Aguarde um instante e tente de novo.',
1314
+ 'throttled.retry_link': 'Voltar e tentar de novo',
1303
1315
  // Política de senha (validação ao definir uma senha nova) + vazamento (HIBP) + histórico + expiração.
1304
1316
  'password.policy.min_length': 'A senha deve ter no mínimo {min} caracteres.',
1305
1317
  'password.policy.uppercase': 'A senha deve conter ao menos uma letra maiúscula.',
@@ -52,4 +52,24 @@ export declare function __setLimiterLoaderForTests(fn: (() => Promise<LimiterSer
52
52
  * Retorna `undefined` quando rate-limit está desligado (rotas montadas sem throttle).
53
53
  */
54
54
  export declare function createAuthThrottles(config: ResolvedRateLimitConfig): AuthThrottles | undefined;
55
+ /**
56
+ * Embrulha um throttle de rota de BROWSER para trocar o 429 cru do limiter pela
57
+ * tela themeável `throttled` (Edge built-in, ou a página React do host quando
58
+ * listada no allowlist do `inertiaRenderer`).
59
+ *
60
+ * Antes deste wrapper, estourar o throttle no meio do login (ex.: cliques
61
+ * repetidos no "Receber link mágico") cuspia um "Too many requests" sem marca,
62
+ * sem marca de tempo e sem caminho de volta — o usuário ficava preso numa página
63
+ * de erro morta. A tela mostra o `Retry-After` e linka de volta pro passo atual
64
+ * (raiz da interaction quando há `uid`; a própria URL nas demais).
65
+ *
66
+ * Aplicado SÓ nos throttles de browser (`login`, `sudo`, `otpLogin`) —
67
+ * `introspection`/`adminIp` servem APIs, onde o 429 cru (JSON/headers) é o
68
+ * contrato correto.
69
+ *
70
+ * O `send` explícito com o guard `hasLazyBody` espelha o contrato de
71
+ * `interaction_recovery.ts`: funciona tanto se o http-server usar o valor de
72
+ * retorno quanto se o renderer já tiver escrito o body.
73
+ */
74
+ export declare function withFriendly429(throttle: ThrottleMiddleware): ThrottleMiddleware;
55
75
  export {};
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { brandFor } from './branding.js';
9
10
  let limiterServicePromise;
10
11
  /**
11
12
  * Importa o service do limiter do HOST de forma preguiçosa e fail-safe.
@@ -74,7 +75,9 @@ export function createAuthThrottles(config) {
74
75
  return undefined;
75
76
  return {
76
77
  // Login/signup/forgot/reset: keyed por IP (default do HttpLimiter).
77
- login: buildThrottle('authkit_login', config.login, config.store),
78
+ // `friendly`: rota de BROWSER — 429 vira a tela themeável `throttled`
79
+ // em vez do erro cru do limiter (ver `withFriendly429`).
80
+ login: withFriendly429(buildThrottle('authkit_login', config.login, config.store)),
78
81
  // Introspecção de PAT: keyed pelo bearer secret quando presente, senão por IP.
79
82
  introspection: buildThrottle('authkit_pat_introspection', config.introspection, config.store, (ctx) => {
80
83
  const auth = ctx.request.header('authorization');
@@ -95,9 +98,75 @@ export function createAuthThrottles(config) {
95
98
  // contagem por nome, então `authkit_login` e `authkit_sudo` nunca somam,
96
99
  // mesmo vindo do mesmo IP. Sem `usingKey` próprio de propósito: inventar uma
97
100
  // key aqui seria mudar o EIXO da contagem, e o eixo certo continua sendo o IP.
98
- sudo: buildThrottle('authkit_sudo', config.sudo, config.store),
101
+ sudo: withFriendly429(buildThrottle('authkit_sudo', config.sudo, config.store)),
99
102
  // Verificação de código OTP: keyed por IP (default), bucket próprio e mais
100
103
  // apertado que o login. O namespace do nome mantém a contagem separada.
101
- otpLogin: buildThrottle('authkit_otp_login', config.otpLogin, config.store),
104
+ otpLogin: withFriendly429(buildThrottle('authkit_otp_login', config.otpLogin, config.store)),
105
+ };
106
+ }
107
+ /**
108
+ * Discriminador de "estourou o throttle" por DUCK-TYPING — o `@adonisjs/limiter`
109
+ * é peer opcional, então a classe `ThrottleException` não é importável aqui. O
110
+ * `code` é o identificador estável; o `status` é o fallback.
111
+ */
112
+ function isThrottleExceeded(err) {
113
+ if (!err || typeof err !== 'object')
114
+ return false;
115
+ const e = err;
116
+ return e.code === 'E_TOO_MANY_REQUESTS' || e.status === 429;
117
+ }
118
+ /** `response.availableIn` (segundos) da `LimiterResponse` embutida na exceção. */
119
+ function retryAfterSeconds(err) {
120
+ const availableIn = err?.response?.availableIn;
121
+ return typeof availableIn === 'number' && availableIn > 0 ? Math.ceil(availableIn) : null;
122
+ }
123
+ /**
124
+ * Embrulha um throttle de rota de BROWSER para trocar o 429 cru do limiter pela
125
+ * tela themeável `throttled` (Edge built-in, ou a página React do host quando
126
+ * listada no allowlist do `inertiaRenderer`).
127
+ *
128
+ * Antes deste wrapper, estourar o throttle no meio do login (ex.: cliques
129
+ * repetidos no "Receber link mágico") cuspia um "Too many requests" sem marca,
130
+ * sem marca de tempo e sem caminho de volta — o usuário ficava preso numa página
131
+ * de erro morta. A tela mostra o `Retry-After` e linka de volta pro passo atual
132
+ * (raiz da interaction quando há `uid`; a própria URL nas demais).
133
+ *
134
+ * Aplicado SÓ nos throttles de browser (`login`, `sudo`, `otpLogin`) —
135
+ * `introspection`/`adminIp` servem APIs, onde o 429 cru (JSON/headers) é o
136
+ * contrato correto.
137
+ *
138
+ * O `send` explícito com o guard `hasLazyBody` espelha o contrato de
139
+ * `interaction_recovery.ts`: funciona tanto se o http-server usar o valor de
140
+ * retorno quanto se o renderer já tiver escrito o body.
141
+ */
142
+ export function withFriendly429(throttle) {
143
+ return async (ctx, next) => {
144
+ try {
145
+ await throttle(ctx, next);
146
+ }
147
+ catch (err) {
148
+ if (!isThrottleExceeded(err))
149
+ throw err;
150
+ const service = await ctx.containerResolver.make('authkit.server');
151
+ const cfg = service.config;
152
+ const render = cfg.render;
153
+ if (!render)
154
+ throw err; // Sem renderer configurado — mantém o 429 cru.
155
+ const retryAfter = retryAfterSeconds(err);
156
+ const uid = ctx.request.param('uid');
157
+ const retryUrl = uid ? `/auth/interaction/${uid}` : ctx.request.url();
158
+ const brand = cfg.branding ? brandFor(cfg.branding, undefined, undefined) : undefined;
159
+ ctx.response.status(429);
160
+ if (retryAfter)
161
+ ctx.response.header('Retry-After', String(retryAfter));
162
+ const body = await render(ctx, 'throttled', { brand, retryAfter, retryUrl });
163
+ const res = ctx.response;
164
+ if (body !== undefined && !res.hasLazyBody && body !== ctx.response) {
165
+ res.send(body);
166
+ }
167
+ // Não retorna o body: a `ThrottleMiddleware` é `Promise<void>` e o `send`
168
+ // explícito (com o guard `hasLazyBody` contra double-write) já entregou
169
+ // a resposta — mesmo contrato de `interaction_recovery.ts`.
170
+ }
102
171
  };
103
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.52.1",
3
+ "version": "0.53.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",
@@ -148,7 +148,7 @@
148
148
  "react-error-boundary": "6.1.2",
149
149
  "nuqs": "2.8.9",
150
150
  "recharts": "3.8.1",
151
- "@adonis-agora/authkit-react": "0.17.0"
151
+ "@adonis-agora/authkit-react": "0.18.0"
152
152
  },
153
153
  "scripts": {
154
154
  "build": "node scripts/build_host_css.mjs && node scripts/build_webauthn.mjs && node scripts/build_ui.mjs && node -e \"const fs=require('node:fs');for(const d of ['build/stubs','build/host/views'])fs.rmSync(d,{recursive:true,force:true})\" && tsc && node -e \"require('node:fs').cpSync('src/host/assets','build/src/host/assets',{recursive:true})\" && node -e \"require('node:fs').cpSync('stubs','build/stubs',{recursive:true,filter:(s)=>!s.endsWith('.ts')})\" && node -e \"const fs=require('node:fs');if(fs.existsSync('assets'))fs.cpSync('assets','build/assets',{recursive:true})\" && node -e \"require('node:fs').cpSync('src/host/views','build/host/views',{recursive:true})\" && node -e \"const fs=require('node:fs');fs.mkdirSync('build/host/ui',{recursive:true});fs.readdirSync('src/host/ui').filter(f=>f.endsWith('.html')).forEach(f=>fs.copyFileSync('src/host/ui/'+f,'build/host/ui/'+f))\" && node -e \"require('node:fs').copyFileSync('commands/commands.json','build/commands/commands.json')\" && node -e \"const fs=require('node:fs');fs.mkdirSync('build/password',{recursive:true});fs.copyFileSync('src/password/common_passwords.txt','build/password/common_passwords.txt')\"",