@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.
- package/README.md +23 -2
- package/build/host/views/account/apps.edge +58 -0
- package/build/host/views/account/security.edge +66 -0
- package/build/host/views/account/tokens.edge +1 -0
- package/build/host/views/admin/users.edge +62 -2
- package/build/host/views/login.edge +55 -0
- package/build/host/views/mfa-challenge.edge +12 -0
- package/build/index.d.ts +8 -2
- package/build/index.js +4 -1
- package/build/src/accounts/account_store.d.ts +80 -2
- package/build/src/accounts/account_store.js +12 -0
- package/build/src/accounts/lucid_account_store.js +8 -0
- package/build/src/accounts/lucid_store/core.d.ts +2 -2
- package/build/src/accounts/lucid_store/core.js +33 -0
- package/build/src/accounts/lucid_store/mfa.js +4 -1
- package/build/src/accounts/lucid_store/status_profile.d.ts +21 -0
- package/build/src/accounts/lucid_store/status_profile.js +66 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/define_config.d.ts +82 -0
- package/build/src/define_config.js +24 -1
- package/build/src/doctor/checks.js +32 -32
- package/build/src/events/dispatcher.d.ts +45 -0
- package/build/src/events/dispatcher.js +92 -0
- package/build/src/host/admin_sessions_service.d.ts +8 -0
- package/build/src/host/admin_sessions_service.js +19 -0
- package/build/src/host/avatar_storage.d.ts +58 -0
- package/build/src/host/avatar_storage.js +125 -0
- package/build/src/host/controllers/account_apps_controller.d.ts +15 -0
- package/build/src/host/controllers/account_apps_controller.js +61 -0
- package/build/src/host/controllers/account_security_controller.d.ts +9 -0
- package/build/src/host/controllers/account_security_controller.js +84 -2
- package/build/src/host/controllers/account_session_controller.js +3 -1
- package/build/src/host/controllers/admin/admin_users_controller.d.ts +13 -0
- package/build/src/host/controllers/admin/admin_users_controller.js +133 -0
- package/build/src/host/controllers/interaction_controller.d.ts +32 -0
- package/build/src/host/controllers/interaction_controller.js +169 -6
- package/build/src/host/default_mailer.d.ts +8 -0
- package/build/src/host/default_mailer.js +28 -0
- package/build/src/host/i18n.d.ts +98 -0
- package/build/src/host/i18n.js +106 -0
- package/build/src/host/login_attempt.d.ts +1 -0
- package/build/src/host/login_attempt.js +11 -0
- package/build/src/host/register_auth_host.js +18 -1
- package/build/src/host/trusted_device.d.ts +61 -0
- package/build/src/host/trusted_device.js +65 -0
- package/build/src/host/validators.d.ts +35 -0
- package/build/src/host/validators.js +14 -0
- 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
|
-
|
|
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:
|
|
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')
|
|
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')
|
|
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
|
|
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
|
|
36
|
+
return [{ level: 'error', message: `issuer is not a valid URL: "${issuer}".` }];
|
|
37
37
|
}
|
|
38
|
-
const findings = [{ level: 'ok', message: `issuer
|
|
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: `
|
|
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: '
|
|
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: '
|
|
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)
|
|
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)
|
|
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
|
|
73
|
+
return [{ level: 'error', message: 'accountStore missing — required.' }];
|
|
74
74
|
}
|
|
75
|
-
const findings = [{ level: 'ok', message: 'accountStore
|
|
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
|
-
? `
|
|
89
|
-
: '
|
|
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
|
|
99
|
+
message: '@adonisjs/session is not importable — install it (required peer).',
|
|
100
100
|
},
|
|
101
101
|
];
|
|
102
102
|
}
|
|
103
103
|
if (!input.sessionConfig) {
|
|
104
|
-
return [{ level: 'warn', message: "config('session')
|
|
104
|
+
return [{ level: 'warn', message: "config('session') missing — the session provider may not be configured." }];
|
|
105
105
|
}
|
|
106
|
-
const findings = [{ level: 'ok', message: 'provider
|
|
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
|
|
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
|
|
119
|
+
return { level: 'error', message: '@adonisjs/shield is not importable — install it (required peer).' };
|
|
120
120
|
}
|
|
121
121
|
const mountPath = input.authkitConfig?.mountPath ?? '/oidc';
|
|
122
122
|
return {
|
|
123
123
|
level: 'warn',
|
|
124
|
-
message: `
|
|
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
|
|
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
|
|
135
|
+
return { level: 'error', message: 'social login configured, but @adonisjs/ally is not importable.' };
|
|
136
136
|
}
|
|
137
|
-
return { level: 'ok', message: 'login
|
|
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
|
|
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
|
|
150
|
+
message: 'rate-limiting is on (default), but @adonisjs/limiter is not importable — becomes a no-op (no anti-brute-force protection).',
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
|
-
return { level: 'ok', message: 'rate-limiting
|
|
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
|
|
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
|
|
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}")
|
|
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
|
|
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 —
|
|
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
|
|
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
|
+
}
|