@dudousxd/adonis-authkit-server 0.5.0 → 0.7.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 (48) hide show
  1. package/README.md +23 -2
  2. package/build/host/views/account/apps.edge +58 -0
  3. package/build/host/views/account/security.edge +66 -0
  4. package/build/host/views/account/tokens.edge +1 -0
  5. package/build/host/views/admin/users.edge +62 -2
  6. package/build/host/views/login.edge +55 -0
  7. package/build/host/views/mfa-challenge.edge +12 -0
  8. package/build/index.d.ts +8 -2
  9. package/build/index.js +4 -1
  10. package/build/src/accounts/account_store.d.ts +80 -2
  11. package/build/src/accounts/account_store.js +12 -0
  12. package/build/src/accounts/lucid_account_store.js +8 -0
  13. package/build/src/accounts/lucid_store/core.d.ts +2 -2
  14. package/build/src/accounts/lucid_store/core.js +33 -0
  15. package/build/src/accounts/lucid_store/mfa.js +4 -1
  16. package/build/src/accounts/lucid_store/status_profile.d.ts +21 -0
  17. package/build/src/accounts/lucid_store/status_profile.js +66 -0
  18. package/build/src/audit/audit_sink.d.ts +1 -1
  19. package/build/src/define_config.d.ts +82 -0
  20. package/build/src/define_config.js +24 -1
  21. package/build/src/doctor/checks.js +32 -32
  22. package/build/src/events/dispatcher.d.ts +45 -0
  23. package/build/src/events/dispatcher.js +92 -0
  24. package/build/src/host/admin_sessions_service.d.ts +8 -0
  25. package/build/src/host/admin_sessions_service.js +19 -0
  26. package/build/src/host/avatar_storage.d.ts +58 -0
  27. package/build/src/host/avatar_storage.js +125 -0
  28. package/build/src/host/controllers/account_apps_controller.d.ts +15 -0
  29. package/build/src/host/controllers/account_apps_controller.js +61 -0
  30. package/build/src/host/controllers/account_security_controller.d.ts +9 -0
  31. package/build/src/host/controllers/account_security_controller.js +84 -2
  32. package/build/src/host/controllers/account_session_controller.js +3 -1
  33. package/build/src/host/controllers/admin/admin_users_controller.d.ts +13 -0
  34. package/build/src/host/controllers/admin/admin_users_controller.js +133 -0
  35. package/build/src/host/controllers/interaction_controller.d.ts +32 -0
  36. package/build/src/host/controllers/interaction_controller.js +169 -6
  37. package/build/src/host/default_mailer.d.ts +8 -0
  38. package/build/src/host/default_mailer.js +28 -0
  39. package/build/src/host/i18n.d.ts +98 -0
  40. package/build/src/host/i18n.js +106 -0
  41. package/build/src/host/login_attempt.d.ts +1 -0
  42. package/build/src/host/login_attempt.js +11 -0
  43. package/build/src/host/register_auth_host.js +18 -1
  44. package/build/src/host/trusted_device.d.ts +61 -0
  45. package/build/src/host/trusted_device.js +65 -0
  46. package/build/src/host/validators.d.ts +35 -0
  47. package/build/src/host/validators.js +14 -0
  48. package/package.json +6 -1
@@ -11,7 +11,10 @@ export function buildMfa(ctx) {
11
11
  return {
12
12
  async getMfaState(accountId) {
13
13
  const row = await Model.find(accountId);
14
- return { enabled: !!row?.mfaEnabledAt };
14
+ // `enabledAt` (epoch ms) habilita o trusted-device check: um cookie de
15
+ // confiança emitido ANTES deste instante é inválido (re-enrolar revoga).
16
+ const enabledAt = row?.mfaEnabledAt ? row.mfaEnabledAt.toMillis() : null;
17
+ return { enabled: !!row?.mfaEnabledAt, enabledAt };
15
18
  },
16
19
  async startTotpEnrollment(accountId) {
17
20
  const row = await Model.find(accountId);
@@ -0,0 +1,21 @@
1
+ import type { AccountStatusCapability, ProfileCapability } from '../account_store.js';
2
+ import type { LucidStoreContext } from './shared.js';
3
+ /**
4
+ * Indica se o model declara uma coluna (pela propriedade do model, ex.: `disabledAt`).
5
+ * Lucid expõe as colunas em `$columnsDefinitions` (Map de propertyName → definição).
6
+ * Usado para montar as capacidades opcionais SÓ quando a coluna existe — assim o
7
+ * store degrada graciosamente (capacidade ausente) em models que não têm a coluna.
8
+ */
9
+ export declare function hasColumn(Model: any, property: string): boolean;
10
+ /**
11
+ * Status da conta (habilitar/desabilitar) sobre a coluna `disabled_at`
12
+ * (propriedade `disabledAt`) do model. Só deve ser montado quando a coluna existe
13
+ * ({@link hasColumn}).
14
+ */
15
+ export declare function buildStatus(ctx: LucidStoreContext): AccountStatusCapability;
16
+ /**
17
+ * Edição de perfil (nome/avatar) sobre as colunas `full_name`/`avatar_url` do
18
+ * model. Grava apenas as colunas que existirem. Só deve ser montado quando ao
19
+ * menos uma das colunas existe.
20
+ */
21
+ export declare function buildProfile(ctx: LucidStoreContext): ProfileCapability;
@@ -0,0 +1,66 @@
1
+ import { DateTime } from 'luxon';
2
+ /**
3
+ * Indica se o model declara uma coluna (pela propriedade do model, ex.: `disabledAt`).
4
+ * Lucid expõe as colunas em `$columnsDefinitions` (Map de propertyName → definição).
5
+ * Usado para montar as capacidades opcionais SÓ quando a coluna existe — assim o
6
+ * store degrada graciosamente (capacidade ausente) em models que não têm a coluna.
7
+ */
8
+ export function hasColumn(Model, property) {
9
+ const defs = Model?.$columnsDefinitions;
10
+ if (!defs || typeof defs.has !== 'function')
11
+ return false;
12
+ return defs.has(property);
13
+ }
14
+ /**
15
+ * Status da conta (habilitar/desabilitar) sobre a coluna `disabled_at`
16
+ * (propriedade `disabledAt`) do model. Só deve ser montado quando a coluna existe
17
+ * ({@link hasColumn}).
18
+ */
19
+ export function buildStatus(ctx) {
20
+ const { Model } = ctx;
21
+ return {
22
+ async disableAccount(accountId) {
23
+ const row = await Model.find(accountId);
24
+ if (!row)
25
+ return;
26
+ row.disabledAt = DateTime.now();
27
+ await row.save();
28
+ },
29
+ async enableAccount(accountId) {
30
+ const row = await Model.find(accountId);
31
+ if (!row)
32
+ return;
33
+ row.disabledAt = null;
34
+ await row.save();
35
+ },
36
+ async isDisabled(accountId) {
37
+ const row = await Model.find(accountId);
38
+ if (!row)
39
+ return false;
40
+ return row.disabledAt !== null && row.disabledAt !== undefined;
41
+ },
42
+ };
43
+ }
44
+ /**
45
+ * Edição de perfil (nome/avatar) sobre as colunas `full_name`/`avatar_url` do
46
+ * model. Grava apenas as colunas que existirem. Só deve ser montado quando ao
47
+ * menos uma das colunas existe.
48
+ */
49
+ export function buildProfile(ctx) {
50
+ const { Model, toAccount } = ctx;
51
+ const canName = hasColumn(Model, 'fullName');
52
+ const canAvatar = hasColumn(Model, 'avatarUrl');
53
+ return {
54
+ async updateProfile(accountId, patch) {
55
+ const row = await Model.find(accountId);
56
+ if (!row)
57
+ return null;
58
+ if (canName && patch.name !== undefined)
59
+ row.fullName = patch.name;
60
+ if (canAvatar && patch.avatarUrl !== undefined)
61
+ row.avatarUrl = patch.avatarUrl;
62
+ await row.save();
63
+ return toAccount(row);
64
+ },
65
+ };
66
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP.
3
3
  */
4
- export type AuditEventType = 'login.success' | 'login.failure' | 'signup' | 'password_reset.issued' | 'password_reset.consumed' | 'pat.issued' | 'pat.revoked' | 'pat.used' | 'impersonation' | 'mfa.enabled' | 'mfa.disabled' | 'account.locked' | 'passkey.registered' | 'passkey.removed' | 'email_verification.issued' | 'email_verification.consumed' | 'client.created' | 'client.updated' | 'client.deleted' | 'session.revoked_all' | 'password.changed' | 'email.change_requested' | 'email.changed' | 'login.new_ip_notified';
4
+ export type AuditEventType = 'login.success' | 'login.failure' | 'signup' | 'password_reset.issued' | 'password_reset.consumed' | 'pat.issued' | 'pat.revoked' | 'pat.used' | 'impersonation' | 'mfa.enabled' | 'mfa.disabled' | 'account.locked' | 'passkey.registered' | 'passkey.removed' | 'email_verification.issued' | 'email_verification.consumed' | 'client.created' | 'client.updated' | 'client.deleted' | 'session.revoked_all' | 'password.changed' | 'email.change_requested' | 'email.changed' | 'login.new_ip_notified' | 'grant.revoked_by_user' | 'user.created' | 'user.password_reset_sent' | 'user.disabled' | 'user.enabled' | 'profile.updated';
5
5
  /**
6
6
  * Evento de auditoria a registrar. O timestamp é definido pelo sink (não aqui).
7
7
  */
@@ -4,8 +4,10 @@ import { adapters, type AdapterFactory, type OidcAdapterClass } from './adapters
4
4
  import type { AccountStore, AuthAccount } from './accounts/account_store.js';
5
5
  import type { PatStore } from './pat/pat_store.js';
6
6
  import type { AuditSink } from './audit/audit_sink.js';
7
+ import { type EventsConfigInput } from './events/dispatcher.js';
7
8
  import type { BrandingConfig } from './host/branding.js';
8
9
  import { type AuthMessages, type I18nConfig } from './host/i18n.js';
10
+ import { type ResolvedTrustedDevicesConfig, type TrustedDevicesConfigInput } from './host/trusted_device.js';
9
11
  export { adapters };
10
12
  export type { AuthAccount };
11
13
  export type AuthHostRenderer = (ctx: HttpContext, view: string, props: Record<string, unknown>) => unknown;
@@ -30,6 +32,12 @@ export interface MailHooks {
30
32
  verifyUrl: string;
31
33
  token: string;
32
34
  }) => Promise<void>;
35
+ /** Disparado após gerar o magic link de login (passwordless). */
36
+ onMagicLink?: (data: {
37
+ email: string;
38
+ magicUrl: string;
39
+ token: string;
40
+ }) => Promise<void>;
33
41
  }
34
42
  /** Bucket de rate-limit: pontos (requests) permitidos por janela de duração. */
35
43
  export interface RateLimitBucket {
@@ -153,6 +161,31 @@ export interface ResolvedDeviceFlowConfig {
153
161
  enabled: boolean;
154
162
  }
155
163
  export declare function resolveDeviceFlow(input?: DeviceFlowConfigInput): ResolvedDeviceFlowConfig;
164
+ /**
165
+ * Uploads — usa o `@adonisjs/drive` JÁ configurado no app (mesmo princípio do
166
+ * mailer/limiter: a infra do host por padrão, sobreponível aqui). Hoje cobre o
167
+ * upload de avatar no console de conta. Se o drive estiver ausente, a feature
168
+ * degrada para o input de URL.
169
+ */
170
+ export interface UploadsConfigInput {
171
+ avatars?: {
172
+ /** Disk do `@adonisjs/drive` a usar. Default: o disk DEFAULT do app. */
173
+ disk?: string;
174
+ /** Diretório/prefixo das chaves. Default: 'authkit/avatars'. */
175
+ directory?: string;
176
+ /** Tamanho máximo em MB. Default: 5. */
177
+ maxSizeMb?: number;
178
+ };
179
+ }
180
+ export interface ResolvedUploadsConfig {
181
+ avatars: {
182
+ /** Disk explícito; `undefined` = disk DEFAULT do app. */
183
+ disk?: string;
184
+ directory: string;
185
+ maxSizeMb: number;
186
+ };
187
+ }
188
+ export declare function resolveUploads(input?: UploadsConfigInput): ResolvedUploadsConfig;
156
189
  /**
157
190
  * DPoP — Demonstrating Proof of Possession (RFC 9449). Quando habilitado, o
158
191
  * oidc-provider aceita DPoP proofs e emite tokens sender-constrained
@@ -205,6 +238,29 @@ export interface ResolvedStepUpConfig {
205
238
  mfaAcr: string;
206
239
  }
207
240
  export declare function resolveStepUp(input?: StepUpConfigInput): ResolvedStepUpConfig;
241
+ /**
242
+ * Login passwordless. Duas vias independentes e opcionais:
243
+ * - `magicLink`: na tela de senha, oferece "me envie um link de login". Um token
244
+ * de uso único e curta duração é enviado por e-mail; abrir o link finaliza o
245
+ * login (amr `['email']`). Sempre responde "link enviado" (não vaza contas).
246
+ * - `passkeyFirst`: na tela de senha, se a conta tem passkeys, oferece "entrar
247
+ * com passkey" ANTES da senha. Verificar a passkey já conta como o 2º fator
248
+ * (amr `['webauthn']`) — não pede senha nem MFA.
249
+ *
250
+ * Ambas exigem que o accountStore implemente a capacidade correspondente
251
+ * (MagicLinkCapability / WebauthnCapability), senão a opção fica oculta.
252
+ */
253
+ export interface PasswordlessConfigInput {
254
+ /** Liga o login por magic link (e-mail). Default: false. */
255
+ magicLink?: boolean;
256
+ /** Liga o "entrar com passkey" antes da senha. Default: false. */
257
+ passkeyFirst?: boolean;
258
+ }
259
+ export interface ResolvedPasswordlessConfig {
260
+ magicLink: boolean;
261
+ passkeyFirst: boolean;
262
+ }
263
+ export declare function resolvePasswordless(input?: PasswordlessConfigInput): ResolvedPasswordlessConfig;
208
264
  /**
209
265
  * Console admin opt-in do IdP (B6). Quando habilitado, monta o grupo `/admin/*`
210
266
  * (dashboard, usuários/papéis, clients, audit) atrás de um guard que exige sessão
@@ -281,6 +337,13 @@ export interface AuthServerConfigInput {
281
337
  mail?: MailHooks;
282
338
  /** Sink de auditoria (best-effort). Opcional — quando ausente, auditoria é no-op. */
283
339
  audit?: AuditSink;
340
+ /**
341
+ * Eventos/webhooks: o host observa CADA evento de auditoria via callback
342
+ * in-process (`onEvent`) e/ou POST de webhook (`webhook`). Best-effort, nunca
343
+ * lança para a request. Quando setado, o `audit` resolvido vira um fan-out
344
+ * (sink original + onEvent + webhook).
345
+ */
346
+ events?: EventsConfigInput;
284
347
  /**
285
348
  * Label de issuer TOTP mostrado nos apps autenticadores (MFA). Default: 'AuthKit'.
286
349
  * O `lucidAccountStore` lê isso para montar o keyuri/QR.
@@ -299,12 +362,25 @@ export interface AuthServerConfigInput {
299
362
  dynamicRegistration?: DynamicRegistrationConfigInput;
300
363
  /** Device Authorization Grant (RFC 8628). Default: desligado. */
301
364
  deviceFlow?: DeviceFlowConfigInput;
365
+ /** Uploads (avatar) via o `@adonisjs/drive` do app. Default: drive default, 5MB. */
366
+ uploads?: UploadsConfigInput;
302
367
  /** DPoP — sender-constrained tokens (RFC 9449). Default: desligado. */
303
368
  dpop?: DpopConfigInput;
304
369
  /** Pushed Authorization Requests (RFC 9126). Default: desligado. */
305
370
  par?: ParConfigInput;
306
371
  /** Step-up auth via acr_values (MFA por requisição). Default: vazio (só o mfaAcr derivado). */
307
372
  stepUp?: StepUpConfigInput;
373
+ /**
374
+ * Trusted devices: pular o MFA neste dispositivo por N dias via cookie
375
+ * encriptado (appKey-backed), sem migração. Default: ligado, 30 dias. Step-up
376
+ * (acr_values) sempre ignora o cookie e força o MFA.
377
+ */
378
+ trustedDevices?: TrustedDevicesConfigInput;
379
+ /**
380
+ * Login passwordless (magic link por e-mail e/ou passkey-first). Default: ambos
381
+ * desligados. Exigem as capacidades correspondentes no accountStore.
382
+ */
383
+ passwordless?: PasswordlessConfigInput;
308
384
  /**
309
385
  * Console admin do IdP (B6). Default: desligado. Quando ligado, o host também
310
386
  * deve passar `admin: true` em {@link AuthHostOptions} no registro de rotas
@@ -352,12 +428,18 @@ export interface ResolvedServerConfig {
352
428
  dynamicRegistration: ResolvedDynamicRegistrationConfig;
353
429
  /** Device Authorization Grant resolvido (default desligado). */
354
430
  deviceFlow: ResolvedDeviceFlowConfig;
431
+ /** Uploads resolvido (avatar via drive do app; sempre presente). */
432
+ uploads: ResolvedUploadsConfig;
355
433
  /** DPoP resolvido (default desligado). */
356
434
  dpop: ResolvedDpopConfig;
357
435
  /** PAR resolvido (default desligado). */
358
436
  par: ResolvedParConfig;
359
437
  /** Step-up auth resolvido (mfaAcr sempre presente). */
360
438
  stepUp: ResolvedStepUpConfig;
439
+ /** Trusted devices resolvido (default ligado, 30 dias). */
440
+ trustedDevices: ResolvedTrustedDevicesConfig;
441
+ /** Passwordless resolvido (default tudo desligado). */
442
+ passwordless: ResolvedPasswordlessConfig;
361
443
  /** Console admin resolvido (sempre presente; default desligado). */
362
444
  admin: ResolvedAdminConfig;
363
445
  /** Catálogo de mensagens ativo (locale resolvido), pronto para os renderers. */
@@ -2,7 +2,9 @@ import { configProvider } from '@adonisjs/core';
2
2
  import { generateJwks } from './keys/jwks_manager.js';
3
3
  import { ensureKeystore } from './keys/keystore.js';
4
4
  import { adapters } from './adapters/factory.js';
5
+ import { composeAuditSink, resolveEvents } from './events/dispatcher.js';
5
6
  import { resolveMessages } from './host/i18n.js';
7
+ import { resolveTrustedDevices, } from './host/trusted_device.js';
6
8
  export { adapters };
7
9
  const RATE_LIMIT_DEFAULTS = {
8
10
  login: { points: 10, duration: '1 min' },
@@ -54,6 +56,15 @@ export function resolveDynamicRegistration(input) {
54
56
  export function resolveDeviceFlow(input) {
55
57
  return { enabled: input?.enabled ?? false };
56
58
  }
59
+ export function resolveUploads(input) {
60
+ return {
61
+ avatars: {
62
+ disk: input?.avatars?.disk,
63
+ directory: input?.avatars?.directory ?? 'authkit/avatars',
64
+ maxSizeMb: input?.avatars?.maxSizeMb ?? 5,
65
+ },
66
+ };
67
+ }
57
68
  export function resolveDpop(input) {
58
69
  return { enabled: input?.enabled ?? false };
59
70
  }
@@ -69,6 +80,12 @@ export function resolveStepUp(input) {
69
80
  const acrValues = Array.from(new Set([...(input?.acrValues ?? []), mfaAcr]));
70
81
  return { acrValues, mfaAcr };
71
82
  }
83
+ export function resolvePasswordless(input) {
84
+ return {
85
+ magicLink: input?.magicLink ?? false,
86
+ passkeyFirst: input?.passkeyFirst ?? false,
87
+ };
88
+ }
72
89
  export function resolveAdmin(input) {
73
90
  return {
74
91
  enabled: input?.enabled ?? false,
@@ -157,14 +174,20 @@ export function defineConfig(config) {
157
174
  lockout: resolveLockout(config.lockout),
158
175
  notifications: resolveNotifications(config.notifications),
159
176
  mail: config.mail,
160
- audit: config.audit,
177
+ audit: (() => {
178
+ const events = resolveEvents(config.events);
179
+ return events ? composeAuditSink(config.audit, events) : config.audit;
180
+ })(),
161
181
  mfaIssuer: config.mfaIssuer ?? 'AuthKit',
162
182
  webauthn: resolveWebauthn(config.issuer, config.mfaIssuer ?? 'AuthKit', config.webauthn),
163
183
  dynamicRegistration: resolveDynamicRegistration(config.dynamicRegistration),
164
184
  deviceFlow: resolveDeviceFlow(config.deviceFlow),
185
+ uploads: resolveUploads(config.uploads),
165
186
  dpop: resolveDpop(config.dpop),
166
187
  par: resolvePar(config.par),
167
188
  stepUp: resolveStepUp(config.stepUp),
189
+ trustedDevices: resolveTrustedDevices(config.trustedDevices),
190
+ passwordless: resolvePasswordless(config.passwordless),
168
191
  admin: resolveAdmin(config.admin),
169
192
  messages: resolveMessages(config.i18n),
170
193
  locale: config.i18n?.locale ?? 'pt-BR',
@@ -13,10 +13,10 @@ export function checkConfigResolves(input) {
13
13
  if (!input.authkitConfig) {
14
14
  return {
15
15
  level: 'error',
16
- message: "config('authkit') não resolveu — config/authkit.ts está ausente ou inválido.",
16
+ message: "config('authkit') did not resolve — config/authkit.ts is missing or invalid.",
17
17
  };
18
18
  }
19
- return { level: 'ok', message: "config('authkit') resolvido." };
19
+ return { level: 'ok', message: "config('authkit') resolved." };
20
20
  }
21
21
  /** issuer é uma URL válida e seu pathname casa com o mountPath. */
22
22
  export function checkIssuer(input) {
@@ -26,21 +26,21 @@ export function checkIssuer(input) {
26
26
  const issuer = cfg.issuer;
27
27
  const mountPath = cfg.mountPath ?? '/oidc';
28
28
  if (typeof issuer !== 'string' || issuer.length === 0) {
29
- return [{ level: 'error', message: 'issuer ausente na config.' }];
29
+ return [{ level: 'error', message: 'issuer missing in config.' }];
30
30
  }
31
31
  let url;
32
32
  try {
33
33
  url = new URL(issuer);
34
34
  }
35
35
  catch {
36
- return [{ level: 'error', message: `issuer não é uma URL válida: "${issuer}".` }];
36
+ return [{ level: 'error', message: `issuer is not a valid URL: "${issuer}".` }];
37
37
  }
38
- const findings = [{ level: 'ok', message: `issuer válido: ${url.origin}${url.pathname}` }];
38
+ const findings = [{ level: 'ok', message: `valid issuer: ${url.origin}${url.pathname}` }];
39
39
  const normalize = (p) => (p.endsWith('/') ? p.slice(0, -1) : p) || '/';
40
40
  if (normalize(url.pathname) !== normalize(mountPath)) {
41
41
  findings.push({
42
42
  level: 'warn',
43
- message: `O pathname do issuer ("${url.pathname}") difere do mountPath ("${mountPath}"). As rotas OIDC podem não casar com as URLs anunciadas no discovery.`,
43
+ message: `issuer pathname ("${url.pathname}") differs from mountPath ("${mountPath}"). OIDC routes may not match the URLs announced in discovery.`,
44
44
  });
45
45
  }
46
46
  return findings;
@@ -49,19 +49,19 @@ export function checkIssuer(input) {
49
49
  export function checkClients(input) {
50
50
  const cfg = input.authkitConfig;
51
51
  if (!cfg)
52
- return { level: 'error', message: 'sem config para validar clients.' };
52
+ return { level: 'error', message: 'no config to validate clients.' };
53
53
  const clients = Array.isArray(cfg.clients) ? cfg.clients : [];
54
54
  if (clients.length === 0) {
55
- return { level: 'error', message: 'nenhum client configurado em `clients`.' };
55
+ return { level: 'error', message: 'no client configured in `clients`.' };
56
56
  }
57
57
  const withRedirects = clients.filter((c) => Array.isArray(c?.redirectUris) && c.redirectUris.length > 0);
58
58
  if (withRedirects.length === 0) {
59
59
  return {
60
60
  level: 'error',
61
- message: `${clients.length} client(s) configurado(s), mas nenhum tem redirectUris.`,
61
+ message: `${clients.length} client(s) configured, but none has redirectUris.`,
62
62
  };
63
63
  }
64
- return { level: 'ok', message: `${withRedirects.length}/${clients.length} client(s) com redirectUris.` };
64
+ return { level: 'ok', message: `${withRedirects.length}/${clients.length} client(s) with redirectUris.` };
65
65
  }
66
66
  /** accountStore presente + quais capacidades implementa. */
67
67
  export function checkAccountStore(input) {
@@ -70,9 +70,9 @@ export function checkAccountStore(input) {
70
70
  return [];
71
71
  const store = cfg.accountStore;
72
72
  if (!store) {
73
- return [{ level: 'error', message: 'accountStore ausenteobrigatório.' }];
73
+ return [{ level: 'error', message: 'accountStore missingrequired.' }];
74
74
  }
75
- const findings = [{ level: 'ok', message: 'accountStore presente.' }];
75
+ const findings = [{ level: 'ok', message: 'accountStore present.' }];
76
76
  const caps = [];
77
77
  if (has(store, 'getMfaState'))
78
78
  caps.push('MFA');
@@ -85,8 +85,8 @@ export function checkAccountStore(input) {
85
85
  findings.push({
86
86
  level: 'ok',
87
87
  message: caps.length
88
- ? `Capacidades opcionais: ${caps.join(', ')}.`
89
- : 'Apenas o núcleo do accountStore (sem MFA/passkeys/linking/security).',
88
+ ? `Optional capabilities: ${caps.join(', ')}.`
89
+ : 'accountStore core only (no MFA/passkeys/linking/security).',
90
90
  });
91
91
  return findings;
92
92
  }
@@ -96,19 +96,19 @@ export function checkSession(input) {
96
96
  return [
97
97
  {
98
98
  level: 'error',
99
- message: '@adonisjs/session não é importávelinstale-o (peer obrigatório).',
99
+ message: '@adonisjs/session is not importableinstall it (required peer).',
100
100
  },
101
101
  ];
102
102
  }
103
103
  if (!input.sessionConfig) {
104
- return [{ level: 'warn', message: "config('session') ausenteo provider de sessão pode não estar configurado." }];
104
+ return [{ level: 'warn', message: "config('session') missingthe session provider may not be configured." }];
105
105
  }
106
- const findings = [{ level: 'ok', message: 'provider de sessão configurado.' }];
106
+ const findings = [{ level: 'ok', message: 'session provider configured.' }];
107
107
  const driver = input.sessionConfig.store ?? input.sessionConfig.driver;
108
108
  if (driver === 'cookie') {
109
109
  findings.push({
110
110
  level: 'warn',
111
- message: 'session store = cookie: token sets grandes podem estourar o limite de 4KB do cookie. Prefira `redis`/`file` em produção.',
111
+ message: 'session store = cookie: large token sets may exceed the 4KB cookie limit. Prefer `redis`/`file` in production.',
112
112
  });
113
113
  }
114
114
  return findings;
@@ -116,12 +116,12 @@ export function checkSession(input) {
116
116
  /** Hint de exceções de CSRF do shield para o mountPath. */
117
117
  export function checkShield(input) {
118
118
  if (!input.peers.shield) {
119
- return { level: 'error', message: '@adonisjs/shield não é importávelinstale-o (peer obrigatório).' };
119
+ return { level: 'error', message: '@adonisjs/shield is not importableinstall it (required peer).' };
120
120
  }
121
121
  const mountPath = input.authkitConfig?.mountPath ?? '/oidc';
122
122
  return {
123
123
  level: 'warn',
124
- message: `Garanta que as rotas POST do IdP sob "${mountPath}" estejam nas exceções de CSRF do shield (ex.: endpoint /token), senão chamadas server-to-server falham.`,
124
+ message: `Make sure the IdP POST routes under "${mountPath}" are in the shield CSRF exceptions (e.g. the /token endpoint), otherwise server-to-server calls fail.`,
125
125
  };
126
126
  }
127
127
  /** ally só é necessário quando social está configurado. */
@@ -129,12 +129,12 @@ export function checkAlly(input) {
129
129
  const social = input.authkitConfig?.social;
130
130
  const usesSocial = !!social && (Array.isArray(social.providers) ? social.providers.length > 0 : Object.keys(social).length > 0);
131
131
  if (!usesSocial) {
132
- return { level: 'ok', message: 'login social não configurado — @adonisjs/ally é opcional.' };
132
+ return { level: 'ok', message: 'social login not configured — @adonisjs/ally is optional.' };
133
133
  }
134
134
  if (!input.peers.ally) {
135
- return { level: 'error', message: 'login social configurado, mas @adonisjs/ally não é importável.' };
135
+ return { level: 'error', message: 'social login configured, but @adonisjs/ally is not importable.' };
136
136
  }
137
- return { level: 'ok', message: 'login social configurado e @adonisjs/ally disponível.' };
137
+ return { level: 'ok', message: 'social login configured and @adonisjs/ally available.' };
138
138
  }
139
139
  /** rateLimit ligado mas @adonisjs/limiter ausente → warn. */
140
140
  export function checkRateLimit(input) {
@@ -142,15 +142,15 @@ export function checkRateLimit(input) {
142
142
  const rateLimit = cfg?.rateLimit;
143
143
  const enabled = rateLimit === undefined ? true : rateLimit?.enabled !== false;
144
144
  if (!enabled) {
145
- return { level: 'ok', message: 'rate-limiting desligado por config.' };
145
+ return { level: 'ok', message: 'rate-limiting disabled by config.' };
146
146
  }
147
147
  if (!input.peers.limiter) {
148
148
  return {
149
149
  level: 'warn',
150
- message: 'rate-limiting está ligado (default), mas @adonisjs/limiter não é importávelvira no-op (sem proteção anti-brute-force).',
150
+ message: 'rate-limiting is on (default), but @adonisjs/limiter is not importablebecomes a no-op (no anti-brute-force protection).',
151
151
  };
152
152
  }
153
- return { level: 'ok', message: 'rate-limiting ligado e @adonisjs/limiter disponível.' };
153
+ return { level: 'ok', message: 'rate-limiting on and @adonisjs/limiter available.' };
154
154
  }
155
155
  /** admin.enabled mas sem roles → warn. */
156
156
  export function checkAdmin(input) {
@@ -161,10 +161,10 @@ export function checkAdmin(input) {
161
161
  if (roles.length === 0) {
162
162
  return {
163
163
  level: 'warn',
164
- message: 'console admin ligado, mas sem `admin.roles` — ninguém terá acesso (default ["ADMIN"] não foi resolvido aqui).',
164
+ message: 'admin console on, but no `admin.roles` — nobody will have access (the default ["ADMIN"] was not resolved here).',
165
165
  };
166
166
  }
167
- return { level: 'ok', message: `console admin ligado para roles: ${roles.join(', ')}.` };
167
+ return { level: 'ok', message: `admin console on for roles: ${roles.join(', ')}.` };
168
168
  }
169
169
  /** webauthn rpId deve casar com o host do issuer. */
170
170
  export function checkWebauthn(input) {
@@ -185,10 +185,10 @@ export function checkWebauthn(input) {
185
185
  if (webauthn.rpId !== host) {
186
186
  return {
187
187
  level: 'warn',
188
- message: `webauthn.rpId ("${webauthn.rpId}") difere do host do issuer ("${host}") — as passkeys não validarão no browser.`,
188
+ message: `webauthn.rpId ("${webauthn.rpId}") differs from the issuer host ("${host}") — passkeys will not validate in the browser.`,
189
189
  };
190
190
  }
191
- return { level: 'ok', message: `webauthn.rpId casa com o host do issuer (${host}).` };
191
+ return { level: 'ok', message: `webauthn.rpId matches the issuer host (${host}).` };
192
192
  }
193
193
  /** info sobre rotação quando jwks é managed. */
194
194
  export function checkJwks(input) {
@@ -198,10 +198,10 @@ export function checkJwks(input) {
198
198
  if (jwks.source === 'managed') {
199
199
  return {
200
200
  level: 'ok',
201
- message: 'jwks managed — rotacione as chaves de assinatura com `node ace authkit:rotate-keys` (use --store para persistir entre boots).',
201
+ message: 'jwks managed — rotate the signing keys with `node ace authkit:rotate-keys` (use --store to persist across boots).',
202
202
  };
203
203
  }
204
- return { level: 'ok', message: 'jwks fornecido inline (source=jwks).' };
204
+ return { level: 'ok', message: 'jwks provided inline (source=jwks).' };
205
205
  }
206
206
  /** Roda todos os checks e devolve a lista plana de findings. */
207
207
  export function runAllChecks(input) {
@@ -0,0 +1,45 @@
1
+ import type { AuditEvent, AuditSink } from '../audit/audit_sink.js';
2
+ /**
3
+ * Configuração de eventos/webhooks para o host observar tudo que o IdP audita.
4
+ *
5
+ * - `onEvent`: callback in-process disparado para CADA evento de auditoria
6
+ * (best-effort, fire-and-forget). Útil para encaminhar a um bus interno.
7
+ * - `webhook`: POST JSON do evento para uma URL externa. Quando `secret` é dado,
8
+ * assina o corpo com HMAC-SHA256 no header `x-authkit-signature`.
9
+ *
10
+ * Nada aqui pode lançar para dentro do caminho da request: todo erro é engolido.
11
+ */
12
+ export interface EventsConfigInput {
13
+ /** Callback in-process para cada evento auditado (best-effort). */
14
+ onEvent?: (event: AuditEvent) => void | Promise<void>;
15
+ /** Webhook HTTP: POST do evento em JSON. */
16
+ webhook?: {
17
+ /** URL de destino do POST. */
18
+ url: string;
19
+ /** Segredo opcional para assinar o corpo (HMAC-SHA256). */
20
+ secret?: string;
21
+ };
22
+ }
23
+ export interface ResolvedEventsConfig {
24
+ onEvent?: (event: AuditEvent) => void | Promise<void>;
25
+ webhook?: {
26
+ url: string;
27
+ secret?: string;
28
+ };
29
+ }
30
+ export declare function resolveEvents(input?: EventsConfigInput): ResolvedEventsConfig | undefined;
31
+ /**
32
+ * Constrói o corpo JSON canônico do webhook a partir de um evento de auditoria.
33
+ * O `ts` é o instante do dispatch (ISO 8601).
34
+ */
35
+ export declare function buildWebhookBody(event: AuditEvent): string;
36
+ /** Calcula o header de assinatura `sha256=<hmac>` para um corpo + segredo. */
37
+ export declare function signWebhookBody(body: string, secret: string): string;
38
+ /**
39
+ * Decora um AuditSink (ou cria um do zero) num sink fan-out: cada `record`
40
+ * persiste no sink original (se houver) E dispara onEvent + webhook. Falhas em
41
+ * qualquer ramo são isoladas — uma não impede as outras nem a request.
42
+ *
43
+ * `list` é delegado ao sink original quando existir (preserva a consulta admin).
44
+ */
45
+ export declare function composeAuditSink(original: AuditSink | undefined, events: ResolvedEventsConfig): AuditSink;
@@ -0,0 +1,92 @@
1
+ import { createHmac } from 'node:crypto';
2
+ export function resolveEvents(input) {
3
+ if (!input || (!input.onEvent && !input.webhook))
4
+ return undefined;
5
+ return {
6
+ onEvent: input.onEvent,
7
+ webhook: input.webhook,
8
+ };
9
+ }
10
+ /** Timeout (ms) do POST do webhook antes de abortar. */
11
+ const WEBHOOK_TIMEOUT_MS = 5000;
12
+ /**
13
+ * Constrói o corpo JSON canônico do webhook a partir de um evento de auditoria.
14
+ * O `ts` é o instante do dispatch (ISO 8601).
15
+ */
16
+ export function buildWebhookBody(event) {
17
+ return JSON.stringify({
18
+ type: event.type,
19
+ accountId: event.accountId ?? null,
20
+ email: event.email ?? null,
21
+ clientId: event.clientId ?? null,
22
+ ip: event.ip ?? null,
23
+ metadata: event.metadata ?? {},
24
+ ts: new Date().toISOString(),
25
+ });
26
+ }
27
+ /** Calcula o header de assinatura `sha256=<hmac>` para um corpo + segredo. */
28
+ export function signWebhookBody(body, secret) {
29
+ return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
30
+ }
31
+ /**
32
+ * Dispara o webhook de forma fire-and-forget: timeout de 5s via AbortSignal,
33
+ * captura QUALQUER erro (rede, abort, HMAC) sem propagar. Nunca lança.
34
+ */
35
+ async function dispatchWebhook(webhook, event) {
36
+ try {
37
+ const body = buildWebhookBody(event);
38
+ const headers = { 'content-type': 'application/json' };
39
+ if (webhook.secret) {
40
+ headers['x-authkit-signature'] = signWebhookBody(body, webhook.secret);
41
+ }
42
+ await fetch(webhook.url, {
43
+ method: 'POST',
44
+ headers,
45
+ body,
46
+ signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
47
+ });
48
+ }
49
+ catch {
50
+ // best-effort: erro de webhook nunca quebra o caminho da request.
51
+ }
52
+ }
53
+ /**
54
+ * Decora um AuditSink (ou cria um do zero) num sink fan-out: cada `record`
55
+ * persiste no sink original (se houver) E dispara onEvent + webhook. Falhas em
56
+ * qualquer ramo são isoladas — uma não impede as outras nem a request.
57
+ *
58
+ * `list` é delegado ao sink original quando existir (preserva a consulta admin).
59
+ */
60
+ export function composeAuditSink(original, events) {
61
+ const composed = {
62
+ async record(event) {
63
+ // Persistência original (best-effort, isolada).
64
+ if (original) {
65
+ try {
66
+ await original.record(event);
67
+ }
68
+ catch {
69
+ // sink original com defeito não impede os demais ramos.
70
+ }
71
+ }
72
+ // Callback in-process (best-effort, isolado).
73
+ if (events.onEvent) {
74
+ try {
75
+ await events.onEvent(event);
76
+ }
77
+ catch {
78
+ // onEvent com defeito não quebra a request.
79
+ }
80
+ }
81
+ // Webhook fire-and-forget (não aguardamos a entrega).
82
+ if (events.webhook) {
83
+ void dispatchWebhook(events.webhook, event);
84
+ }
85
+ },
86
+ };
87
+ // Preserva a capacidade de consulta do sink original (console admin).
88
+ if (original && typeof original.list === 'function') {
89
+ composed.list = original.list.bind(original);
90
+ }
91
+ return composed;
92
+ }