@dudousxd/adonis-authkit-server 0.4.0 → 0.6.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 (50) 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 +53 -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 +9 -3
  9. package/build/index.js +5 -2
  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 +53 -0
  20. package/build/src/define_config.js +14 -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/controllers/account_apps_controller.d.ts +15 -0
  27. package/build/src/host/controllers/account_apps_controller.js +61 -0
  28. package/build/src/host/controllers/account_mfa_controller.js +6 -2
  29. package/build/src/host/controllers/account_security_controller.d.ts +9 -0
  30. package/build/src/host/controllers/account_security_controller.js +52 -2
  31. package/build/src/host/controllers/account_session_controller.js +3 -1
  32. package/build/src/host/controllers/admin/admin_users_controller.d.ts +13 -0
  33. package/build/src/host/controllers/admin/admin_users_controller.js +133 -0
  34. package/build/src/host/controllers/interaction_controller.d.ts +32 -0
  35. package/build/src/host/controllers/interaction_controller.js +175 -8
  36. package/build/src/host/default_mailer.d.ts +8 -0
  37. package/build/src/host/default_mailer.js +81 -19
  38. package/build/src/host/email_templates.d.ts +4 -0
  39. package/build/src/host/email_templates.js +5 -2
  40. package/build/src/host/i18n.d.ts +395 -11
  41. package/build/src/host/i18n.js +433 -12
  42. package/build/src/host/login_attempt.d.ts +1 -0
  43. package/build/src/host/login_attempt.js +11 -0
  44. package/build/src/host/register_auth_host.js +18 -1
  45. package/build/src/host/trusted_device.d.ts +61 -0
  46. package/build/src/host/trusted_device.js +65 -0
  47. package/build/src/host/validators.d.ts +35 -0
  48. package/build/src/host/validators.js +14 -0
  49. package/build/src/observability/metrics_controller.js +4 -4
  50. package/package.json +1 -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 {
@@ -205,6 +213,29 @@ export interface ResolvedStepUpConfig {
205
213
  mfaAcr: string;
206
214
  }
207
215
  export declare function resolveStepUp(input?: StepUpConfigInput): ResolvedStepUpConfig;
216
+ /**
217
+ * Login passwordless. Duas vias independentes e opcionais:
218
+ * - `magicLink`: na tela de senha, oferece "me envie um link de login". Um token
219
+ * de uso único e curta duração é enviado por e-mail; abrir o link finaliza o
220
+ * login (amr `['email']`). Sempre responde "link enviado" (não vaza contas).
221
+ * - `passkeyFirst`: na tela de senha, se a conta tem passkeys, oferece "entrar
222
+ * com passkey" ANTES da senha. Verificar a passkey já conta como o 2º fator
223
+ * (amr `['webauthn']`) — não pede senha nem MFA.
224
+ *
225
+ * Ambas exigem que o accountStore implemente a capacidade correspondente
226
+ * (MagicLinkCapability / WebauthnCapability), senão a opção fica oculta.
227
+ */
228
+ export interface PasswordlessConfigInput {
229
+ /** Liga o login por magic link (e-mail). Default: false. */
230
+ magicLink?: boolean;
231
+ /** Liga o "entrar com passkey" antes da senha. Default: false. */
232
+ passkeyFirst?: boolean;
233
+ }
234
+ export interface ResolvedPasswordlessConfig {
235
+ magicLink: boolean;
236
+ passkeyFirst: boolean;
237
+ }
238
+ export declare function resolvePasswordless(input?: PasswordlessConfigInput): ResolvedPasswordlessConfig;
208
239
  /**
209
240
  * Console admin opt-in do IdP (B6). Quando habilitado, monta o grupo `/admin/*`
210
241
  * (dashboard, usuários/papéis, clients, audit) atrás de um guard que exige sessão
@@ -281,6 +312,13 @@ export interface AuthServerConfigInput {
281
312
  mail?: MailHooks;
282
313
  /** Sink de auditoria (best-effort). Opcional — quando ausente, auditoria é no-op. */
283
314
  audit?: AuditSink;
315
+ /**
316
+ * Eventos/webhooks: o host observa CADA evento de auditoria via callback
317
+ * in-process (`onEvent`) e/ou POST de webhook (`webhook`). Best-effort, nunca
318
+ * lança para a request. Quando setado, o `audit` resolvido vira um fan-out
319
+ * (sink original + onEvent + webhook).
320
+ */
321
+ events?: EventsConfigInput;
284
322
  /**
285
323
  * Label de issuer TOTP mostrado nos apps autenticadores (MFA). Default: 'AuthKit'.
286
324
  * O `lucidAccountStore` lê isso para montar o keyuri/QR.
@@ -305,6 +343,17 @@ export interface AuthServerConfigInput {
305
343
  par?: ParConfigInput;
306
344
  /** Step-up auth via acr_values (MFA por requisição). Default: vazio (só o mfaAcr derivado). */
307
345
  stepUp?: StepUpConfigInput;
346
+ /**
347
+ * Trusted devices: pular o MFA neste dispositivo por N dias via cookie
348
+ * encriptado (appKey-backed), sem migração. Default: ligado, 30 dias. Step-up
349
+ * (acr_values) sempre ignora o cookie e força o MFA.
350
+ */
351
+ trustedDevices?: TrustedDevicesConfigInput;
352
+ /**
353
+ * Login passwordless (magic link por e-mail e/ou passkey-first). Default: ambos
354
+ * desligados. Exigem as capacidades correspondentes no accountStore.
355
+ */
356
+ passwordless?: PasswordlessConfigInput;
308
357
  /**
309
358
  * Console admin do IdP (B6). Default: desligado. Quando ligado, o host também
310
359
  * deve passar `admin: true` em {@link AuthHostOptions} no registro de rotas
@@ -358,6 +407,10 @@ export interface ResolvedServerConfig {
358
407
  par: ResolvedParConfig;
359
408
  /** Step-up auth resolvido (mfaAcr sempre presente). */
360
409
  stepUp: ResolvedStepUpConfig;
410
+ /** Trusted devices resolvido (default ligado, 30 dias). */
411
+ trustedDevices: ResolvedTrustedDevicesConfig;
412
+ /** Passwordless resolvido (default tudo desligado). */
413
+ passwordless: ResolvedPasswordlessConfig;
361
414
  /** Console admin resolvido (sempre presente; default desligado). */
362
415
  admin: ResolvedAdminConfig;
363
416
  /** 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' },
@@ -69,6 +71,12 @@ export function resolveStepUp(input) {
69
71
  const acrValues = Array.from(new Set([...(input?.acrValues ?? []), mfaAcr]));
70
72
  return { acrValues, mfaAcr };
71
73
  }
74
+ export function resolvePasswordless(input) {
75
+ return {
76
+ magicLink: input?.magicLink ?? false,
77
+ passkeyFirst: input?.passkeyFirst ?? false,
78
+ };
79
+ }
72
80
  export function resolveAdmin(input) {
73
81
  return {
74
82
  enabled: input?.enabled ?? false,
@@ -157,7 +165,10 @@ export function defineConfig(config) {
157
165
  lockout: resolveLockout(config.lockout),
158
166
  notifications: resolveNotifications(config.notifications),
159
167
  mail: config.mail,
160
- audit: config.audit,
168
+ audit: (() => {
169
+ const events = resolveEvents(config.events);
170
+ return events ? composeAuditSink(config.audit, events) : config.audit;
171
+ })(),
161
172
  mfaIssuer: config.mfaIssuer ?? 'AuthKit',
162
173
  webauthn: resolveWebauthn(config.issuer, config.mfaIssuer ?? 'AuthKit', config.webauthn),
163
174
  dynamicRegistration: resolveDynamicRegistration(config.dynamicRegistration),
@@ -165,6 +176,8 @@ export function defineConfig(config) {
165
176
  dpop: resolveDpop(config.dpop),
166
177
  par: resolvePar(config.par),
167
178
  stepUp: resolveStepUp(config.stepUp),
179
+ trustedDevices: resolveTrustedDevices(config.trustedDevices),
180
+ passwordless: resolvePasswordless(config.passwordless),
168
181
  admin: resolveAdmin(config.admin),
169
182
  messages: resolveMessages(config.i18n),
170
183
  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
+ }
@@ -60,4 +60,12 @@ export declare class AdminSessionsService {
60
60
  * deixando o store limpo. Retorna as contagens do que foi removido.
61
61
  */
62
62
  revokeAll(accountId: string): Promise<RevokeResult>;
63
+ /**
64
+ * Revoga os grants de uma conta para UM client específico (+ tokens ligados),
65
+ * deixando os demais clients intactos. Reaproveita a lógica de {@link revokeAll}
66
+ * restrita ao `clientId`. Usado pelo self-service de consentimento
67
+ * (/account/apps) e por qualquer revogação granular do admin. Retorna as
68
+ * contagens do que foi removido.
69
+ */
70
+ revokeClientGrants(accountId: string, clientId: string): Promise<RevokeResult>;
63
71
  }
@@ -108,6 +108,25 @@ export class AdminSessionsService {
108
108
  refreshTokens,
109
109
  };
110
110
  }
111
+ /**
112
+ * Revoga os grants de uma conta para UM client específico (+ tokens ligados),
113
+ * deixando os demais clients intactos. Reaproveita a lógica de {@link revokeAll}
114
+ * restrita ao `clientId`. Usado pelo self-service de consentimento
115
+ * (/account/apps) e por qualquer revogação granular do admin. Retorna as
116
+ * contagens do que foi removido.
117
+ */
118
+ async revokeClientGrants(accountId, clientId) {
119
+ const grantAdapter = this.#adapter('Grant');
120
+ const grants = (await this.listGrants(accountId)).filter((g) => g.clientId === clientId);
121
+ const grantIds = new Set(grants.map((g) => g.id));
122
+ const accessTokens = await this.#destroyTokensOfGrants('AccessToken', grantIds);
123
+ const refreshTokens = await this.#destroyTokensOfGrants('RefreshToken', grantIds);
124
+ for (const g of grants) {
125
+ await grantAdapter.revokeByGrantId(g.id);
126
+ await grantAdapter.destroy(g.id);
127
+ }
128
+ return { sessions: 0, grants: grants.length, accessTokens, refreshTokens };
129
+ }
111
130
  /** Destrói (quando enumerável) os artefatos de um model token cujos grantId estão em `grantIds`. */
112
131
  async #destroyTokensOfGrants(model, grantIds) {
113
132
  const adapter = this.#adapter(model);
@@ -0,0 +1,15 @@
1
+ import '../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Self-service de consentimento ("apps com acesso") no console de conta. Lista os
5
+ * Grants da própria conta agrupados por client (resolvendo o nome do client da
6
+ * config estática ou do payload do adapter) e permite revogar o acesso de um
7
+ * client (destrói os grants + AT/RT daquele client). Degrada graciosamente quando
8
+ * o adapter OIDC não enumera (`list`), espelhando o console admin.
9
+ */
10
+ export default class AccountAppsController {
11
+ /** GET /account/apps — lista os apps com acesso (grants) da conta logada. */
12
+ index(ctx: HttpContext): Promise<any>;
13
+ /** POST /account/apps/:clientId/revoke — revoga o acesso de um client. */
14
+ revoke(ctx: HttpContext): Promise<void>;
15
+ }