@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.
@@ -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
- <div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
31
- @if(clients.length === 0)
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 clients)
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
- * Listagem de OAuth clients. Mostra os clients ESTÁTICOS da config; clients
5
- * registrados dinamicamente (RFC 7591) vivem no adapter OIDC e não são listados
6
- * aqui a view informa isso quando o registro dinâmico está ligado.
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
- * Listagem de OAuth clients. Mostra os clients ESTÁTICOS da config; clients
4
- * registrados dinamicamente (RFC 7591) vivem no adapter OIDC e não são listados
5
- * aqui a view informa isso quando o registro dinâmico está ligado.
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
- clients: cfg.clients.map((c) => ({
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
  }
@@ -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;
@@ -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 vivem no adapter e não aparecem nesta lista.',
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.2.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",