@dudousxd/adonis-authkit-server 0.2.0 → 0.4.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/build/commands/commands.json +28 -0
- package/build/commands/doctor.d.ts +10 -0
- package/build/commands/doctor.js +66 -0
- package/build/commands/rotate_keys.d.ts +10 -0
- package/build/commands/rotate_keys.js +53 -0
- package/build/host/views/account/email-confirmed.edge +15 -0
- package/build/host/views/account/security.edge +83 -0
- package/build/host/views/account/tokens.edge +7 -4
- package/build/host/views/admin/client_form.edge +83 -0
- package/build/host/views/admin/clients.edge +68 -3
- package/build/host/views/admin/sessions.edge +89 -0
- package/build/host/views/admin/users.edge +1 -0
- package/build/host/views/mfa-challenge.edge +29 -23
- package/build/index.d.ts +4 -3
- package/build/index.js +2 -2
- package/build/src/accounts/account_store.d.ts +46 -1
- package/build/src/accounts/account_store.js +4 -0
- package/build/src/accounts/lucid_store/core.d.ts +5 -4
- package/build/src/accounts/lucid_store/core.js +67 -2
- package/build/src/adapters/adapter_contract.d.ts +29 -0
- package/build/src/adapters/database_adapter.d.ts +12 -1
- package/build/src/adapters/database_adapter.js +24 -0
- package/build/src/adapters/redis_adapter.d.ts +14 -1
- package/build/src/adapters/redis_adapter.js +35 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/define_config.d.ts +102 -0
- package/build/src/define_config.js +46 -3
- package/build/src/doctor/checks.d.ts +51 -0
- package/build/src/doctor/checks.js +231 -0
- package/build/src/host/admin_clients_service.d.ts +65 -0
- package/build/src/host/admin_clients_service.js +143 -0
- package/build/src/host/admin_sessions_service.d.ts +63 -0
- package/build/src/host/admin_sessions_service.js +127 -0
- package/build/src/host/controllers/account_security_controller.d.ts +16 -0
- package/build/src/host/controllers/account_security_controller.js +119 -0
- package/build/src/host/controllers/account_session_controller.js +2 -1
- package/build/src/host/controllers/admin/admin_clients_controller.d.ts +17 -3
- package/build/src/host/controllers/admin/admin_clients_controller.js +158 -4
- package/build/src/host/controllers/admin/admin_sessions_controller.d.ts +14 -0
- package/build/src/host/controllers/admin/admin_sessions_controller.js +64 -0
- package/build/src/host/controllers/interaction_controller.d.ts +11 -0
- package/build/src/host/controllers/interaction_controller.js +49 -10
- package/build/src/host/default_mailer.d.ts +17 -0
- package/build/src/host/default_mailer.js +51 -0
- package/build/src/host/i18n.d.ts +80 -0
- package/build/src/host/i18n.js +86 -1
- package/build/src/host/login_notify.d.ts +20 -0
- package/build/src/host/login_notify.js +71 -0
- package/build/src/host/register_auth_host.js +20 -0
- package/build/src/host/validators.d.ts +32 -0
- package/build/src/host/validators.js +14 -0
- package/build/src/keys/keystore.d.ts +43 -0
- package/build/src/keys/keystore.js +74 -0
- package/build/src/provider/build_provider.js +23 -0
- package/build/src/provider/device_sources.d.ts +6 -0
- package/build/src/provider/device_sources.js +65 -0
- package/build/src/provider/interaction_actions.d.ts +6 -1
- package/build/src/provider/interaction_actions.js +9 -2
- package/build/src/provider/oidc_service.d.ts +15 -0
- package/build/src/provider/oidc_service.js +27 -0
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { configProvider } from '@adonisjs/core';
|
|
2
2
|
import { generateJwks } from './keys/jwks_manager.js';
|
|
3
|
+
import { ensureKeystore } from './keys/keystore.js';
|
|
3
4
|
import { adapters } from './adapters/factory.js';
|
|
4
5
|
import { resolveMessages } from './host/i18n.js';
|
|
5
6
|
export { adapters };
|
|
@@ -26,6 +27,11 @@ export function resolveLockout(input) {
|
|
|
26
27
|
store: input?.store,
|
|
27
28
|
};
|
|
28
29
|
}
|
|
30
|
+
export function resolveNotifications(input) {
|
|
31
|
+
return {
|
|
32
|
+
newLoginEmail: input?.newLoginEmail ?? true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
29
35
|
/**
|
|
30
36
|
* Resolve a config de registro dinâmico e VALIDA invariantes em tempo de resolução.
|
|
31
37
|
* O Registration Management (RFC 7592) só faz sentido com o registro habilitado
|
|
@@ -45,6 +51,24 @@ export function resolveDynamicRegistration(input) {
|
|
|
45
51
|
management,
|
|
46
52
|
};
|
|
47
53
|
}
|
|
54
|
+
export function resolveDeviceFlow(input) {
|
|
55
|
+
return { enabled: input?.enabled ?? false };
|
|
56
|
+
}
|
|
57
|
+
export function resolveDpop(input) {
|
|
58
|
+
return { enabled: input?.enabled ?? false };
|
|
59
|
+
}
|
|
60
|
+
export function resolvePar(input) {
|
|
61
|
+
return {
|
|
62
|
+
enabled: input?.enabled ?? false,
|
|
63
|
+
requirePushedAuthorizationRequests: input?.requirePushedAuthorizationRequests ?? false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function resolveStepUp(input) {
|
|
67
|
+
const mfaAcr = input?.mfaAcr ?? 'urn:authkit:mfa';
|
|
68
|
+
// Garante que o mfaAcr esteja sempre na lista anunciada como suportada.
|
|
69
|
+
const acrValues = Array.from(new Set([...(input?.acrValues ?? []), mfaAcr]));
|
|
70
|
+
return { acrValues, mfaAcr };
|
|
71
|
+
}
|
|
48
72
|
export function resolveAdmin(input) {
|
|
49
73
|
return {
|
|
50
74
|
enabled: input?.enabled ?? false,
|
|
@@ -86,9 +110,23 @@ export function toSeconds(value, fallback) {
|
|
|
86
110
|
export function defineConfig(config) {
|
|
87
111
|
return configProvider.create(async (app) => {
|
|
88
112
|
const AdapterClass = await config.adapter.resolver(app);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
let jwks;
|
|
114
|
+
if (config.jwks.source === 'managed') {
|
|
115
|
+
const alg = config.jwks.algorithm ?? 'RS256';
|
|
116
|
+
if (config.jwks.store) {
|
|
117
|
+
// keystore persistido em arquivo: chaves sobrevivem a restarts + rotacionáveis.
|
|
118
|
+
const storePath = app.makePath(config.jwks.store);
|
|
119
|
+
const store = await ensureKeystore(storePath, alg);
|
|
120
|
+
jwks = { keys: store.keys };
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// managed efêmero: uma chave nova por boot.
|
|
124
|
+
jwks = await generateJwks(alg);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
jwks = { keys: config.jwks.keys ?? [] };
|
|
129
|
+
}
|
|
92
130
|
return {
|
|
93
131
|
issuer: config.issuer,
|
|
94
132
|
AdapterClass,
|
|
@@ -117,11 +155,16 @@ export function defineConfig(config) {
|
|
|
117
155
|
patIntrospectionSecret: config.patIntrospectionSecret,
|
|
118
156
|
rateLimit: resolveRateLimit(config.rateLimit),
|
|
119
157
|
lockout: resolveLockout(config.lockout),
|
|
158
|
+
notifications: resolveNotifications(config.notifications),
|
|
120
159
|
mail: config.mail,
|
|
121
160
|
audit: config.audit,
|
|
122
161
|
mfaIssuer: config.mfaIssuer ?? 'AuthKit',
|
|
123
162
|
webauthn: resolveWebauthn(config.issuer, config.mfaIssuer ?? 'AuthKit', config.webauthn),
|
|
124
163
|
dynamicRegistration: resolveDynamicRegistration(config.dynamicRegistration),
|
|
164
|
+
deviceFlow: resolveDeviceFlow(config.deviceFlow),
|
|
165
|
+
dpop: resolveDpop(config.dpop),
|
|
166
|
+
par: resolvePar(config.par),
|
|
167
|
+
stepUp: resolveStepUp(config.stepUp),
|
|
125
168
|
admin: resolveAdmin(config.admin),
|
|
126
169
|
messages: resolveMessages(config.i18n),
|
|
127
170
|
locale: config.i18n?.locale ?? 'pt-BR',
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funções puras de verificação para `node ace authkit:doctor`. Não dependem do
|
|
3
|
+
* Ace nem do container — recebem objetos simples para serem testáveis em
|
|
4
|
+
* isolamento. O comando `authkit:doctor` só coleta o ambiente e imprime os
|
|
5
|
+
* resultados destas funções.
|
|
6
|
+
*/
|
|
7
|
+
export type FindingLevel = 'ok' | 'warn' | 'error';
|
|
8
|
+
export interface Finding {
|
|
9
|
+
level: FindingLevel;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
/** Entrada mínima necessária para rodar os checks (subconjunto da config AuthKit). */
|
|
13
|
+
export interface DoctorInput {
|
|
14
|
+
/** A config `authkit` resolvida pelo container, ou null se não resolver. */
|
|
15
|
+
authkitConfig: Record<string, any> | null;
|
|
16
|
+
/** A config `session` do app (config('session')), ou null se ausente. */
|
|
17
|
+
sessionConfig: Record<string, any> | null;
|
|
18
|
+
/** Resultado de tentar resolver cada peer (true = importável). */
|
|
19
|
+
peers: {
|
|
20
|
+
session: boolean;
|
|
21
|
+
shield: boolean;
|
|
22
|
+
ally: boolean;
|
|
23
|
+
limiter: boolean;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** config('authkit') resolve? */
|
|
27
|
+
export declare function checkConfigResolves(input: DoctorInput): Finding;
|
|
28
|
+
/** issuer é uma URL válida e seu pathname casa com o mountPath. */
|
|
29
|
+
export declare function checkIssuer(input: DoctorInput): Finding[];
|
|
30
|
+
/** Pelo menos um client com redirectUris. */
|
|
31
|
+
export declare function checkClients(input: DoctorInput): Finding;
|
|
32
|
+
/** accountStore presente + quais capacidades implementa. */
|
|
33
|
+
export declare function checkAccountStore(input: DoctorInput): Finding[];
|
|
34
|
+
/** session provider configurado + warn se cookie store com tokenSets grandes. */
|
|
35
|
+
export declare function checkSession(input: DoctorInput): Finding[];
|
|
36
|
+
/** Hint de exceções de CSRF do shield para o mountPath. */
|
|
37
|
+
export declare function checkShield(input: DoctorInput): Finding;
|
|
38
|
+
/** ally só é necessário quando social está configurado. */
|
|
39
|
+
export declare function checkAlly(input: DoctorInput): Finding;
|
|
40
|
+
/** rateLimit ligado mas @adonisjs/limiter ausente → warn. */
|
|
41
|
+
export declare function checkRateLimit(input: DoctorInput): Finding;
|
|
42
|
+
/** admin.enabled mas sem roles → warn. */
|
|
43
|
+
export declare function checkAdmin(input: DoctorInput): Finding | null;
|
|
44
|
+
/** webauthn rpId deve casar com o host do issuer. */
|
|
45
|
+
export declare function checkWebauthn(input: DoctorInput): Finding | null;
|
|
46
|
+
/** info sobre rotação quando jwks é managed. */
|
|
47
|
+
export declare function checkJwks(input: DoctorInput): Finding | null;
|
|
48
|
+
/** Roda todos os checks e devolve a lista plana de findings. */
|
|
49
|
+
export declare function runAllChecks(input: DoctorInput): Finding[];
|
|
50
|
+
/** Há algum finding de nível 'error'? (define o exit code). */
|
|
51
|
+
export declare function hasErrors(findings: Finding[]): boolean;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funções puras de verificação para `node ace authkit:doctor`. Não dependem do
|
|
3
|
+
* Ace nem do container — recebem objetos simples para serem testáveis em
|
|
4
|
+
* isolamento. O comando `authkit:doctor` só coleta o ambiente e imprime os
|
|
5
|
+
* resultados destas funções.
|
|
6
|
+
*/
|
|
7
|
+
/** Type guard estrutural: o store expõe um método (capacidade presente). */
|
|
8
|
+
function has(store, method) {
|
|
9
|
+
return !!store && typeof store[method] === 'function';
|
|
10
|
+
}
|
|
11
|
+
/** config('authkit') resolve? */
|
|
12
|
+
export function checkConfigResolves(input) {
|
|
13
|
+
if (!input.authkitConfig) {
|
|
14
|
+
return {
|
|
15
|
+
level: 'error',
|
|
16
|
+
message: "config('authkit') não resolveu — config/authkit.ts está ausente ou inválido.",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { level: 'ok', message: "config('authkit') resolvido." };
|
|
20
|
+
}
|
|
21
|
+
/** issuer é uma URL válida e seu pathname casa com o mountPath. */
|
|
22
|
+
export function checkIssuer(input) {
|
|
23
|
+
const cfg = input.authkitConfig;
|
|
24
|
+
if (!cfg)
|
|
25
|
+
return [];
|
|
26
|
+
const issuer = cfg.issuer;
|
|
27
|
+
const mountPath = cfg.mountPath ?? '/oidc';
|
|
28
|
+
if (typeof issuer !== 'string' || issuer.length === 0) {
|
|
29
|
+
return [{ level: 'error', message: 'issuer ausente na config.' }];
|
|
30
|
+
}
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(issuer);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [{ level: 'error', message: `issuer não é uma URL válida: "${issuer}".` }];
|
|
37
|
+
}
|
|
38
|
+
const findings = [{ level: 'ok', message: `issuer válido: ${url.origin}${url.pathname}` }];
|
|
39
|
+
const normalize = (p) => (p.endsWith('/') ? p.slice(0, -1) : p) || '/';
|
|
40
|
+
if (normalize(url.pathname) !== normalize(mountPath)) {
|
|
41
|
+
findings.push({
|
|
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.`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return findings;
|
|
47
|
+
}
|
|
48
|
+
/** Pelo menos um client com redirectUris. */
|
|
49
|
+
export function checkClients(input) {
|
|
50
|
+
const cfg = input.authkitConfig;
|
|
51
|
+
if (!cfg)
|
|
52
|
+
return { level: 'error', message: 'sem config para validar clients.' };
|
|
53
|
+
const clients = Array.isArray(cfg.clients) ? cfg.clients : [];
|
|
54
|
+
if (clients.length === 0) {
|
|
55
|
+
return { level: 'error', message: 'nenhum client configurado em `clients`.' };
|
|
56
|
+
}
|
|
57
|
+
const withRedirects = clients.filter((c) => Array.isArray(c?.redirectUris) && c.redirectUris.length > 0);
|
|
58
|
+
if (withRedirects.length === 0) {
|
|
59
|
+
return {
|
|
60
|
+
level: 'error',
|
|
61
|
+
message: `${clients.length} client(s) configurado(s), mas nenhum tem redirectUris.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { level: 'ok', message: `${withRedirects.length}/${clients.length} client(s) com redirectUris.` };
|
|
65
|
+
}
|
|
66
|
+
/** accountStore presente + quais capacidades implementa. */
|
|
67
|
+
export function checkAccountStore(input) {
|
|
68
|
+
const cfg = input.authkitConfig;
|
|
69
|
+
if (!cfg)
|
|
70
|
+
return [];
|
|
71
|
+
const store = cfg.accountStore;
|
|
72
|
+
if (!store) {
|
|
73
|
+
return [{ level: 'error', message: 'accountStore ausente — obrigatório.' }];
|
|
74
|
+
}
|
|
75
|
+
const findings = [{ level: 'ok', message: 'accountStore presente.' }];
|
|
76
|
+
const caps = [];
|
|
77
|
+
if (has(store, 'getMfaState'))
|
|
78
|
+
caps.push('MFA');
|
|
79
|
+
if (has(store, 'listPasskeys'))
|
|
80
|
+
caps.push('passkeys/WebAuthn');
|
|
81
|
+
if (has(store, 'findByProviderIdentity'))
|
|
82
|
+
caps.push('account-linking');
|
|
83
|
+
if (has(store, 'changePassword'))
|
|
84
|
+
caps.push('account-security');
|
|
85
|
+
findings.push({
|
|
86
|
+
level: 'ok',
|
|
87
|
+
message: caps.length
|
|
88
|
+
? `Capacidades opcionais: ${caps.join(', ')}.`
|
|
89
|
+
: 'Apenas o núcleo do accountStore (sem MFA/passkeys/linking/security).',
|
|
90
|
+
});
|
|
91
|
+
return findings;
|
|
92
|
+
}
|
|
93
|
+
/** session provider configurado + warn se cookie store com tokenSets grandes. */
|
|
94
|
+
export function checkSession(input) {
|
|
95
|
+
if (!input.peers.session) {
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
level: 'error',
|
|
99
|
+
message: '@adonisjs/session não é importável — instale-o (peer obrigatório).',
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
if (!input.sessionConfig) {
|
|
104
|
+
return [{ level: 'warn', message: "config('session') ausente — o provider de sessão pode não estar configurado." }];
|
|
105
|
+
}
|
|
106
|
+
const findings = [{ level: 'ok', message: 'provider de sessão configurado.' }];
|
|
107
|
+
const driver = input.sessionConfig.store ?? input.sessionConfig.driver;
|
|
108
|
+
if (driver === 'cookie') {
|
|
109
|
+
findings.push({
|
|
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.',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return findings;
|
|
115
|
+
}
|
|
116
|
+
/** Hint de exceções de CSRF do shield para o mountPath. */
|
|
117
|
+
export function checkShield(input) {
|
|
118
|
+
if (!input.peers.shield) {
|
|
119
|
+
return { level: 'error', message: '@adonisjs/shield não é importável — instale-o (peer obrigatório).' };
|
|
120
|
+
}
|
|
121
|
+
const mountPath = input.authkitConfig?.mountPath ?? '/oidc';
|
|
122
|
+
return {
|
|
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.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** ally só é necessário quando social está configurado. */
|
|
128
|
+
export function checkAlly(input) {
|
|
129
|
+
const social = input.authkitConfig?.social;
|
|
130
|
+
const usesSocial = !!social && (Array.isArray(social.providers) ? social.providers.length > 0 : Object.keys(social).length > 0);
|
|
131
|
+
if (!usesSocial) {
|
|
132
|
+
return { level: 'ok', message: 'login social não configurado — @adonisjs/ally é opcional.' };
|
|
133
|
+
}
|
|
134
|
+
if (!input.peers.ally) {
|
|
135
|
+
return { level: 'error', message: 'login social configurado, mas @adonisjs/ally não é importável.' };
|
|
136
|
+
}
|
|
137
|
+
return { level: 'ok', message: 'login social configurado e @adonisjs/ally disponível.' };
|
|
138
|
+
}
|
|
139
|
+
/** rateLimit ligado mas @adonisjs/limiter ausente → warn. */
|
|
140
|
+
export function checkRateLimit(input) {
|
|
141
|
+
const cfg = input.authkitConfig;
|
|
142
|
+
const rateLimit = cfg?.rateLimit;
|
|
143
|
+
const enabled = rateLimit === undefined ? true : rateLimit?.enabled !== false;
|
|
144
|
+
if (!enabled) {
|
|
145
|
+
return { level: 'ok', message: 'rate-limiting desligado por config.' };
|
|
146
|
+
}
|
|
147
|
+
if (!input.peers.limiter) {
|
|
148
|
+
return {
|
|
149
|
+
level: 'warn',
|
|
150
|
+
message: 'rate-limiting está ligado (default), mas @adonisjs/limiter não é importável — vira no-op (sem proteção anti-brute-force).',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { level: 'ok', message: 'rate-limiting ligado e @adonisjs/limiter disponível.' };
|
|
154
|
+
}
|
|
155
|
+
/** admin.enabled mas sem roles → warn. */
|
|
156
|
+
export function checkAdmin(input) {
|
|
157
|
+
const admin = input.authkitConfig?.admin;
|
|
158
|
+
if (!admin || admin.enabled !== true)
|
|
159
|
+
return null;
|
|
160
|
+
const roles = Array.isArray(admin.roles) ? admin.roles : [];
|
|
161
|
+
if (roles.length === 0) {
|
|
162
|
+
return {
|
|
163
|
+
level: 'warn',
|
|
164
|
+
message: 'console admin ligado, mas sem `admin.roles` — ninguém terá acesso (default ["ADMIN"] não foi resolvido aqui).',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return { level: 'ok', message: `console admin ligado para roles: ${roles.join(', ')}.` };
|
|
168
|
+
}
|
|
169
|
+
/** webauthn rpId deve casar com o host do issuer. */
|
|
170
|
+
export function checkWebauthn(input) {
|
|
171
|
+
const cfg = input.authkitConfig;
|
|
172
|
+
const webauthn = cfg?.webauthn;
|
|
173
|
+
if (!webauthn || !webauthn.rpId)
|
|
174
|
+
return null;
|
|
175
|
+
const issuer = cfg.issuer;
|
|
176
|
+
if (typeof issuer !== 'string')
|
|
177
|
+
return null;
|
|
178
|
+
let host;
|
|
179
|
+
try {
|
|
180
|
+
host = new URL(issuer).hostname;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
if (webauthn.rpId !== host) {
|
|
186
|
+
return {
|
|
187
|
+
level: 'warn',
|
|
188
|
+
message: `webauthn.rpId ("${webauthn.rpId}") difere do host do issuer ("${host}") — as passkeys não validarão no browser.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return { level: 'ok', message: `webauthn.rpId casa com o host do issuer (${host}).` };
|
|
192
|
+
}
|
|
193
|
+
/** info sobre rotação quando jwks é managed. */
|
|
194
|
+
export function checkJwks(input) {
|
|
195
|
+
const jwks = input.authkitConfig?.jwks;
|
|
196
|
+
if (!jwks)
|
|
197
|
+
return null;
|
|
198
|
+
if (jwks.source === 'managed') {
|
|
199
|
+
return {
|
|
200
|
+
level: 'ok',
|
|
201
|
+
message: 'jwks managed — rotacione as chaves de assinatura com `node ace authkit:rotate-keys` (use --store para persistir entre boots).',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return { level: 'ok', message: 'jwks fornecido inline (source=jwks).' };
|
|
205
|
+
}
|
|
206
|
+
/** Roda todos os checks e devolve a lista plana de findings. */
|
|
207
|
+
export function runAllChecks(input) {
|
|
208
|
+
const findings = [];
|
|
209
|
+
findings.push(checkConfigResolves(input));
|
|
210
|
+
findings.push(...checkIssuer(input));
|
|
211
|
+
findings.push(checkClients(input));
|
|
212
|
+
findings.push(...checkAccountStore(input));
|
|
213
|
+
findings.push(...checkSession(input));
|
|
214
|
+
findings.push(checkShield(input));
|
|
215
|
+
findings.push(checkAlly(input));
|
|
216
|
+
findings.push(checkRateLimit(input));
|
|
217
|
+
const admin = checkAdmin(input);
|
|
218
|
+
if (admin)
|
|
219
|
+
findings.push(admin);
|
|
220
|
+
const webauthn = checkWebauthn(input);
|
|
221
|
+
if (webauthn)
|
|
222
|
+
findings.push(webauthn);
|
|
223
|
+
const jwks = checkJwks(input);
|
|
224
|
+
if (jwks)
|
|
225
|
+
findings.push(jwks);
|
|
226
|
+
return findings;
|
|
227
|
+
}
|
|
228
|
+
/** Há algum finding de nível 'error'? (define o exit code). */
|
|
229
|
+
export function hasErrors(findings) {
|
|
230
|
+
return findings.some((f) => f.level === 'error');
|
|
231
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { OidcService } from '../provider/oidc_service.js';
|
|
2
|
+
/** Métodos de autenticação no token endpoint suportados pelo formulário admin. */
|
|
3
|
+
export type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
|
|
4
|
+
/** Entrada normalizada de um client gerenciável (vinda do formulário admin). */
|
|
5
|
+
export interface ClientInput {
|
|
6
|
+
clientId?: string;
|
|
7
|
+
redirectUris: string[];
|
|
8
|
+
postLogoutRedirectUris: string[];
|
|
9
|
+
grantTypes: string[];
|
|
10
|
+
tokenEndpointAuthMethod: TokenEndpointAuthMethod;
|
|
11
|
+
}
|
|
12
|
+
/** Client persistido, apresentado ao console admin. */
|
|
13
|
+
export interface AdminClient {
|
|
14
|
+
clientId: string;
|
|
15
|
+
confidential: boolean;
|
|
16
|
+
grants: string[];
|
|
17
|
+
redirectUris: string[];
|
|
18
|
+
postLogoutRedirectUris: string[];
|
|
19
|
+
tokenEndpointAuthMethod: string;
|
|
20
|
+
}
|
|
21
|
+
/** Resultado de uma criação: o client + o secret em claro (mostrado UMA vez). */
|
|
22
|
+
export interface CreatedClient {
|
|
23
|
+
clientId: string;
|
|
24
|
+
/** undefined para public clients (sem secret). */
|
|
25
|
+
clientSecret?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Serviço de CRUD de clients OIDC persistidos no adapter (model `Client`),
|
|
29
|
+
* usado pelo console admin. Encapsula:
|
|
30
|
+
* - a montagem do payload NA FORMA EXATA que o oidc-provider espera (snake_case,
|
|
31
|
+
* igual ao que o registro dinâmico — RFC 7591 — grava);
|
|
32
|
+
* - a invalidação do cache de clients dinâmicos do provider após cada escrita
|
|
33
|
+
* (ver {@link OidcService.evictDynamicClientCache});
|
|
34
|
+
* - a enumeração via a capacidade opcional `listClients` do adapter.
|
|
35
|
+
*/
|
|
36
|
+
export declare class AdminClientsService {
|
|
37
|
+
#private;
|
|
38
|
+
private oidc;
|
|
39
|
+
constructor(oidc: OidcService);
|
|
40
|
+
/** Indica se o adapter suporta enumeração (capacidade opcional). */
|
|
41
|
+
get canList(): boolean;
|
|
42
|
+
/** Lista os clients persistidos (vazio quando o adapter não enumera; cheque canList). */
|
|
43
|
+
list(): Promise<AdminClient[]>;
|
|
44
|
+
/** Lê um client persistido pelo client_id (undefined quando não existe). */
|
|
45
|
+
find(clientId: string): Promise<AdminClient | undefined>;
|
|
46
|
+
/**
|
|
47
|
+
* Cria um client. Gera client_id quando não informado; gera client_secret
|
|
48
|
+
* para clients confidenciais (auth method != 'none'). Retorna o secret em
|
|
49
|
+
* claro UMA vez (não é recuperável depois — o payload guarda o mesmo valor).
|
|
50
|
+
*/
|
|
51
|
+
create(input: ClientInput): Promise<CreatedClient>;
|
|
52
|
+
/**
|
|
53
|
+
* Atualiza metadata editável (redirect/post-logout URIs, grants, auth method)
|
|
54
|
+
* PRESERVANDO o client_secret existente. Lança se o client não existe.
|
|
55
|
+
*/
|
|
56
|
+
update(clientId: string, input: ClientInput): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Regenera o client_secret de um client confidencial, preservando o resto da
|
|
59
|
+
* metadata. Retorna o novo secret em claro (mostrado UMA vez). Lança se o
|
|
60
|
+
* client não existe ou é public (auth method 'none').
|
|
61
|
+
*/
|
|
62
|
+
regenerateSecret(clientId: string): Promise<string>;
|
|
63
|
+
/** Remove um client persistido e invalida o cache. */
|
|
64
|
+
delete(clientId: string): Promise<void>;
|
|
65
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
/** Métodos públicos (sem segredo) — espelham os "non-secret" do oidc-provider. */
|
|
3
|
+
const PUBLIC_AUTH_METHODS = new Set(['none']);
|
|
4
|
+
/** Gera um identificador opaco no estilo do oidc-provider (~43 chars base64url). */
|
|
5
|
+
function randomId() {
|
|
6
|
+
return randomBytes(32).toString('base64url');
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Serviço de CRUD de clients OIDC persistidos no adapter (model `Client`),
|
|
10
|
+
* usado pelo console admin. Encapsula:
|
|
11
|
+
* - a montagem do payload NA FORMA EXATA que o oidc-provider espera (snake_case,
|
|
12
|
+
* igual ao que o registro dinâmico — RFC 7591 — grava);
|
|
13
|
+
* - a invalidação do cache de clients dinâmicos do provider após cada escrita
|
|
14
|
+
* (ver {@link OidcService.evictDynamicClientCache});
|
|
15
|
+
* - a enumeração via a capacidade opcional `listClients` do adapter.
|
|
16
|
+
*/
|
|
17
|
+
export class AdminClientsService {
|
|
18
|
+
oidc;
|
|
19
|
+
#adapter;
|
|
20
|
+
constructor(oidc) {
|
|
21
|
+
this.oidc = oidc;
|
|
22
|
+
// O AdapterClass é o MESMO que o provider usa; instanciamos o model 'Client'
|
|
23
|
+
// para ler/gravar os mesmos artefatos que o oidc-provider persiste.
|
|
24
|
+
this.#adapter = new oidc.config.AdapterClass('Client');
|
|
25
|
+
}
|
|
26
|
+
/** Indica se o adapter suporta enumeração (capacidade opcional). */
|
|
27
|
+
get canList() {
|
|
28
|
+
return typeof this.#adapter.list === 'function' || typeof this.#adapter.listClients === 'function';
|
|
29
|
+
}
|
|
30
|
+
/** Lista os clients persistidos (vazio quando o adapter não enumera; cheque canList). */
|
|
31
|
+
async list() {
|
|
32
|
+
// Prefere a enumeração genérica `list`; cai no `listClients` legado se for o
|
|
33
|
+
// único disponível (adapters customizados antigos).
|
|
34
|
+
if (this.#adapter.list) {
|
|
35
|
+
const rows = await this.#adapter.list();
|
|
36
|
+
return rows.map((r) => this.#present({ clientId: r.id, payload: r.payload }));
|
|
37
|
+
}
|
|
38
|
+
if (this.#adapter.listClients) {
|
|
39
|
+
const rows = await this.#adapter.listClients();
|
|
40
|
+
return rows.map((r) => this.#present(r));
|
|
41
|
+
}
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
/** Lê um client persistido pelo client_id (undefined quando não existe). */
|
|
45
|
+
async find(clientId) {
|
|
46
|
+
const payload = await this.#adapter.find(clientId);
|
|
47
|
+
if (!payload)
|
|
48
|
+
return undefined;
|
|
49
|
+
return this.#present({ clientId, payload: payload });
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Cria um client. Gera client_id quando não informado; gera client_secret
|
|
53
|
+
* para clients confidenciais (auth method != 'none'). Retorna o secret em
|
|
54
|
+
* claro UMA vez (não é recuperável depois — o payload guarda o mesmo valor).
|
|
55
|
+
*/
|
|
56
|
+
async create(input) {
|
|
57
|
+
const clientId = (input.clientId ?? '').trim() || randomId();
|
|
58
|
+
const confidential = !PUBLIC_AUTH_METHODS.has(input.tokenEndpointAuthMethod);
|
|
59
|
+
const clientSecret = confidential ? randomId() : undefined;
|
|
60
|
+
const payload = this.#buildPayload(clientId, input, clientSecret);
|
|
61
|
+
// expiresIn 0 => sem TTL (clients são permanentes, como no registro dinâmico).
|
|
62
|
+
await this.#adapter.upsert(clientId, payload, 0);
|
|
63
|
+
await this.oidc.evictDynamicClientCache();
|
|
64
|
+
return { clientId, clientSecret };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Atualiza metadata editável (redirect/post-logout URIs, grants, auth method)
|
|
68
|
+
* PRESERVANDO o client_secret existente. Lança se o client não existe.
|
|
69
|
+
*/
|
|
70
|
+
async update(clientId, input) {
|
|
71
|
+
const existing = await this.#adapter.find(clientId);
|
|
72
|
+
if (!existing)
|
|
73
|
+
throw new Error(`client ${clientId} não encontrado`);
|
|
74
|
+
const previousSecret = existing.client_secret;
|
|
75
|
+
const confidential = !PUBLIC_AUTH_METHODS.has(input.tokenEndpointAuthMethod);
|
|
76
|
+
// Mantém o secret atual se continua confidencial; se virou public, remove-o.
|
|
77
|
+
const clientSecret = confidential ? (previousSecret ?? randomId()) : undefined;
|
|
78
|
+
const payload = this.#buildPayload(clientId, input, clientSecret);
|
|
79
|
+
await this.#adapter.upsert(clientId, payload, 0);
|
|
80
|
+
await this.oidc.evictDynamicClientCache();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Regenera o client_secret de um client confidencial, preservando o resto da
|
|
84
|
+
* metadata. Retorna o novo secret em claro (mostrado UMA vez). Lança se o
|
|
85
|
+
* client não existe ou é public (auth method 'none').
|
|
86
|
+
*/
|
|
87
|
+
async regenerateSecret(clientId) {
|
|
88
|
+
const existing = await this.#adapter.find(clientId);
|
|
89
|
+
if (!existing)
|
|
90
|
+
throw new Error(`client ${clientId} não encontrado`);
|
|
91
|
+
const authMethod = existing.token_endpoint_auth_method ?? 'client_secret_basic';
|
|
92
|
+
if (PUBLIC_AUTH_METHODS.has(authMethod)) {
|
|
93
|
+
throw new Error(`client ${clientId} é public — não possui secret`);
|
|
94
|
+
}
|
|
95
|
+
const clientSecret = randomId();
|
|
96
|
+
const payload = { ...existing, client_secret: clientSecret };
|
|
97
|
+
await this.#adapter.upsert(clientId, payload, 0);
|
|
98
|
+
await this.oidc.evictDynamicClientCache();
|
|
99
|
+
return clientSecret;
|
|
100
|
+
}
|
|
101
|
+
/** Remove um client persistido e invalida o cache. */
|
|
102
|
+
async delete(clientId) {
|
|
103
|
+
await this.#adapter.destroy(clientId);
|
|
104
|
+
await this.oidc.evictDynamicClientCache();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Monta o payload na forma snake_case que o oidc-provider espera/persiste —
|
|
108
|
+
* verificada contra o que o registro dinâmico (RFC 7591) grava. As chaves de
|
|
109
|
+
* metadata não enviadas (subject_type, id_token_signed_response_alg, etc.) são
|
|
110
|
+
* preenchidas pelo Schema do provider ao construir o Client em `find`.
|
|
111
|
+
*/
|
|
112
|
+
#buildPayload(clientId, input, clientSecret) {
|
|
113
|
+
const grantTypes = input.grantTypes.length
|
|
114
|
+
? input.grantTypes
|
|
115
|
+
: ['authorization_code', 'refresh_token'];
|
|
116
|
+
// response_types: 'code' quando o fluxo de authorization_code está presente.
|
|
117
|
+
const responseTypes = grantTypes.includes('authorization_code') ? ['code'] : [];
|
|
118
|
+
const payload = {
|
|
119
|
+
client_id: clientId,
|
|
120
|
+
redirect_uris: input.redirectUris,
|
|
121
|
+
post_logout_redirect_uris: input.postLogoutRedirectUris,
|
|
122
|
+
grant_types: grantTypes,
|
|
123
|
+
response_types: responseTypes,
|
|
124
|
+
token_endpoint_auth_method: input.tokenEndpointAuthMethod,
|
|
125
|
+
};
|
|
126
|
+
if (clientSecret)
|
|
127
|
+
payload.client_secret = clientSecret;
|
|
128
|
+
return payload;
|
|
129
|
+
}
|
|
130
|
+
/** Projeta um payload persistido para a forma exibida no console admin. */
|
|
131
|
+
#present(row) {
|
|
132
|
+
const p = row.payload;
|
|
133
|
+
const authMethod = p.token_endpoint_auth_method ?? 'client_secret_basic';
|
|
134
|
+
return {
|
|
135
|
+
clientId: row.clientId,
|
|
136
|
+
confidential: !!p.client_secret,
|
|
137
|
+
grants: p.grant_types ?? ['authorization_code', 'refresh_token'],
|
|
138
|
+
redirectUris: p.redirect_uris ?? [],
|
|
139
|
+
postLogoutRedirectUris: p.post_logout_redirect_uris ?? [],
|
|
140
|
+
tokenEndpointAuthMethod: authMethod,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { OidcService } from '../provider/oidc_service.js';
|
|
2
|
+
/** Uma sessão ativa do IdP (login do usuário no provider), apresentada ao admin. */
|
|
3
|
+
export interface AdminSession {
|
|
4
|
+
/** Id do artefato `Session` no adapter. */
|
|
5
|
+
id: string;
|
|
6
|
+
accountId: string;
|
|
7
|
+
/** Epoch (segundos) do login, quando presente no payload. */
|
|
8
|
+
loginTs?: number;
|
|
9
|
+
/** Métodos de autenticação registrados na sessão (amr), quando presentes. */
|
|
10
|
+
amr?: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Um grant (autorização concedida a um client), com a contagem de tokens vivos. */
|
|
13
|
+
export interface AdminGrant {
|
|
14
|
+
/** Id do artefato `Grant` no adapter (== grantId dos tokens). */
|
|
15
|
+
id: string;
|
|
16
|
+
accountId: string;
|
|
17
|
+
clientId?: string;
|
|
18
|
+
/** Tokens de acesso vivos que referenciam este grant. */
|
|
19
|
+
accessTokens: number;
|
|
20
|
+
/** Refresh tokens vivos que referenciam este grant. */
|
|
21
|
+
refreshTokens: number;
|
|
22
|
+
}
|
|
23
|
+
/** Resultado de uma revogação em massa das sessões/grants de uma conta. */
|
|
24
|
+
export interface RevokeResult {
|
|
25
|
+
sessions: number;
|
|
26
|
+
grants: number;
|
|
27
|
+
accessTokens: number;
|
|
28
|
+
refreshTokens: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Serviço de inspeção/revogação das SESSÕES e GRANTS ativos de uma conta,
|
|
32
|
+
* persistidos pelo oidc-provider via o MESMO `AdapterClass` (mesmo padrão do
|
|
33
|
+
* {@link AdminClientsService}). Encapsula:
|
|
34
|
+
* - a enumeração via a capacidade opcional `list` do adapter (degrada quando
|
|
35
|
+
* ausente, igual ao CRUD de clients);
|
|
36
|
+
* - a destruição das sessões + grants da conta. Destruir um grant CASCATEIA a
|
|
37
|
+
* invalidação dos tokens no oidc-provider: os consumidores de access/refresh
|
|
38
|
+
* token carregam `Grant.find(token.grantId)` e lançam `InvalidToken('grant not
|
|
39
|
+
* found')` quando o grant some (verificado em oidc-provider v9). Mesmo assim,
|
|
40
|
+
* por garantia (belt-and-braces), também destruímos as linhas de AT/RT que
|
|
41
|
+
* referenciam os grants revogados quando o adapter enumera.
|
|
42
|
+
*/
|
|
43
|
+
export declare class AdminSessionsService {
|
|
44
|
+
#private;
|
|
45
|
+
constructor(oidc: OidcService);
|
|
46
|
+
/** Indica se o adapter suporta enumeração (capacidade opcional). */
|
|
47
|
+
get canList(): boolean;
|
|
48
|
+
/** Lista as sessões ativas da conta (vazio quando o adapter não enumera). */
|
|
49
|
+
listSessions(accountId: string): Promise<AdminSession[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Lista os grants da conta, com a contagem de access/refresh tokens vivos que
|
|
52
|
+
* referenciam cada grant (`payload.grantId`). As contagens são baratas (uma
|
|
53
|
+
* enumeração de cada model token), feitas só quando o adapter enumera.
|
|
54
|
+
*/
|
|
55
|
+
listGrants(accountId: string): Promise<AdminGrant[]>;
|
|
56
|
+
/**
|
|
57
|
+
* Revoga TODAS as sessões e grants da conta. Destruir os grants já invalida os
|
|
58
|
+
* tokens (cascata via `grant not found`); ainda assim, quando o adapter enumera,
|
|
59
|
+
* destruímos explicitamente as linhas de AT/RT desses grants (belt-and-braces),
|
|
60
|
+
* deixando o store limpo. Retorna as contagens do que foi removido.
|
|
61
|
+
*/
|
|
62
|
+
revokeAll(accountId: string): Promise<RevokeResult>;
|
|
63
|
+
}
|