@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.
- package/README.md +23 -2
- package/build/host/views/account/apps.edge +58 -0
- package/build/host/views/account/security.edge +53 -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 +9 -3
- package/build/index.js +5 -2
- 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 +53 -0
- package/build/src/define_config.js +14 -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/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_mfa_controller.js +6 -2
- package/build/src/host/controllers/account_security_controller.d.ts +9 -0
- package/build/src/host/controllers/account_security_controller.js +52 -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 +175 -8
- package/build/src/host/default_mailer.d.ts +8 -0
- package/build/src/host/default_mailer.js +81 -19
- package/build/src/host/email_templates.d.ts +4 -0
- package/build/src/host/email_templates.js +5 -2
- package/build/src/host/i18n.d.ts +395 -11
- package/build/src/host/i18n.js +433 -12
- 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/build/src/observability/metrics_controller.js +4 -4
- 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
|
-
|
|
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:
|
|
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')
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|