@dudousxd/adonis-authkit-server 0.2.0 → 0.3.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/host/views/admin/client_form.edge +83 -0
- package/build/host/views/admin/clients.edge +68 -3
- package/build/src/adapters/adapter_contract.d.ts +12 -0
- package/build/src/adapters/database_adapter.d.ts +8 -1
- package/build/src/adapters/database_adapter.js +17 -0
- package/build/src/adapters/redis_adapter.d.ts +8 -1
- package/build/src/adapters/redis_adapter.js +26 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/host/admin_clients_service.d.ts +65 -0
- package/build/src/host/admin_clients_service.js +136 -0
- 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/i18n.d.ts +27 -0
- package/build/src/host/i18n.js +28 -1
- package/build/src/host/register_auth_host.js +8 -0
- package/build/src/provider/oidc_service.d.ts +15 -0
- package/build/src/provider/oidc_service.js +27 -0
- package/package.json +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="pt-br"><head><meta charset="utf-8"><title>{{ mode === 'edit' ? t('admin.clients.edit_title') : t('admin.clients.new_title') }}</title>
|
|
3
|
+
<script src="https://cdn.tailwindcss.com"></script></head>
|
|
4
|
+
<body class="min-h-screen bg-gray-100 p-4">
|
|
5
|
+
<div class="mx-auto max-w-2xl">
|
|
6
|
+
<div class="flex items-center justify-between py-6">
|
|
7
|
+
<div>
|
|
8
|
+
<div class="text-xs font-semibold uppercase tracking-widest text-gray-400">{{ t('common.brand_eyebrow') }}</div>
|
|
9
|
+
<h1 class="text-xl font-semibold text-gray-900">{{ mode === 'edit' ? t('admin.clients.edit_title') : t('admin.clients.new_title') }}</h1>
|
|
10
|
+
</div>
|
|
11
|
+
<a href="/admin/clients" class="text-sm text-gray-500 hover:underline">{{ t('admin.clients.back') }}</a>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<form
|
|
15
|
+
method="POST"
|
|
16
|
+
action="{{ mode === 'edit' ? `/admin/clients/${client.clientId}/edit` : '/admin/clients' }}"
|
|
17
|
+
class="space-y-5 rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5"
|
|
18
|
+
>
|
|
19
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
20
|
+
|
|
21
|
+
<div>
|
|
22
|
+
<label class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_client_id') }}</label>
|
|
23
|
+
@if(mode === 'edit')
|
|
24
|
+
<input type="text" value="{{ client.clientId }}" disabled
|
|
25
|
+
class="mt-1 w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-sm text-gray-500" />
|
|
26
|
+
@else
|
|
27
|
+
<input type="text" name="client_id" value="{{ client.clientId }}" placeholder="{{ t('admin.clients.field_client_id_placeholder') }}"
|
|
28
|
+
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-gray-900" />
|
|
29
|
+
<p class="mt-1 text-xs text-gray-400">{{ t('admin.clients.field_client_id_help') }}</p>
|
|
30
|
+
@end
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div>
|
|
34
|
+
<label class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_redirect_uris') }}</label>
|
|
35
|
+
<textarea name="redirect_uris" rows="3" placeholder="https://app.exemplo.com/callback"
|
|
36
|
+
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-gray-900">{{ client.redirectUris.join('\n') }}</textarea>
|
|
37
|
+
<p class="mt-1 text-xs text-gray-400">{{ t('admin.clients.field_redirect_uris_help') }}</p>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div>
|
|
41
|
+
<label class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_post_logout_uris') }}</label>
|
|
42
|
+
<textarea name="post_logout_redirect_uris" rows="2"
|
|
43
|
+
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm outline-none focus:border-gray-900">{{ client.postLogoutRedirectUris.join('\n') }}</textarea>
|
|
44
|
+
<p class="mt-1 text-xs text-gray-400">{{ t('admin.clients.field_post_logout_uris_help') }}</p>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div>
|
|
48
|
+
<span class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_grant_types') }}</span>
|
|
49
|
+
<div class="mt-2 space-y-1">
|
|
50
|
+
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
51
|
+
<input type="checkbox" name="grant_types" value="authorization_code" {{ client.grants.includes('authorization_code') ? 'checked' : '' }}>
|
|
52
|
+
authorization_code
|
|
53
|
+
</label>
|
|
54
|
+
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
55
|
+
<input type="checkbox" name="grant_types" value="refresh_token" {{ client.grants.includes('refresh_token') ? 'checked' : '' }}>
|
|
56
|
+
refresh_token
|
|
57
|
+
</label>
|
|
58
|
+
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
59
|
+
<input type="checkbox" name="grant_types" value="client_credentials" {{ client.grants.includes('client_credentials') ? 'checked' : '' }}>
|
|
60
|
+
client_credentials
|
|
61
|
+
</label>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div>
|
|
66
|
+
<label class="block text-sm font-medium text-gray-700">{{ t('admin.clients.field_auth_method') }}</label>
|
|
67
|
+
<select name="token_endpoint_auth_method"
|
|
68
|
+
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900">
|
|
69
|
+
<option value="client_secret_basic" {{ client.tokenEndpointAuthMethod === 'client_secret_basic' ? 'selected' : '' }}>client_secret_basic</option>
|
|
70
|
+
<option value="client_secret_post" {{ client.tokenEndpointAuthMethod === 'client_secret_post' ? 'selected' : '' }}>client_secret_post</option>
|
|
71
|
+
<option value="none" {{ client.tokenEndpointAuthMethod === 'none' ? 'selected' : '' }}>none ({{ t('admin.clients.public') }})</option>
|
|
72
|
+
</select>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="flex items-center justify-end gap-2 pt-2">
|
|
76
|
+
<a href="/admin/clients" class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">{{ t('admin.clients.cancel') }}</a>
|
|
77
|
+
<button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-semibold text-white hover:bg-gray-800">
|
|
78
|
+
{{ mode === 'edit' ? t('admin.clients.save') : t('admin.clients.create') }}
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</form>
|
|
82
|
+
</div>
|
|
83
|
+
</body></html>
|
|
@@ -21,17 +21,30 @@
|
|
|
21
21
|
<a href="/admin/audit" class="text-gray-500 hover:underline">{{ t('admin.nav.audit') }}</a>
|
|
22
22
|
</nav>
|
|
23
23
|
|
|
24
|
+
@if(createdSecret)
|
|
25
|
+
<div class="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4 text-sm text-emerald-900">
|
|
26
|
+
<p class="font-semibold">{{ t('admin.clients.secret_once_title') }}</p>
|
|
27
|
+
<p class="mt-1">{{ t('admin.clients.secret_once_notice') }}</p>
|
|
28
|
+
<p class="mt-2 break-all font-mono text-xs">
|
|
29
|
+
<span class="text-emerald-700">client_id:</span> {{ createdSecret.clientId }}<br>
|
|
30
|
+
<span class="text-emerald-700">client_secret:</span> {{ createdSecret.clientSecret }}
|
|
31
|
+
</p>
|
|
32
|
+
</div>
|
|
33
|
+
@end
|
|
34
|
+
|
|
24
35
|
@if(dynamicEnabled)
|
|
25
36
|
<div class="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
|
|
26
37
|
{{ t('admin.clients.dynamic_notice') }}
|
|
27
38
|
</div>
|
|
28
39
|
@end
|
|
29
40
|
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
{{-- Clients estáticos (config, somente leitura). --}}
|
|
42
|
+
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">{{ t('admin.clients.static_section') }}</h2>
|
|
43
|
+
<div class="mb-8 overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
|
|
44
|
+
@if(staticClients.length === 0)
|
|
32
45
|
<p class="p-6 text-sm text-gray-500">{{ t('admin.clients.empty') }}</p>
|
|
33
46
|
@else
|
|
34
|
-
@each(client in
|
|
47
|
+
@each(client in staticClients)
|
|
35
48
|
<div class="border-b border-gray-100 p-4 last:border-0">
|
|
36
49
|
<div class="flex items-center justify-between">
|
|
37
50
|
<p class="text-sm font-medium text-gray-900">{{ client.clientId }}</p>
|
|
@@ -47,5 +60,57 @@
|
|
|
47
60
|
@end
|
|
48
61
|
@end
|
|
49
62
|
</div>
|
|
63
|
+
|
|
64
|
+
{{-- Clients dinâmicos (adapter, com CRUD). --}}
|
|
65
|
+
<div class="mb-2 flex items-center justify-between">
|
|
66
|
+
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500">{{ t('admin.clients.dynamic_section') }}</h2>
|
|
67
|
+
@if(dynamicSupported)
|
|
68
|
+
<a href="/admin/clients/new" class="rounded-lg bg-gray-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-gray-800">
|
|
69
|
+
{{ t('admin.clients.new') }}
|
|
70
|
+
</a>
|
|
71
|
+
@end
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
@if(!dynamicSupported)
|
|
75
|
+
<div class="rounded-xl bg-white p-6 text-sm text-gray-500 shadow-sm ring-1 ring-black/5">
|
|
76
|
+
{{ t('admin.clients.dynamic_not_supported') }}
|
|
77
|
+
</div>
|
|
78
|
+
@else
|
|
79
|
+
<div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
|
|
80
|
+
@if(dynamicClients.length === 0)
|
|
81
|
+
<p class="p-6 text-sm text-gray-500">{{ t('admin.clients.dynamic_empty') }}</p>
|
|
82
|
+
@else
|
|
83
|
+
@each(client in dynamicClients)
|
|
84
|
+
<div class="border-b border-gray-100 p-4 last:border-0">
|
|
85
|
+
<div class="flex items-center justify-between gap-2">
|
|
86
|
+
<p class="break-all text-sm font-medium text-gray-900">{{ client.clientId }}</p>
|
|
87
|
+
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs {{ client.confidential ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600' }}">
|
|
88
|
+
{{ client.confidential ? t('admin.clients.confidential') : t('admin.clients.public') }}
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
<p class="mt-1 text-xs text-gray-500">{{ t('admin.clients.grants', { grants: client.grants.join(', ') }) }}</p>
|
|
92
|
+
@if(client.redirectUris.length > 0)
|
|
93
|
+
<p class="text-xs text-gray-400">{{ t('admin.clients.redirect_uris', { uris: client.redirectUris.join(', ') }) }}</p>
|
|
94
|
+
@end
|
|
95
|
+
<div class="mt-3 flex flex-wrap items-center gap-2">
|
|
96
|
+
<a href="/admin/clients/{{ client.clientId }}/edit" class="rounded border border-gray-300 px-3 py-1 text-xs hover:bg-gray-50">
|
|
97
|
+
{{ t('admin.clients.edit') }}
|
|
98
|
+
</a>
|
|
99
|
+
@if(client.confidential)
|
|
100
|
+
<form method="POST" action="/admin/clients/{{ client.clientId }}/regenerate-secret" onsubmit="return confirm('{{ t('admin.clients.regenerate_confirm') }}')">
|
|
101
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
102
|
+
<button type="submit" class="rounded border border-gray-300 px-3 py-1 text-xs hover:bg-gray-50">{{ t('admin.clients.regenerate_secret') }}</button>
|
|
103
|
+
</form>
|
|
104
|
+
@end
|
|
105
|
+
<form method="POST" action="/admin/clients/{{ client.clientId }}/delete" onsubmit="return confirm('{{ t('admin.clients.delete_confirm') }}')">
|
|
106
|
+
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
107
|
+
<button type="submit" class="rounded border border-red-300 px-3 py-1 text-xs text-red-700 hover:bg-red-50">{{ t('admin.clients.delete') }}</button>
|
|
108
|
+
</form>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
@end
|
|
112
|
+
@end
|
|
113
|
+
</div>
|
|
114
|
+
@end
|
|
50
115
|
</div>
|
|
51
116
|
</body></html>
|
|
@@ -6,6 +6,11 @@ export interface OidcPayload {
|
|
|
6
6
|
uid?: string;
|
|
7
7
|
consumed?: unknown;
|
|
8
8
|
}
|
|
9
|
+
/** Um client OIDC enumerado do adapter (id + payload de metadata persistido). */
|
|
10
|
+
export interface EnumeratedClient {
|
|
11
|
+
clientId: string;
|
|
12
|
+
payload: Record<string, unknown>;
|
|
13
|
+
}
|
|
9
14
|
/** Contrato que o oidc-provider espera de um adapter (um por model). */
|
|
10
15
|
export interface OidcAdapter {
|
|
11
16
|
upsert(id: string, payload: OidcPayload, expiresIn: number): Promise<void>;
|
|
@@ -15,4 +20,11 @@ export interface OidcAdapter {
|
|
|
15
20
|
consume(id: string): Promise<void>;
|
|
16
21
|
destroy(id: string): Promise<void>;
|
|
17
22
|
revokeByGrantId(grantId: string): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Enumera os artefatos do model deste adapter — usado SÓ para o model `Client`
|
|
25
|
+
* pelo console admin, para listar clients persistidos (registro dinâmico/CRUD).
|
|
26
|
+
* Capacidade OPCIONAL (estilo `AuditSink.list`): adapters que não conseguem
|
|
27
|
+
* enumerar de forma barata omitem o método e a UI degrada graciosamente.
|
|
28
|
+
*/
|
|
29
|
+
listClients?(): Promise<EnumeratedClient[]>;
|
|
18
30
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Database } from '@adonisjs/lucid/database';
|
|
2
|
-
import type { OidcAdapter, OidcPayload } from './adapter_contract.js';
|
|
2
|
+
import type { EnumeratedClient, OidcAdapter, OidcPayload } from './adapter_contract.js';
|
|
3
3
|
export declare class DatabaseAdapter implements OidcAdapter {
|
|
4
4
|
#private;
|
|
5
5
|
private name;
|
|
@@ -12,4 +12,11 @@ export declare class DatabaseAdapter implements OidcAdapter {
|
|
|
12
12
|
consume(id: string): Promise<void>;
|
|
13
13
|
destroy(id: string): Promise<void>;
|
|
14
14
|
revokeByGrantId(grantId: string): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Enumera os clients persistidos (registro dinâmico ou CRUD do console admin).
|
|
17
|
+
* Filtra por `model_name = this.name` (sempre 'Client' aqui) e descarta linhas
|
|
18
|
+
* expiradas — clients são persistidos sem TTL (`expires_at` NULL), então isso
|
|
19
|
+
* só é uma rede de segurança caso algum dia algo grave o model com expiração.
|
|
20
|
+
*/
|
|
21
|
+
listClients(): Promise<EnumeratedClient[]>;
|
|
15
22
|
}
|
|
@@ -60,4 +60,21 @@ export class DatabaseAdapter {
|
|
|
60
60
|
async revokeByGrantId(grantId) {
|
|
61
61
|
await this.db.query().from(TABLE).where('grant_id', grantId).delete();
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Enumera os clients persistidos (registro dinâmico ou CRUD do console admin).
|
|
65
|
+
* Filtra por `model_name = this.name` (sempre 'Client' aqui) e descarta linhas
|
|
66
|
+
* expiradas — clients são persistidos sem TTL (`expires_at` NULL), então isso
|
|
67
|
+
* só é uma rede de segurança caso algum dia algo grave o model com expiração.
|
|
68
|
+
*/
|
|
69
|
+
async listClients() {
|
|
70
|
+
const rows = await this.#query().orderBy('id', 'asc');
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const result = [];
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
if (row.expires_at && new Date(row.expires_at).getTime() <= now)
|
|
75
|
+
continue;
|
|
76
|
+
result.push({ clientId: row.id, payload: JSON.parse(row.payload) });
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
63
80
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Redis } from 'ioredis';
|
|
2
|
-
import type { OidcAdapter, OidcPayload } from './adapter_contract.js';
|
|
2
|
+
import type { EnumeratedClient, OidcAdapter, OidcPayload } from './adapter_contract.js';
|
|
3
3
|
export declare class RedisAdapter implements OidcAdapter {
|
|
4
4
|
#private;
|
|
5
5
|
private name;
|
|
@@ -13,4 +13,11 @@ export declare class RedisAdapter implements OidcAdapter {
|
|
|
13
13
|
consume(id: string): Promise<void>;
|
|
14
14
|
destroy(id: string): Promise<void>;
|
|
15
15
|
revokeByGrantId(grantId: string): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Enumera os clients persistidos via SCAN sobre o prefixo de chave do model
|
|
18
|
+
* (`<prefix>:Client:*`). É limpo porque cada artefato é uma chave única já
|
|
19
|
+
* namespaceada por `prefix` + `name`; SCAN é não-bloqueante (cursor) ao
|
|
20
|
+
* contrário de KEYS. Usado apenas pelo console admin (model 'Client').
|
|
21
|
+
*/
|
|
22
|
+
listClients(): Promise<EnumeratedClient[]>;
|
|
16
23
|
}
|
|
@@ -92,4 +92,30 @@ export class RedisAdapter {
|
|
|
92
92
|
multi.del(gk);
|
|
93
93
|
await multi.exec();
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Enumera os clients persistidos via SCAN sobre o prefixo de chave do model
|
|
97
|
+
* (`<prefix>:Client:*`). É limpo porque cada artefato é uma chave única já
|
|
98
|
+
* namespaceada por `prefix` + `name`; SCAN é não-bloqueante (cursor) ao
|
|
99
|
+
* contrário de KEYS. Usado apenas pelo console admin (model 'Client').
|
|
100
|
+
*/
|
|
101
|
+
async listClients() {
|
|
102
|
+
const prefix = `${this.prefix}:${this.name}:`;
|
|
103
|
+
const result = [];
|
|
104
|
+
let cursor = '0';
|
|
105
|
+
do {
|
|
106
|
+
const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 100);
|
|
107
|
+
cursor = next;
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
const data = await this.redis.get(key);
|
|
110
|
+
if (!data)
|
|
111
|
+
continue;
|
|
112
|
+
result.push({
|
|
113
|
+
clientId: key.slice(prefix.length),
|
|
114
|
+
payload: JSON.parse(data),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
} while (cursor !== '0');
|
|
118
|
+
result.sort((a, b) => a.clientId.localeCompare(b.clientId));
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
95
121
|
}
|
|
@@ -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';
|
|
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';
|
|
5
5
|
/**
|
|
6
6
|
* Evento de auditoria a registrar. O timestamp é definido pelo sink (não aqui).
|
|
7
7
|
*/
|
|
@@ -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,136 @@
|
|
|
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.listClients === 'function';
|
|
29
|
+
}
|
|
30
|
+
/** Lista os clients persistidos (vazio quando o adapter não enumera; cheque canList). */
|
|
31
|
+
async list() {
|
|
32
|
+
if (!this.#adapter.listClients)
|
|
33
|
+
return [];
|
|
34
|
+
const rows = await this.#adapter.listClients();
|
|
35
|
+
return rows.map((r) => this.#present(r));
|
|
36
|
+
}
|
|
37
|
+
/** Lê um client persistido pelo client_id (undefined quando não existe). */
|
|
38
|
+
async find(clientId) {
|
|
39
|
+
const payload = await this.#adapter.find(clientId);
|
|
40
|
+
if (!payload)
|
|
41
|
+
return undefined;
|
|
42
|
+
return this.#present({ clientId, payload: payload });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Cria um client. Gera client_id quando não informado; gera client_secret
|
|
46
|
+
* para clients confidenciais (auth method != 'none'). Retorna o secret em
|
|
47
|
+
* claro UMA vez (não é recuperável depois — o payload guarda o mesmo valor).
|
|
48
|
+
*/
|
|
49
|
+
async create(input) {
|
|
50
|
+
const clientId = (input.clientId ?? '').trim() || randomId();
|
|
51
|
+
const confidential = !PUBLIC_AUTH_METHODS.has(input.tokenEndpointAuthMethod);
|
|
52
|
+
const clientSecret = confidential ? randomId() : undefined;
|
|
53
|
+
const payload = this.#buildPayload(clientId, input, clientSecret);
|
|
54
|
+
// expiresIn 0 => sem TTL (clients são permanentes, como no registro dinâmico).
|
|
55
|
+
await this.#adapter.upsert(clientId, payload, 0);
|
|
56
|
+
await this.oidc.evictDynamicClientCache();
|
|
57
|
+
return { clientId, clientSecret };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Atualiza metadata editável (redirect/post-logout URIs, grants, auth method)
|
|
61
|
+
* PRESERVANDO o client_secret existente. Lança se o client não existe.
|
|
62
|
+
*/
|
|
63
|
+
async update(clientId, input) {
|
|
64
|
+
const existing = await this.#adapter.find(clientId);
|
|
65
|
+
if (!existing)
|
|
66
|
+
throw new Error(`client ${clientId} não encontrado`);
|
|
67
|
+
const previousSecret = existing.client_secret;
|
|
68
|
+
const confidential = !PUBLIC_AUTH_METHODS.has(input.tokenEndpointAuthMethod);
|
|
69
|
+
// Mantém o secret atual se continua confidencial; se virou public, remove-o.
|
|
70
|
+
const clientSecret = confidential ? (previousSecret ?? randomId()) : undefined;
|
|
71
|
+
const payload = this.#buildPayload(clientId, input, clientSecret);
|
|
72
|
+
await this.#adapter.upsert(clientId, payload, 0);
|
|
73
|
+
await this.oidc.evictDynamicClientCache();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Regenera o client_secret de um client confidencial, preservando o resto da
|
|
77
|
+
* metadata. Retorna o novo secret em claro (mostrado UMA vez). Lança se o
|
|
78
|
+
* client não existe ou é public (auth method 'none').
|
|
79
|
+
*/
|
|
80
|
+
async regenerateSecret(clientId) {
|
|
81
|
+
const existing = await this.#adapter.find(clientId);
|
|
82
|
+
if (!existing)
|
|
83
|
+
throw new Error(`client ${clientId} não encontrado`);
|
|
84
|
+
const authMethod = existing.token_endpoint_auth_method ?? 'client_secret_basic';
|
|
85
|
+
if (PUBLIC_AUTH_METHODS.has(authMethod)) {
|
|
86
|
+
throw new Error(`client ${clientId} é public — não possui secret`);
|
|
87
|
+
}
|
|
88
|
+
const clientSecret = randomId();
|
|
89
|
+
const payload = { ...existing, client_secret: clientSecret };
|
|
90
|
+
await this.#adapter.upsert(clientId, payload, 0);
|
|
91
|
+
await this.oidc.evictDynamicClientCache();
|
|
92
|
+
return clientSecret;
|
|
93
|
+
}
|
|
94
|
+
/** Remove um client persistido e invalida o cache. */
|
|
95
|
+
async delete(clientId) {
|
|
96
|
+
await this.#adapter.destroy(clientId);
|
|
97
|
+
await this.oidc.evictDynamicClientCache();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Monta o payload na forma snake_case que o oidc-provider espera/persiste —
|
|
101
|
+
* verificada contra o que o registro dinâmico (RFC 7591) grava. As chaves de
|
|
102
|
+
* metadata não enviadas (subject_type, id_token_signed_response_alg, etc.) são
|
|
103
|
+
* preenchidas pelo Schema do provider ao construir o Client em `find`.
|
|
104
|
+
*/
|
|
105
|
+
#buildPayload(clientId, input, clientSecret) {
|
|
106
|
+
const grantTypes = input.grantTypes.length
|
|
107
|
+
? input.grantTypes
|
|
108
|
+
: ['authorization_code', 'refresh_token'];
|
|
109
|
+
// response_types: 'code' quando o fluxo de authorization_code está presente.
|
|
110
|
+
const responseTypes = grantTypes.includes('authorization_code') ? ['code'] : [];
|
|
111
|
+
const payload = {
|
|
112
|
+
client_id: clientId,
|
|
113
|
+
redirect_uris: input.redirectUris,
|
|
114
|
+
post_logout_redirect_uris: input.postLogoutRedirectUris,
|
|
115
|
+
grant_types: grantTypes,
|
|
116
|
+
response_types: responseTypes,
|
|
117
|
+
token_endpoint_auth_method: input.tokenEndpointAuthMethod,
|
|
118
|
+
};
|
|
119
|
+
if (clientSecret)
|
|
120
|
+
payload.client_secret = clientSecret;
|
|
121
|
+
return payload;
|
|
122
|
+
}
|
|
123
|
+
/** Projeta um payload persistido para a forma exibida no console admin. */
|
|
124
|
+
#present(row) {
|
|
125
|
+
const p = row.payload;
|
|
126
|
+
const authMethod = p.token_endpoint_auth_method ?? 'client_secret_basic';
|
|
127
|
+
return {
|
|
128
|
+
clientId: row.clientId,
|
|
129
|
+
confidential: !!p.client_secret,
|
|
130
|
+
grants: p.grant_types ?? ['authorization_code', 'refresh_token'],
|
|
131
|
+
redirectUris: p.redirect_uris ?? [],
|
|
132
|
+
postLogoutRedirectUris: p.post_logout_redirect_uris ?? [],
|
|
133
|
+
tokenEndpointAuthMethod: authMethod,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import '../../augmentations.js';
|
|
2
2
|
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* CRUD de clients OIDC no console admin. Mostra os clients ESTÁTICOS da config
|
|
5
|
+
* (somente leitura, rotulados) lado a lado com os clients DINÂMICOS persistidos
|
|
6
|
+
* no adapter (registro dinâmico/RFC 7591 + os criados aqui). Quando o adapter não
|
|
7
|
+
* suporta enumeração (`listClients`), a seção dinâmica degrada graciosamente —
|
|
8
|
+
* espelhando o padrão da tela de auditoria.
|
|
7
9
|
*/
|
|
8
10
|
export default class AdminClientsController {
|
|
9
11
|
index(ctx: HttpContext): Promise<any>;
|
|
12
|
+
/** Formulário de criação. */
|
|
13
|
+
create(ctx: HttpContext): Promise<any>;
|
|
14
|
+
/** Persiste um client novo; mostra o secret UMA vez via flash. */
|
|
15
|
+
store(ctx: HttpContext): Promise<void>;
|
|
16
|
+
/** Formulário de edição de um client persistido. */
|
|
17
|
+
edit(ctx: HttpContext): Promise<any>;
|
|
18
|
+
/** Atualiza metadata editável (NÃO o secret). */
|
|
19
|
+
update(ctx: HttpContext): Promise<void>;
|
|
20
|
+
/** Regenera o secret de um client confidencial; mostra o novo valor UMA vez. */
|
|
21
|
+
regenerateSecret(ctx: HttpContext): Promise<void>;
|
|
22
|
+
/** Remove um client persistido. */
|
|
23
|
+
destroy(ctx: HttpContext): Promise<void>;
|
|
10
24
|
}
|
|
@@ -1,24 +1,178 @@
|
|
|
1
1
|
import '../../augmentations.js';
|
|
2
|
+
import { ACCOUNT_SESSION_KEY } from '../../middleware/account_auth.js';
|
|
3
|
+
import { AdminClientsService, } from '../../admin_clients_service.js';
|
|
4
|
+
const VALID_GRANTS = ['authorization_code', 'refresh_token', 'client_credentials'];
|
|
5
|
+
const VALID_AUTH_METHODS = [
|
|
6
|
+
'client_secret_basic',
|
|
7
|
+
'client_secret_post',
|
|
8
|
+
'none',
|
|
9
|
+
];
|
|
10
|
+
/** Normaliza um textarea (1 item por linha) numa lista sem vazios nem duplicatas. */
|
|
11
|
+
function parseLines(raw) {
|
|
12
|
+
return Array.from(new Set(String(raw ?? '')
|
|
13
|
+
.split(/\r?\n/)
|
|
14
|
+
.map((l) => l.trim())
|
|
15
|
+
.filter((l) => l.length > 0)));
|
|
16
|
+
}
|
|
17
|
+
/** Lê os grants marcados no form (checkboxes); cai no default quando nenhum. */
|
|
18
|
+
function parseGrants(ctx) {
|
|
19
|
+
const raw = ctx.request.input('grant_types', []);
|
|
20
|
+
const arr = Array.isArray(raw) ? raw : [raw];
|
|
21
|
+
const filtered = arr.filter((g) => VALID_GRANTS.includes(g));
|
|
22
|
+
return filtered.length ? filtered : ['authorization_code', 'refresh_token'];
|
|
23
|
+
}
|
|
24
|
+
function parseAuthMethod(ctx) {
|
|
25
|
+
const raw = ctx.request.input('token_endpoint_auth_method', 'client_secret_basic');
|
|
26
|
+
return (VALID_AUTH_METHODS.includes(raw)
|
|
27
|
+
? raw
|
|
28
|
+
: 'client_secret_basic');
|
|
29
|
+
}
|
|
30
|
+
function readInput(ctx) {
|
|
31
|
+
return {
|
|
32
|
+
clientId: ctx.request.input('client_id', '').trim() || undefined,
|
|
33
|
+
redirectUris: parseLines(ctx.request.input('redirect_uris')),
|
|
34
|
+
postLogoutRedirectUris: parseLines(ctx.request.input('post_logout_redirect_uris')),
|
|
35
|
+
grantTypes: parseGrants(ctx),
|
|
36
|
+
tokenEndpointAuthMethod: parseAuthMethod(ctx),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
2
39
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
40
|
+
* CRUD de clients OIDC no console admin. Mostra os clients ESTÁTICOS da config
|
|
41
|
+
* (somente leitura, rotulados) lado a lado com os clients DINÂMICOS persistidos
|
|
42
|
+
* no adapter (registro dinâmico/RFC 7591 + os criados aqui). Quando o adapter não
|
|
43
|
+
* suporta enumeração (`listClients`), a seção dinâmica degrada graciosamente —
|
|
44
|
+
* espelhando o padrão da tela de auditoria.
|
|
6
45
|
*/
|
|
7
46
|
export default class AdminClientsController {
|
|
8
47
|
async index(ctx) {
|
|
9
48
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
10
49
|
const cfg = service.config;
|
|
11
50
|
const render = cfg.render;
|
|
51
|
+
const admin = new AdminClientsService(service);
|
|
52
|
+
const dynamicSupported = admin.canList;
|
|
53
|
+
const dynamicClients = dynamicSupported ? await admin.list() : [];
|
|
54
|
+
const createdSecret = ctx.session.flashMessages.get('createdClientSecret');
|
|
12
55
|
return render(ctx, 'admin/clients', {
|
|
13
56
|
csrfToken: ctx.request.csrfToken,
|
|
14
57
|
dynamicEnabled: cfg.dynamicRegistration.enabled,
|
|
15
|
-
|
|
58
|
+
dynamicSupported,
|
|
59
|
+
createdSecret: createdSecret ?? null,
|
|
60
|
+
staticClients: cfg.clients.map((c) => ({
|
|
16
61
|
clientId: c.clientId,
|
|
17
62
|
confidential: !!c.clientSecret,
|
|
18
63
|
grants: c.grants ?? ['authorization_code', 'refresh_token'],
|
|
19
64
|
redirectUris: c.redirectUris ?? [],
|
|
20
65
|
postLogoutRedirectUris: c.postLogoutRedirectUris ?? [],
|
|
21
66
|
})),
|
|
67
|
+
dynamicClients,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Formulário de criação. */
|
|
71
|
+
async create(ctx) {
|
|
72
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
73
|
+
const render = service.config.render;
|
|
74
|
+
return render(ctx, 'admin/client_form', {
|
|
75
|
+
csrfToken: ctx.request.csrfToken,
|
|
76
|
+
mode: 'create',
|
|
77
|
+
client: {
|
|
78
|
+
clientId: '',
|
|
79
|
+
redirectUris: [],
|
|
80
|
+
postLogoutRedirectUris: [],
|
|
81
|
+
grants: ['authorization_code', 'refresh_token'],
|
|
82
|
+
tokenEndpointAuthMethod: 'client_secret_basic',
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Persiste um client novo; mostra o secret UMA vez via flash. */
|
|
87
|
+
async store(ctx) {
|
|
88
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
89
|
+
const cfg = service.config;
|
|
90
|
+
const admin = new AdminClientsService(service);
|
|
91
|
+
const input = readInput(ctx);
|
|
92
|
+
const created = await admin.create(input);
|
|
93
|
+
if (created.clientSecret) {
|
|
94
|
+
ctx.session.flash('createdClientSecret', {
|
|
95
|
+
clientId: created.clientId,
|
|
96
|
+
clientSecret: created.clientSecret,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
await cfg.audit?.record({
|
|
100
|
+
type: 'client.created',
|
|
101
|
+
clientId: created.clientId,
|
|
102
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
103
|
+
ip: ctx.request.ip?.() ?? null,
|
|
104
|
+
});
|
|
105
|
+
return ctx.response.redirect('/admin/clients');
|
|
106
|
+
}
|
|
107
|
+
/** Formulário de edição de um client persistido. */
|
|
108
|
+
async edit(ctx) {
|
|
109
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
110
|
+
const render = service.config.render;
|
|
111
|
+
const admin = new AdminClientsService(service);
|
|
112
|
+
const clientId = ctx.request.param('id');
|
|
113
|
+
const client = await admin.find(clientId);
|
|
114
|
+
if (!client)
|
|
115
|
+
return ctx.response.redirect('/admin/clients');
|
|
116
|
+
return render(ctx, 'admin/client_form', {
|
|
117
|
+
csrfToken: ctx.request.csrfToken,
|
|
118
|
+
mode: 'edit',
|
|
119
|
+
client,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Atualiza metadata editável (NÃO o secret). */
|
|
123
|
+
async update(ctx) {
|
|
124
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
125
|
+
const cfg = service.config;
|
|
126
|
+
const admin = new AdminClientsService(service);
|
|
127
|
+
const clientId = ctx.request.param('id');
|
|
128
|
+
const existing = await admin.find(clientId);
|
|
129
|
+
if (!existing)
|
|
130
|
+
return ctx.response.redirect('/admin/clients');
|
|
131
|
+
const input = { ...readInput(ctx), clientId };
|
|
132
|
+
await admin.update(clientId, input);
|
|
133
|
+
await cfg.audit?.record({
|
|
134
|
+
type: 'client.updated',
|
|
135
|
+
clientId,
|
|
136
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
137
|
+
ip: ctx.request.ip?.() ?? null,
|
|
138
|
+
});
|
|
139
|
+
return ctx.response.redirect('/admin/clients');
|
|
140
|
+
}
|
|
141
|
+
/** Regenera o secret de um client confidencial; mostra o novo valor UMA vez. */
|
|
142
|
+
async regenerateSecret(ctx) {
|
|
143
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
144
|
+
const cfg = service.config;
|
|
145
|
+
const admin = new AdminClientsService(service);
|
|
146
|
+
const clientId = ctx.request.param('id');
|
|
147
|
+
try {
|
|
148
|
+
const secret = await admin.regenerateSecret(clientId);
|
|
149
|
+
ctx.session.flash('createdClientSecret', { clientId, clientSecret: secret });
|
|
150
|
+
await cfg.audit?.record({
|
|
151
|
+
type: 'client.updated',
|
|
152
|
+
clientId,
|
|
153
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
154
|
+
ip: ctx.request.ip?.() ?? null,
|
|
155
|
+
metadata: { action: 'regenerate_secret' },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// client inexistente ou public — sem secret a regenerar; volta silenciosamente.
|
|
160
|
+
}
|
|
161
|
+
return ctx.response.redirect('/admin/clients');
|
|
162
|
+
}
|
|
163
|
+
/** Remove um client persistido. */
|
|
164
|
+
async destroy(ctx) {
|
|
165
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
166
|
+
const cfg = service.config;
|
|
167
|
+
const admin = new AdminClientsService(service);
|
|
168
|
+
const clientId = ctx.request.param('id');
|
|
169
|
+
await admin.delete(clientId);
|
|
170
|
+
await cfg.audit?.record({
|
|
171
|
+
type: 'client.deleted',
|
|
172
|
+
clientId,
|
|
173
|
+
actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
174
|
+
ip: ctx.request.ip?.() ?? null,
|
|
22
175
|
});
|
|
176
|
+
return ctx.response.redirect('/admin/clients');
|
|
23
177
|
}
|
|
24
178
|
}
|
package/build/src/host/i18n.d.ts
CHANGED
|
@@ -148,6 +148,33 @@ export declare const DEFAULT_MESSAGES: {
|
|
|
148
148
|
'admin.clients.grants': string;
|
|
149
149
|
'admin.clients.redirect_uris': string;
|
|
150
150
|
'admin.clients.dynamic_notice': string;
|
|
151
|
+
'admin.clients.static_section': string;
|
|
152
|
+
'admin.clients.dynamic_section': string;
|
|
153
|
+
'admin.clients.dynamic_empty': string;
|
|
154
|
+
'admin.clients.dynamic_not_supported': string;
|
|
155
|
+
'admin.clients.new': string;
|
|
156
|
+
'admin.clients.new_title': string;
|
|
157
|
+
'admin.clients.edit_title': string;
|
|
158
|
+
'admin.clients.edit': string;
|
|
159
|
+
'admin.clients.delete': string;
|
|
160
|
+
'admin.clients.delete_confirm': string;
|
|
161
|
+
'admin.clients.regenerate_secret': string;
|
|
162
|
+
'admin.clients.regenerate_confirm': string;
|
|
163
|
+
'admin.clients.back': string;
|
|
164
|
+
'admin.clients.cancel': string;
|
|
165
|
+
'admin.clients.save': string;
|
|
166
|
+
'admin.clients.create': string;
|
|
167
|
+
'admin.clients.secret_once_title': string;
|
|
168
|
+
'admin.clients.secret_once_notice': string;
|
|
169
|
+
'admin.clients.field_client_id': string;
|
|
170
|
+
'admin.clients.field_client_id_placeholder': string;
|
|
171
|
+
'admin.clients.field_client_id_help': string;
|
|
172
|
+
'admin.clients.field_redirect_uris': string;
|
|
173
|
+
'admin.clients.field_redirect_uris_help': string;
|
|
174
|
+
'admin.clients.field_post_logout_uris': string;
|
|
175
|
+
'admin.clients.field_post_logout_uris_help': string;
|
|
176
|
+
'admin.clients.field_grant_types': string;
|
|
177
|
+
'admin.clients.field_auth_method': string;
|
|
151
178
|
'admin.audit.page_title': string;
|
|
152
179
|
'admin.audit.title': string;
|
|
153
180
|
'admin.audit.type_placeholder': string;
|
package/build/src/host/i18n.js
CHANGED
|
@@ -153,7 +153,34 @@ export const DEFAULT_MESSAGES = {
|
|
|
153
153
|
'admin.clients.public': 'Público',
|
|
154
154
|
'admin.clients.grants': 'Grants: {grants}',
|
|
155
155
|
'admin.clients.redirect_uris': 'Redirects: {uris}',
|
|
156
|
-
'admin.clients.dynamic_notice': 'O registro dinâmico de clients está ligado — clients registrados via /reg
|
|
156
|
+
'admin.clients.dynamic_notice': 'O registro dinâmico de clients (RFC 7591) está ligado — clients registrados via /reg são persistidos no adapter e aparecem na seção dinâmica abaixo.',
|
|
157
|
+
'admin.clients.static_section': 'Clients estáticos (config)',
|
|
158
|
+
'admin.clients.dynamic_section': 'Clients dinâmicos (adapter)',
|
|
159
|
+
'admin.clients.dynamic_empty': 'Nenhum client dinâmico persistido.',
|
|
160
|
+
'admin.clients.dynamic_not_supported': 'O adapter OIDC configurado não suporta enumeração de clients — a gestão dinâmica fica indisponível.',
|
|
161
|
+
'admin.clients.new': 'Novo client',
|
|
162
|
+
'admin.clients.new_title': 'Novo client OIDC',
|
|
163
|
+
'admin.clients.edit_title': 'Editar client OIDC',
|
|
164
|
+
'admin.clients.edit': 'Editar',
|
|
165
|
+
'admin.clients.delete': 'Excluir',
|
|
166
|
+
'admin.clients.delete_confirm': 'Excluir este client? Esta ação não pode ser desfeita.',
|
|
167
|
+
'admin.clients.regenerate_secret': 'Regenerar secret',
|
|
168
|
+
'admin.clients.regenerate_confirm': 'Regenerar o secret? O secret atual deixará de funcionar imediatamente.',
|
|
169
|
+
'admin.clients.back': 'Voltar',
|
|
170
|
+
'admin.clients.cancel': 'Cancelar',
|
|
171
|
+
'admin.clients.save': 'Salvar',
|
|
172
|
+
'admin.clients.create': 'Criar client',
|
|
173
|
+
'admin.clients.secret_once_title': 'Guarde o client_secret agora',
|
|
174
|
+
'admin.clients.secret_once_notice': 'Este é o único momento em que o secret é exibido. Copie-o agora — ele não pode ser recuperado depois.',
|
|
175
|
+
'admin.clients.field_client_id': 'Client ID',
|
|
176
|
+
'admin.clients.field_client_id_placeholder': 'deixe em branco para gerar automaticamente',
|
|
177
|
+
'admin.clients.field_client_id_help': 'Opcional. Se vazio, um identificador aleatório será gerado.',
|
|
178
|
+
'admin.clients.field_redirect_uris': 'Redirect URIs',
|
|
179
|
+
'admin.clients.field_redirect_uris_help': 'Uma URI por linha.',
|
|
180
|
+
'admin.clients.field_post_logout_uris': 'Post-logout redirect URIs',
|
|
181
|
+
'admin.clients.field_post_logout_uris_help': 'Uma URI por linha (opcional).',
|
|
182
|
+
'admin.clients.field_grant_types': 'Grant types',
|
|
183
|
+
'admin.clients.field_auth_method': 'Token endpoint auth method',
|
|
157
184
|
// Console admin — auditoria.
|
|
158
185
|
'admin.audit.page_title': 'Auditoria',
|
|
159
186
|
'admin.audit.title': 'Log de auditoria',
|
|
@@ -133,6 +133,14 @@ export function registerAuthHost(router, opts) {
|
|
|
133
133
|
router.get('/admin/users', [C.adminUsers, 'index']);
|
|
134
134
|
router.post('/admin/users/:id/roles', [C.adminUsers, 'updateRoles']);
|
|
135
135
|
router.get('/admin/clients', [C.adminClients, 'index']);
|
|
136
|
+
// CRUD de clients OIDC (adapter-backed). `/new` ANTES de `:id` p/ não casar
|
|
137
|
+
// "new" como id; todas as escritas são POST (com _csrf na view).
|
|
138
|
+
router.get('/admin/clients/new', [C.adminClients, 'create']);
|
|
139
|
+
router.post('/admin/clients', [C.adminClients, 'store']);
|
|
140
|
+
router.get('/admin/clients/:id/edit', [C.adminClients, 'edit']);
|
|
141
|
+
router.post('/admin/clients/:id/edit', [C.adminClients, 'update']);
|
|
142
|
+
router.post('/admin/clients/:id/regenerate-secret', [C.adminClients, 'regenerateSecret']);
|
|
143
|
+
router.post('/admin/clients/:id/delete', [C.adminClients, 'destroy']);
|
|
136
144
|
router.get('/admin/audit', [C.adminAudit, 'index']);
|
|
137
145
|
})
|
|
138
146
|
.use([adminGuard]);
|
|
@@ -12,6 +12,21 @@ export declare class OidcService {
|
|
|
12
12
|
readonly interactions: InteractionActions;
|
|
13
13
|
get config(): ResolvedServerConfig;
|
|
14
14
|
constructor(config: ResolvedServerConfig, appKey: string, recorder?: MetricsRecorder);
|
|
15
|
+
/**
|
|
16
|
+
* Invalida o cache de clients DINÂMICOS do oidc-provider (a `dynamicClients`
|
|
17
|
+
* QuickLRU em `instance(provider)`). DEVE ser chamado após qualquer escrita
|
|
18
|
+
* (create/update/delete) no model `Client` via adapter, pelo console admin.
|
|
19
|
+
*
|
|
20
|
+
* NOTA sobre o porquê: o oidc-provider v9 cacheia clients carregados do adapter
|
|
21
|
+
* numa LRU CUJA CHAVE É O HASH (sha256) DO PAYLOAD persistido — não o client_id.
|
|
22
|
+
* Por isso uma alteração de metadata já é "auto-invalidante": `Client.find` relê o
|
|
23
|
+
* adapter, hasheia o payload NOVO, dá cache-miss e reconstrói o client. Mesmo assim
|
|
24
|
+
* limpamos a LRU explicitamente para (a) tornar o efeito imediato e determinístico
|
|
25
|
+
* (sem depender de pressão de LRU para expulsar a entrada antiga, agora inalcançável)
|
|
26
|
+
* e (b) liberar a entrada órfã na hora. É o caminho de invalidação suportado: a LRU
|
|
27
|
+
* é um detalhe interno acessível via o helper `weak_cache` do próprio provider.
|
|
28
|
+
*/
|
|
29
|
+
evictDynamicClientCache(): Promise<void>;
|
|
15
30
|
/** Verifica client_id + client_secret contra os clients da config (p/ endpoints custom como introspecção de PAT). */
|
|
16
31
|
verifyClientCredentials(clientId: string, clientSecret: string): boolean;
|
|
17
32
|
}
|
|
@@ -72,6 +72,33 @@ export class OidcService {
|
|
|
72
72
|
}
|
|
73
73
|
this.interactions = createInteractionActions(this.provider, { verifyCredentials: config.verifyCredentials });
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Invalida o cache de clients DINÂMICOS do oidc-provider (a `dynamicClients`
|
|
77
|
+
* QuickLRU em `instance(provider)`). DEVE ser chamado após qualquer escrita
|
|
78
|
+
* (create/update/delete) no model `Client` via adapter, pelo console admin.
|
|
79
|
+
*
|
|
80
|
+
* NOTA sobre o porquê: o oidc-provider v9 cacheia clients carregados do adapter
|
|
81
|
+
* numa LRU CUJA CHAVE É O HASH (sha256) DO PAYLOAD persistido — não o client_id.
|
|
82
|
+
* Por isso uma alteração de metadata já é "auto-invalidante": `Client.find` relê o
|
|
83
|
+
* adapter, hasheia o payload NOVO, dá cache-miss e reconstrói o client. Mesmo assim
|
|
84
|
+
* limpamos a LRU explicitamente para (a) tornar o efeito imediato e determinístico
|
|
85
|
+
* (sem depender de pressão de LRU para expulsar a entrada antiga, agora inalcançável)
|
|
86
|
+
* e (b) liberar a entrada órfã na hora. É o caminho de invalidação suportado: a LRU
|
|
87
|
+
* é um detalhe interno acessível via o helper `weak_cache` do próprio provider.
|
|
88
|
+
*/
|
|
89
|
+
async evictDynamicClientCache() {
|
|
90
|
+
try {
|
|
91
|
+
const wc = await import('oidc-provider/lib/helpers/weak_cache.js');
|
|
92
|
+
const get = wc.default ?? wc.get;
|
|
93
|
+
const int = get(this.provider);
|
|
94
|
+
int?.dynamicClients?.clear?.();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Estrutura interna mudou numa versão futura do oidc-provider: a invalidação por
|
|
98
|
+
// hash-de-conteúdo (acima) continua garantindo correção; só perdemos a expulsão
|
|
99
|
+
// imediata da entrada órfã. Best-effort — não propaga erro pro caminho da request.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
75
102
|
/** Verifica client_id + client_secret contra os clients da config (p/ endpoints custom como introspecção de PAT). */
|
|
76
103
|
verifyClientCredentials(clientId, clientSecret) {
|
|
77
104
|
const client = this.#clients.find((c) => c.clientId === clientId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dudousxd/adonis-authkit-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dudousxd",
|