@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
@@ -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.
@@ -58,76 +58,33 @@ export type AuthkitScreen = 'login' | 'signup' | 'consent' | 'forgot' | 'reset'
58
58
  *
59
59
  * ---
60
60
  *
61
- * ### Props da tela `account/login`
61
+ * ### Props das telas de conta — tipos exportados (fonte única)
62
62
  *
63
- * | Prop | Tipo | Descrição |
64
- * |-------------|---------------------|-----------|
65
- * | `csrfToken` | `string` | Token CSRF para o campo `_csrf` do formulário. |
66
- * | `returnTo` | `string \| null` | Caminho interno de destino pós-login (já validado pelo servidor — só caminhos internos). Quando presente, o formulário deve incluir `<input type="hidden" name="return_to" value={returnTo} />`. O servidor revalida o valor no POST; hosts com tela custom precisam propagar esse hidden input. |
67
- * | `error` | `string \| undefined` | Mensagem de erro de autenticação localizada (credenciais inválidas, conta bloqueada, etc.). |
68
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
63
+ * Um host que escreve as telas do console em React tipa cada página com os tipos
64
+ * de props exportados do pacote, em vez de copiar o shape à mão deste docblock —
65
+ * que ficou desatualizado no passado. Cada tipo é a FONTE ÚNICA da verdade: os
66
+ * controllers satisfazem (`satisfies Omit<…, 'messages'>`) exatamente o mesmo
67
+ * tipo ao chamar `render()`, então divergência quebra o `tsc` do pacote. Ver
68
+ * `src/host/account_screen_props.ts`.
69
69
  *
70
- * ### Props da tela `account/security`
70
+ * | Tela | Tipo exportado |
71
+ * |---------------------------|----------------------------|
72
+ * | `account/login` | {@link AccountLoginProps} |
73
+ * | `account/security` | {@link AccountSecurityProps} |
74
+ * | `account/mfa` | {@link AccountMfaProps} |
75
+ * | `account/confirm` | {@link AccountConfirmProps} |
76
+ * | `account/email-confirmed` | {@link AccountEmailConfirmedProps} |
71
77
  *
72
- * | Prop | Tipo | Descrição |
73
- * |-------------------------|---------------------|-----------|
74
- * | `csrfToken` | `string` | Token CSRF para os formulários da tela. |
75
- * | `supported` | `boolean` | `false` quando o store não suporta o self-service de segurança (troca de senha/e-mail) — a tela deve degradar. |
76
- * | `profileSupported` | `boolean` | `true` quando o store suporta editar nome/avatar (`updateProfile`). |
77
- * | `avatarUploadSupported` | `boolean` | `true` quando algum backend (drive OU media) pode armazenar o upload de avatar. |
78
- * | `email` | `string` | E-mail atual da conta (`''` se ausente). |
79
- * | `name` | `string` | Nome atual da conta (`''` se ausente). |
80
- * | `avatarUrl` | `string` | URL do avatar atual (`''` se ausente). |
81
- * | `passwordChanged` | `string \| null` | Flash de sucesso da troca de senha (mensagem localizada) ou `null`. |
82
- * | `emailChangeRequested` | `string \| null` | Flash: link de confirmação de troca de e-mail enviado (ou cancelamento) ou `null`. |
83
- * | `emailChanged` | `string \| null` | Flash: troca de e-mail concluída ou `null`. |
84
- * | `profileUpdated` | `string \| null` | Flash: perfil atualizado ou `null`. |
85
- * | `error` | `string \| null` | Flash de erro de segurança (senha inválida, e-mail em uso, política violada) ou `null`. |
86
- * | `trustedDevicesEnabled` | `boolean` | `true` quando o recurso de dispositivos confiáveis está ligado. |
87
- * | `trustedDevicesRevoked` | `string \| null` | Flash: confiança deste navegador revogada ou `null`. |
88
- * | `sessionsSupported` | `boolean` | `true` quando o adapter OIDC enumera as sessões ativas da conta. |
89
- * | `sessions` | `Array<{ loginTs: string; browser: string; os: string; ip: string; location: string }>` | Sessões ativas da própria conta (vazio quando não suportado). `loginTs` é ISO ou `''`. |
90
- * | `exportSupported` | `boolean` | Sempre `true` — export de dados (portabilidade/LGPD) disponível para a conta logada. |
91
- * | `deletionSupported` | `boolean` | `true` quando o store suporta hard delete (danger zone). |
92
- * | `deleteError` | `string \| null` | Flash de erro da confirmação de deleção ou `null`. |
93
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
78
+ * A prop `messages` (catálogo i18n {@link AuthMessages}) é injetada por este
79
+ * renderer como shared prop e faz parte de todos esses tipos — os controllers,
80
+ * que não a passam, satisfazem `Omit<…, 'messages'>`.
94
81
  *
95
- * ### Props da tela `account/mfa`
82
+ * ```tsx
83
+ * import type { AccountSecurityProps } from '@adonis-agora/authkit-server'
96
84
  *
97
- * As props variam por action: `index` manda o estado base; `enroll` acrescenta
98
- * `enrolling`/`secret`/`qrDataUrl` (passo do QR); `confirm` com código inválido
99
- * reenvia `enrolling: true` + `error` (sem regenerar o segredo).
100
- *
101
- * | Prop | Tipo | Descrição |
102
- * |---------------------|---------------------|-----------|
103
- * | `csrfToken` | `string` | Token CSRF para os formulários de enroll/confirm/disable e passkeys. |
104
- * | `enabled` | `boolean` | `true` quando o TOTP já está confirmado (habilitado) para a conta. |
105
- * | `recoveryCodes` | `string[] \| null` | Códigos de recuperação recém-gerados (exibidos UMA vez após enroll/confirm) ou `null`. |
106
- * | `passkeysSupported` | `boolean` | `true` quando o store persiste credenciais WebAuthn (passkeys). |
107
- * | `passkeys` | `Array<{ id: string; label?: string; createdAt: string }>` | Passkeys cadastradas (vazio quando não suportado). `id` é base64url; `createdAt` é ISO. Presente em `index`. |
108
- * | `enrolling` | `boolean \| undefined` | `true` no passo de enrollment (após `POST /mfa/enroll` e na reexibição do `confirm` com código inválido) — a tela mostra o QR/segredo e o campo de código. Ausente em `index`. |
109
- * | `secret` | `string \| null \| undefined` | Segredo TOTP em texto (base32) para entrada manual, exibido no passo de enroll. `null` na reexibição do `confirm` (o segredo pendente NÃO é regenerado). Ausente em `index`. |
110
- * | `qrDataUrl` | `string \| null \| undefined` | QR code do `otpauth://` como data-URL (`<img src>`), no passo de enroll. `null` na reexibição do `confirm`. Ausente em `index`. |
111
- * | `error` | `string \| undefined` | Mensagem de erro localizada (ex.: código TOTP inválido no `confirm`). Ausente quando não há erro. |
112
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
113
- *
114
- * ### Props da tela `account/confirm` (sudo — confirmar identidade)
115
- *
116
- * | Prop | Tipo | Descrição |
117
- * |---------------|---------------------|-----------|
118
- * | `csrfToken` | `string` | Token CSRF para o POST de cada método. |
119
- * | `returnTo` | `string \| null` | Caminho interno de destino após confirmar (validado pelo servidor) ou `null`. |
120
- * | `error` | `string \| null` | Flash de erro da última tentativa de confirmação ou `null`. |
121
- * | `notice` | `string \| null` | Flash informativo (ex.: "link de confirmação enviado") ou `null`. |
122
- * | `methods` | `Array<{ id: string; labelKey: string; kind: 'form' \| 'action' \| 'redirect' \| 'webauthn'; endpoint: string; fields?: Array<{ name: string; type: 'password' \| 'text'; labelKey: string }> }>` | Métodos de sudo disponíveis para a conta. A tela renderiza por `kind`; `endpoint` é o POST de verificação (`webauthn` pede options em `${endpoint}/options`). |
123
- * | `preferredId` | `string \| null` | `id` do último método usado (destaque na UI) ou `null`. |
124
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
125
- *
126
- * ### Props da tela `account/email-confirmed` (terminal do link de troca de e-mail)
127
- *
128
- * | Prop | Tipo | Descrição |
129
- * |------------|----------------|-----------|
130
- * | `ok` | `boolean` | `true` quando o token era válido e o novo e-mail foi aplicado; `false` para token inválido/expirado ou store sem suporte. A tela mostra sucesso ou falha conforme o valor. |
131
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
85
+ * export default function Security(props: AccountSecurityProps) {
86
+ * return <form action="/account/security/password" method="post">…</form>
87
+ * }
88
+ * ```
132
89
  */
133
90
  export declare function inertiaRenderer(opts: InertiaRendererOptions): (ctx: HttpContext, view: string, props: Record<string, unknown>) => Promise<any>;
@@ -29,77 +29,34 @@ async function resolveMessagesFromCtx(ctx) {
29
29
  *
30
30
  * ---
31
31
  *
32
- * ### Props da tela `account/login`
32
+ * ### Props das telas de conta — tipos exportados (fonte única)
33
33
  *
34
- * | Prop | Tipo | Descrição |
35
- * |-------------|---------------------|-----------|
36
- * | `csrfToken` | `string` | Token CSRF para o campo `_csrf` do formulário. |
37
- * | `returnTo` | `string \| null` | Caminho interno de destino pós-login (já validado pelo servidor — só caminhos internos). Quando presente, o formulário deve incluir `<input type="hidden" name="return_to" value={returnTo} />`. O servidor revalida o valor no POST; hosts com tela custom precisam propagar esse hidden input. |
38
- * | `error` | `string \| undefined` | Mensagem de erro de autenticação localizada (credenciais inválidas, conta bloqueada, etc.). |
39
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
34
+ * Um host que escreve as telas do console em React tipa cada página com os tipos
35
+ * de props exportados do pacote, em vez de copiar o shape à mão deste docblock —
36
+ * que ficou desatualizado no passado. Cada tipo é a FONTE ÚNICA da verdade: os
37
+ * controllers satisfazem (`satisfies Omit<…, 'messages'>`) exatamente o mesmo
38
+ * tipo ao chamar `render()`, então divergência quebra o `tsc` do pacote. Ver
39
+ * `src/host/account_screen_props.ts`.
40
40
  *
41
- * ### Props da tela `account/security`
41
+ * | Tela | Tipo exportado |
42
+ * |---------------------------|----------------------------|
43
+ * | `account/login` | {@link AccountLoginProps} |
44
+ * | `account/security` | {@link AccountSecurityProps} |
45
+ * | `account/mfa` | {@link AccountMfaProps} |
46
+ * | `account/confirm` | {@link AccountConfirmProps} |
47
+ * | `account/email-confirmed` | {@link AccountEmailConfirmedProps} |
42
48
  *
43
- * | Prop | Tipo | Descrição |
44
- * |-------------------------|---------------------|-----------|
45
- * | `csrfToken` | `string` | Token CSRF para os formulários da tela. |
46
- * | `supported` | `boolean` | `false` quando o store não suporta o self-service de segurança (troca de senha/e-mail) — a tela deve degradar. |
47
- * | `profileSupported` | `boolean` | `true` quando o store suporta editar nome/avatar (`updateProfile`). |
48
- * | `avatarUploadSupported` | `boolean` | `true` quando algum backend (drive OU media) pode armazenar o upload de avatar. |
49
- * | `email` | `string` | E-mail atual da conta (`''` se ausente). |
50
- * | `name` | `string` | Nome atual da conta (`''` se ausente). |
51
- * | `avatarUrl` | `string` | URL do avatar atual (`''` se ausente). |
52
- * | `passwordChanged` | `string \| null` | Flash de sucesso da troca de senha (mensagem localizada) ou `null`. |
53
- * | `emailChangeRequested` | `string \| null` | Flash: link de confirmação de troca de e-mail enviado (ou cancelamento) ou `null`. |
54
- * | `emailChanged` | `string \| null` | Flash: troca de e-mail concluída ou `null`. |
55
- * | `profileUpdated` | `string \| null` | Flash: perfil atualizado ou `null`. |
56
- * | `error` | `string \| null` | Flash de erro de segurança (senha inválida, e-mail em uso, política violada) ou `null`. |
57
- * | `trustedDevicesEnabled` | `boolean` | `true` quando o recurso de dispositivos confiáveis está ligado. |
58
- * | `trustedDevicesRevoked` | `string \| null` | Flash: confiança deste navegador revogada ou `null`. |
59
- * | `sessionsSupported` | `boolean` | `true` quando o adapter OIDC enumera as sessões ativas da conta. |
60
- * | `sessions` | `Array<{ loginTs: string; browser: string; os: string; ip: string; location: string }>` | Sessões ativas da própria conta (vazio quando não suportado). `loginTs` é ISO ou `''`. |
61
- * | `exportSupported` | `boolean` | Sempre `true` — export de dados (portabilidade/LGPD) disponível para a conta logada. |
62
- * | `deletionSupported` | `boolean` | `true` quando o store suporta hard delete (danger zone). |
63
- * | `deleteError` | `string \| null` | Flash de erro da confirmação de deleção ou `null`. |
64
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
49
+ * A prop `messages` (catálogo i18n {@link AuthMessages}) é injetada por este
50
+ * renderer como shared prop e faz parte de todos esses tipos — os controllers,
51
+ * que não a passam, satisfazem `Omit<…, 'messages'>`.
65
52
  *
66
- * ### Props da tela `account/mfa`
53
+ * ```tsx
54
+ * import type { AccountSecurityProps } from '@adonis-agora/authkit-server'
67
55
  *
68
- * As props variam por action: `index` manda o estado base; `enroll` acrescenta
69
- * `enrolling`/`secret`/`qrDataUrl` (passo do QR); `confirm` com código inválido
70
- * reenvia `enrolling: true` + `error` (sem regenerar o segredo).
71
- *
72
- * | Prop | Tipo | Descrição |
73
- * |---------------------|---------------------|-----------|
74
- * | `csrfToken` | `string` | Token CSRF para os formulários de enroll/confirm/disable e passkeys. |
75
- * | `enabled` | `boolean` | `true` quando o TOTP já está confirmado (habilitado) para a conta. |
76
- * | `recoveryCodes` | `string[] \| null` | Códigos de recuperação recém-gerados (exibidos UMA vez após enroll/confirm) ou `null`. |
77
- * | `passkeysSupported` | `boolean` | `true` quando o store persiste credenciais WebAuthn (passkeys). |
78
- * | `passkeys` | `Array<{ id: string; label?: string; createdAt: string }>` | Passkeys cadastradas (vazio quando não suportado). `id` é base64url; `createdAt` é ISO. Presente em `index`. |
79
- * | `enrolling` | `boolean \| undefined` | `true` no passo de enrollment (após `POST /mfa/enroll` e na reexibição do `confirm` com código inválido) — a tela mostra o QR/segredo e o campo de código. Ausente em `index`. |
80
- * | `secret` | `string \| null \| undefined` | Segredo TOTP em texto (base32) para entrada manual, exibido no passo de enroll. `null` na reexibição do `confirm` (o segredo pendente NÃO é regenerado). Ausente em `index`. |
81
- * | `qrDataUrl` | `string \| null \| undefined` | QR code do `otpauth://` como data-URL (`<img src>`), no passo de enroll. `null` na reexibição do `confirm`. Ausente em `index`. |
82
- * | `error` | `string \| undefined` | Mensagem de erro localizada (ex.: código TOTP inválido no `confirm`). Ausente quando não há erro. |
83
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
84
- *
85
- * ### Props da tela `account/confirm` (sudo — confirmar identidade)
86
- *
87
- * | Prop | Tipo | Descrição |
88
- * |---------------|---------------------|-----------|
89
- * | `csrfToken` | `string` | Token CSRF para o POST de cada método. |
90
- * | `returnTo` | `string \| null` | Caminho interno de destino após confirmar (validado pelo servidor) ou `null`. |
91
- * | `error` | `string \| null` | Flash de erro da última tentativa de confirmação ou `null`. |
92
- * | `notice` | `string \| null` | Flash informativo (ex.: "link de confirmação enviado") ou `null`. |
93
- * | `methods` | `Array<{ id: string; labelKey: string; kind: 'form' \| 'action' \| 'redirect' \| 'webauthn'; endpoint: string; fields?: Array<{ name: string; type: 'password' \| 'text'; labelKey: string }> }>` | Métodos de sudo disponíveis para a conta. A tela renderiza por `kind`; `endpoint` é o POST de verificação (`webauthn` pede options em `${endpoint}/options`). |
94
- * | `preferredId` | `string \| null` | `id` do último método usado (destaque na UI) ou `null`. |
95
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
96
- *
97
- * ### Props da tela `account/email-confirmed` (terminal do link de troca de e-mail)
98
- *
99
- * | Prop | Tipo | Descrição |
100
- * |------------|----------------|-----------|
101
- * | `ok` | `boolean` | `true` quando o token era válido e o novo e-mail foi aplicado; `false` para token inválido/expirado ou store sem suporte. A tela mostra sucesso ou falha conforme o valor. |
102
- * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
56
+ * export default function Security(props: AccountSecurityProps) {
57
+ * return <form action="/account/security/password" method="post">…</form>
58
+ * }
59
+ * ```
103
60
  */
104
61
  export function inertiaRenderer(opts) {
105
62
  const allowed = opts.views ? new Set(opts.views) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.48.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",
@@ -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.16.0"
151
+ "@adonis-agora/authkit-react": "0.17.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')\"",