@dudousxd/adonis-authkit-server 0.1.1 → 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.
Files changed (50) hide show
  1. package/build/host/views/admin/client_form.edge +83 -0
  2. package/build/host/views/admin/clients.edge +68 -3
  3. package/build/index.d.ts +3 -2
  4. package/build/index.js +2 -1
  5. package/build/src/accounts/account_store.d.ts +74 -17
  6. package/build/src/accounts/account_store.js +12 -1
  7. package/build/src/accounts/lucid_account_store.d.ts +12 -27
  8. package/build/src/accounts/lucid_account_store.js +38 -365
  9. package/build/src/accounts/lucid_store/core.d.ts +8 -0
  10. package/build/src/accounts/lucid_store/core.js +108 -0
  11. package/build/src/accounts/lucid_store/mfa.d.ts +8 -0
  12. package/build/src/accounts/lucid_store/mfa.js +77 -0
  13. package/build/src/accounts/lucid_store/provider_identity.d.ts +8 -0
  14. package/build/src/accounts/lucid_store/provider_identity.js +41 -0
  15. package/build/src/accounts/lucid_store/shared.d.ts +48 -0
  16. package/build/src/accounts/lucid_store/shared.js +15 -0
  17. package/build/src/accounts/lucid_store/webauthn.d.ts +8 -0
  18. package/build/src/accounts/lucid_store/webauthn.js +135 -0
  19. package/build/src/adapters/adapter_contract.d.ts +12 -0
  20. package/build/src/adapters/database_adapter.d.ts +8 -1
  21. package/build/src/adapters/database_adapter.js +17 -0
  22. package/build/src/adapters/redis_adapter.d.ts +8 -1
  23. package/build/src/adapters/redis_adapter.js +26 -0
  24. package/build/src/audit/audit_sink.d.ts +1 -1
  25. package/build/src/define_config.d.ts +6 -0
  26. package/build/src/define_config.js +20 -5
  27. package/build/src/host/admin_clients_service.d.ts +65 -0
  28. package/build/src/host/admin_clients_service.js +136 -0
  29. package/build/src/host/controllers/account_mfa_controller.js +2 -1
  30. package/build/src/host/controllers/account_session_controller.js +10 -18
  31. package/build/src/host/controllers/admin/admin_clients_controller.d.ts +17 -3
  32. package/build/src/host/controllers/admin/admin_clients_controller.js +158 -4
  33. package/build/src/host/controllers/interaction_controller.js +13 -32
  34. package/build/src/host/controllers/social_controller.js +7 -0
  35. package/build/src/host/i18n.d.ts +27 -0
  36. package/build/src/host/i18n.js +28 -1
  37. package/build/src/host/login_attempt.d.ts +39 -0
  38. package/build/src/host/login_attempt.js +37 -0
  39. package/build/src/host/register_auth_host.d.ts +13 -0
  40. package/build/src/host/register_auth_host.js +17 -2
  41. package/build/src/mixins/json_column.d.ts +38 -0
  42. package/build/src/mixins/json_column.js +31 -0
  43. package/build/src/mixins/with_audit_log.js +2 -4
  44. package/build/src/mixins/with_auth_user.js +2 -4
  45. package/build/src/mixins/with_mfa.js +2 -6
  46. package/build/src/mixins/with_personal_access_token.js +2 -4
  47. package/build/src/mixins/with_webauthn_credential.js +6 -8
  48. package/build/src/provider/oidc_service.d.ts +15 -0
  49. package/build/src/provider/oidc_service.js +27 -0
  50. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
2
+ import type { AuthAccount } from '../account_store.js';
3
+ /**
4
+ * Encripta/decripta um valor (ex.: o segredo TOTP) em repouso. Mantém a lib
5
+ * desacoplada do serviço de encryption do app — qualquer implementação que
6
+ * faça round-trip serve (em prod, normalmente o `@adonisjs/core/services/encryption`).
7
+ * `decrypt` retorna `null` se o valor foi adulterado/é inválido.
8
+ */
9
+ export interface AccountSecretEncrypter {
10
+ encrypt(value: string): string;
11
+ decrypt(value: string): string | null;
12
+ }
13
+ /**
14
+ * Funções das cerimônias WebAuthn. Espelham a assinatura do `@simplewebauthn/server`
15
+ * (subconjunto usado). Injetáveis via {@link LucidAccountStoreOptions.webauthnCeremonies}
16
+ * para testes.
17
+ */
18
+ export interface WebauthnCeremonies {
19
+ generateRegistrationOptions: typeof generateRegistrationOptions;
20
+ verifyRegistrationResponse: typeof verifyRegistrationResponse;
21
+ generateAuthenticationOptions: typeof generateAuthenticationOptions;
22
+ verifyAuthenticationResponse: typeof verifyAuthenticationResponse;
23
+ }
24
+ /** RP (Relying Party) do WebAuthn usado nas cerimônias. */
25
+ export interface ResolvedRp {
26
+ rpName: string;
27
+ rpId: string;
28
+ origin: string | string[];
29
+ }
30
+ /**
31
+ * Contexto compartilhado pelos builders de capacidade. Carrega o model principal,
32
+ * os helpers de segredo e (quando configurados) os models/parametros das capacidades.
33
+ */
34
+ export interface LucidStoreContext {
35
+ Model: any;
36
+ mfaIssuer: string;
37
+ recoveryCodeCount: number;
38
+ /** Encripta o segredo antes de persistir (no-op sem encrypter). */
39
+ sealSecret(secret: string): string;
40
+ /** Decripta o segredo armazenado; null em falha/adulteração (no-op sem encrypter). */
41
+ openSecret(stored: string | null | undefined): string | null;
42
+ toAccount(row: any): AuthAccount;
43
+ }
44
+ export declare const sha256: (value: string) => string;
45
+ /** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
46
+ export declare function generateRecoveryCode(): string;
47
+ /** Comparação de hashes hex resistente a timing. */
48
+ export declare function hashesEqual(a: string, b: string): boolean;
@@ -0,0 +1,15 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
3
+ /** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
4
+ export function generateRecoveryCode() {
5
+ const raw = randomBytes(5).toString('hex');
6
+ return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
7
+ }
8
+ /** Comparação de hashes hex resistente a timing. */
9
+ export function hashesEqual(a, b) {
10
+ const ba = Buffer.from(a);
11
+ const bb = Buffer.from(b);
12
+ if (ba.length !== bb.length)
13
+ return false;
14
+ return timingSafeEqual(ba, bb);
15
+ }
@@ -0,0 +1,8 @@
1
+ import type { WebauthnCapability } from '../account_store.js';
2
+ import type { LucidStoreContext, ResolvedRp, WebauthnCeremonies } from './shared.js';
3
+ /**
4
+ * Capacidade de passkeys / WebAuthn (2º fator alternativo ao TOTP). Só é montada
5
+ * quando o `webauthnCredentialModel` é fornecido — ausente, a capacidade inteira
6
+ * fica ABSENTE do store (a UI esconde a seção de passkeys).
7
+ */
8
+ export declare function buildWebauthn(ctx: LucidStoreContext, Credential: any, webauthn: ResolvedRp, ceremonies: WebauthnCeremonies): WebauthnCapability;
@@ -0,0 +1,135 @@
1
+ import { DateTime } from 'luxon';
2
+ /**
3
+ * Capacidade de passkeys / WebAuthn (2º fator alternativo ao TOTP). Só é montada
4
+ * quando o `webauthnCredentialModel` é fornecido — ausente, a capacidade inteira
5
+ * fica ABSENTE do store (a UI esconde a seção de passkeys).
6
+ */
7
+ export function buildWebauthn(ctx, Credential, webauthn, ceremonies) {
8
+ const { Model } = ctx;
9
+ return {
10
+ async generatePasskeyRegistrationOptions(accountId) {
11
+ const row = await Model.find(accountId);
12
+ if (!row)
13
+ return null;
14
+ const existing = await Credential.query().where('accountId', accountId);
15
+ const options = await ceremonies.generateRegistrationOptions({
16
+ rpName: webauthn.rpName,
17
+ rpID: webauthn.rpId,
18
+ userName: row.email,
19
+ userDisplayName: row.fullName ?? row.email,
20
+ // Não pede attestation (privacidade); confia na verificação local.
21
+ attestationType: 'none',
22
+ // Evita registrar a mesma credencial duas vezes.
23
+ excludeCredentials: existing.map((c) => ({
24
+ id: c.id,
25
+ transports: (c.transports ?? undefined),
26
+ })),
27
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
28
+ });
29
+ return {
30
+ options: options,
31
+ challenge: options.challenge,
32
+ };
33
+ },
34
+ async verifyPasskeyRegistration(accountId, response, expectedChallenge) {
35
+ const row = await Model.find(accountId);
36
+ if (!row)
37
+ return false;
38
+ let verification;
39
+ try {
40
+ verification = await ceremonies.verifyRegistrationResponse({
41
+ response: response,
42
+ expectedChallenge,
43
+ expectedOrigin: webauthn.origin,
44
+ expectedRPID: webauthn.rpId,
45
+ });
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ if (!verification.verified || !verification.registrationInfo)
51
+ return false;
52
+ const { credential } = verification.registrationInfo;
53
+ // publicKey vem como Uint8Array → armazenamos como base64url (texto).
54
+ const publicKey = Buffer.from(credential.publicKey).toString('base64url');
55
+ await Credential.create({
56
+ id: credential.id,
57
+ accountId,
58
+ publicKey,
59
+ counter: credential.counter,
60
+ transports: credential.transports ?? null,
61
+ label: null,
62
+ });
63
+ // Registrar uma passkey também habilita o MFA (2º fator presente).
64
+ if (!row.mfaEnabledAt) {
65
+ row.mfaEnabledAt = DateTime.now();
66
+ await row.save();
67
+ }
68
+ return true;
69
+ },
70
+ async generatePasskeyAuthenticationOptions(accountId) {
71
+ const creds = await Credential.query().where('accountId', accountId);
72
+ if (creds.length === 0)
73
+ return null;
74
+ const options = await ceremonies.generateAuthenticationOptions({
75
+ rpID: webauthn.rpId,
76
+ allowCredentials: creds.map((c) => ({
77
+ id: c.id,
78
+ transports: (c.transports ?? undefined),
79
+ })),
80
+ userVerification: 'preferred',
81
+ });
82
+ return {
83
+ options: options,
84
+ challenge: options.challenge,
85
+ };
86
+ },
87
+ async verifyPasskeyAuthentication(accountId, response, expectedChallenge) {
88
+ const resp = response;
89
+ // O credential id vem na resposta (base64url) → acha a credencial da conta.
90
+ const cred = await Credential.query()
91
+ .where('accountId', accountId)
92
+ .where('id', resp?.id ?? '')
93
+ .first();
94
+ if (!cred)
95
+ return false;
96
+ let verification;
97
+ try {
98
+ verification = await ceremonies.verifyAuthenticationResponse({
99
+ response: resp,
100
+ expectedChallenge,
101
+ expectedOrigin: webauthn.origin,
102
+ expectedRPID: webauthn.rpId,
103
+ credential: {
104
+ id: cred.id,
105
+ publicKey: new Uint8Array(Buffer.from(cred.publicKey, 'base64url')),
106
+ counter: cred.counter,
107
+ transports: (cred.transports ?? undefined),
108
+ },
109
+ });
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ if (!verification.verified)
115
+ return false;
116
+ // Atualiza o signature counter (anti-replay).
117
+ cred.counter = verification.authenticationInfo.newCounter;
118
+ await cred.save();
119
+ return true;
120
+ },
121
+ async listPasskeys(accountId) {
122
+ const creds = await Credential.query()
123
+ .where('accountId', accountId)
124
+ .orderBy('createdAt', 'asc');
125
+ return creds.map((c) => ({
126
+ id: c.id,
127
+ label: c.label ?? undefined,
128
+ createdAt: c.createdAt?.toISO?.() ?? String(c.createdAt ?? ''),
129
+ }));
130
+ },
131
+ async removePasskey(accountId, credentialId) {
132
+ await Credential.query().where('accountId', accountId).where('id', credentialId).delete();
133
+ },
134
+ };
135
+ }
@@ -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
  */
@@ -117,6 +117,12 @@ export interface ResolvedDynamicRegistrationConfig {
117
117
  initialAccessToken?: string;
118
118
  management: boolean;
119
119
  }
120
+ /**
121
+ * Resolve a config de registro dinâmico e VALIDA invariantes em tempo de resolução.
122
+ * O Registration Management (RFC 7592) só faz sentido com o registro habilitado
123
+ * (RFC 7591) — `management: true` com `enabled: false` é um erro de configuração.
124
+ */
125
+ export declare function resolveDynamicRegistration(input?: DynamicRegistrationConfigInput): ResolvedDynamicRegistrationConfig;
120
126
  /**
121
127
  * Console admin opt-in do IdP (B6). Quando habilitado, monta o grupo `/admin/*`
122
128
  * (dashboard, usuários/papéis, clients, audit) atrás de um guard que exige sessão
@@ -26,6 +26,25 @@ export function resolveLockout(input) {
26
26
  store: input?.store,
27
27
  };
28
28
  }
29
+ /**
30
+ * Resolve a config de registro dinâmico e VALIDA invariantes em tempo de resolução.
31
+ * O Registration Management (RFC 7592) só faz sentido com o registro habilitado
32
+ * (RFC 7591) — `management: true` com `enabled: false` é um erro de configuração.
33
+ */
34
+ export function resolveDynamicRegistration(input) {
35
+ const enabled = input?.enabled ?? false;
36
+ const management = input?.management ?? false;
37
+ if (management && !enabled) {
38
+ throw new Error('authkit: dynamicRegistration.management (RFC 7592) requer ' +
39
+ 'dynamicRegistration.enabled: true (RFC 7591). Habilite o registro dinâmico ' +
40
+ 'ou desligue o management.');
41
+ }
42
+ return {
43
+ enabled,
44
+ initialAccessToken: input?.initialAccessToken,
45
+ management,
46
+ };
47
+ }
29
48
  export function resolveAdmin(input) {
30
49
  return {
31
50
  enabled: input?.enabled ?? false,
@@ -102,11 +121,7 @@ export function defineConfig(config) {
102
121
  audit: config.audit,
103
122
  mfaIssuer: config.mfaIssuer ?? 'AuthKit',
104
123
  webauthn: resolveWebauthn(config.issuer, config.mfaIssuer ?? 'AuthKit', config.webauthn),
105
- dynamicRegistration: {
106
- enabled: config.dynamicRegistration?.enabled ?? false,
107
- initialAccessToken: config.dynamicRegistration?.initialAccessToken,
108
- management: config.dynamicRegistration?.management ?? false,
109
- },
124
+ dynamicRegistration: resolveDynamicRegistration(config.dynamicRegistration),
110
125
  admin: resolveAdmin(config.admin),
111
126
  messages: resolveMessages(config.i18n),
112
127
  locale: config.i18n?.locale ?? 'pt-BR',
@@ -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
+ }
@@ -2,6 +2,7 @@ import '../augmentations.js';
2
2
  import QRCode from 'qrcode';
3
3
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
4
4
  import { translate } from '../i18n.js';
5
+ import { supportsPasskeys } from '../../accounts/account_store.js';
5
6
  /** Desafio WebAuthn pendente (registro) guardado na sessão entre begin/finish. */
6
7
  const PASSKEY_REG_CHALLENGE_KEY = 'authkit_passkey_reg_challenge';
7
8
  /**
@@ -18,7 +19,7 @@ export default class AccountMfaController {
18
19
  const state = (await cfg.accountStore.getMfaState?.(userId)) ?? { enabled: false };
19
20
  const recoveryCodes = ctx.session.flashMessages.get('recoveryCodes');
20
21
  // Passkeys disponíveis quando o store as suporta (model de credenciais wired).
21
- const passkeysSupported = typeof cfg.accountStore.listPasskeys === 'function';
22
+ const passkeysSupported = supportsPasskeys(cfg.accountStore);
22
23
  const passkeys = passkeysSupported ? await cfg.accountStore.listPasskeys(userId) : [];
23
24
  return render(ctx, 'account/mfa', {
24
25
  csrfToken: ctx.request.csrfToken,
@@ -1,7 +1,7 @@
1
1
  import '../augmentations.js';
2
2
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
3
3
  import { translate } from '../i18n.js';
4
- import { createAccountLockout } from '../account_lockout.js';
4
+ import { attemptPasswordLogin } from '../login_attempt.js';
5
5
  export default class AccountSessionController {
6
6
  async show(ctx) {
7
7
  const service = await ctx.containerResolver.make('authkit.server');
@@ -18,27 +18,19 @@ export default class AccountSessionController {
18
18
  const render = cfg.render;
19
19
  const { email, password } = ctx.request.only(['email', 'password']);
20
20
  const ip = ctx.request.ip?.() ?? null;
21
- const lockout = createAccountLockout(cfg.lockout);
22
- // Bloqueio progressivo: se a conta está travada, nem verifica a senha.
23
- const lock = await lockout.isLocked(email);
24
- if (lock.locked) {
21
+ // Verificação + lockout + auditoria de falha centralizados (sem clientId no console).
22
+ const result = await attemptPasswordLogin(cfg, { email, password, ip });
23
+ if (!result.ok) {
25
24
  return render(ctx, 'account/login', {
26
25
  csrfToken: ctx.request.csrfToken,
27
- error: translate(cfg.messages, 'errors.account_locked', {
28
- seconds: lock.retryAfterSec ?? 0,
29
- }),
26
+ error: result.locked
27
+ ? translate(cfg.messages, 'errors.account_locked', {
28
+ seconds: result.retryAfterSec ?? 0,
29
+ })
30
+ : translate(cfg.messages, 'errors.invalid_credentials'),
30
31
  });
31
32
  }
32
- const acc = await cfg.accountStore.verifyCredentials(email, password);
33
- if (!acc) {
34
- await cfg.audit?.record({ type: 'login.failure', email, ip });
35
- await lockout.recordFailure(email, { sink: cfg.audit, ip });
36
- return render(ctx, 'account/login', {
37
- csrfToken: ctx.request.csrfToken,
38
- error: translate(cfg.messages, 'errors.invalid_credentials'),
39
- });
40
- }
41
- await lockout.clearFailures(email);
33
+ const acc = result.account;
42
34
  ctx.session.put(ACCOUNT_SESSION_KEY, acc.id);
43
35
  await cfg.audit?.record({ type: 'login.success', accountId: acc.id, email, ip });
44
36
  return ctx.response.redirect('/account/tokens');