@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.
- package/build/host/views/admin/client_form.edge +83 -0
- package/build/host/views/admin/clients.edge +68 -3
- package/build/index.d.ts +3 -2
- package/build/index.js +2 -1
- package/build/src/accounts/account_store.d.ts +74 -17
- package/build/src/accounts/account_store.js +12 -1
- package/build/src/accounts/lucid_account_store.d.ts +12 -27
- package/build/src/accounts/lucid_account_store.js +38 -365
- package/build/src/accounts/lucid_store/core.d.ts +8 -0
- package/build/src/accounts/lucid_store/core.js +108 -0
- package/build/src/accounts/lucid_store/mfa.d.ts +8 -0
- package/build/src/accounts/lucid_store/mfa.js +77 -0
- package/build/src/accounts/lucid_store/provider_identity.d.ts +8 -0
- package/build/src/accounts/lucid_store/provider_identity.js +41 -0
- package/build/src/accounts/lucid_store/shared.d.ts +48 -0
- package/build/src/accounts/lucid_store/shared.js +15 -0
- package/build/src/accounts/lucid_store/webauthn.d.ts +8 -0
- package/build/src/accounts/lucid_store/webauthn.js +135 -0
- 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/define_config.d.ts +6 -0
- package/build/src/define_config.js +20 -5
- 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/account_mfa_controller.js +2 -1
- package/build/src/host/controllers/account_session_controller.js +10 -18
- package/build/src/host/controllers/admin/admin_clients_controller.d.ts +17 -3
- package/build/src/host/controllers/admin/admin_clients_controller.js +158 -4
- package/build/src/host/controllers/interaction_controller.js +13 -32
- package/build/src/host/controllers/social_controller.js +7 -0
- package/build/src/host/i18n.d.ts +27 -0
- package/build/src/host/i18n.js +28 -1
- package/build/src/host/login_attempt.d.ts +39 -0
- package/build/src/host/login_attempt.js +37 -0
- package/build/src/host/register_auth_host.d.ts +13 -0
- package/build/src/host/register_auth_host.js +17 -2
- package/build/src/mixins/json_column.d.ts +38 -0
- package/build/src/mixins/json_column.js +31 -0
- package/build/src/mixins/with_audit_log.js +2 -4
- package/build/src/mixins/with_auth_user.js +2 -4
- package/build/src/mixins/with_mfa.js +2 -6
- package/build/src/mixins/with_personal_access_token.js +2 -4
- package/build/src/mixins/with_webauthn_credential.js +6 -8
- 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,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 =
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
28
|
-
|
|
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 =
|
|
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');
|