src/auth/client/adapters/keycloak-clients.service.ts
Properties |
|
Methods |
|
constructor(cfg: ConfigService, clientRepo: Repository<ClientEntity>)
|
|||||||||
Parameters :
|
Async addClient | |||||||||
addClient(tenantId: string, dto: CreateClientDto)
|
|||||||||
Parameters :
Returns :
unknown
|
Async getClient |
getClient(tenantId: string, clientId: string)
|
Returns :
unknown
|
Async getClients | ||||||
getClients(tenantId: string)
|
||||||
Parameters :
Returns :
Promise<ClientEntity[]>
|
getClientSecret |
getClientSecret(sub: string, id: string)
|
Returns :
Promise<string>
|
Private init |
init()
|
Checks if all the roles are available in the realm. If not they will be created.
Returns :
void
|
Async onModuleInit |
onModuleInit()
|
Returns :
any
|
Async removeClient |
removeClient(tenantId: string, clientId: string)
|
Returns :
any
|
Async updateClient | ||||||||||||
updateClient(tenantId: string, clientId: string, updateClientDto: UpdateClientDto)
|
||||||||||||
Parameters :
Returns :
unknown
|
Private kc |
Type : KeycloakAdminClient
|
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import { Credentials } from "@keycloak/keycloak-admin-client/lib/utils/auth";
import { Injectable, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { decodeJwt } from "jose";
import { Repository } from "typeorm";
import { Role } from "../../roles/role.enum";
import { ClientsProvider } from "../client.provider";
import { CreateClientDto } from "../dto/create-client.dto";
import { UpdateClientDto } from "../dto/update-client.dto";
import { ClientEntity } from "../entities/client.entity";
@Injectable()
export class KeycloakClientsProvider implements ClientsProvider, OnModuleInit {
private kc: KeycloakAdminClient;
constructor(
private cfg: ConfigService,
@InjectRepository(ClientEntity)
private clientRepo: Repository<ClientEntity>,
) {}
async onModuleInit() {
const oidc = this.cfg.getOrThrow<string>("OIDC");
const [baseUrl, realmName] = oidc.split("/realms/");
this.kc = new KeycloakAdminClient({ baseUrl, realmName });
const creds: Credentials = {
grantType: "client_credentials",
clientId: this.cfg.getOrThrow("OIDC_CLIENT_ID"),
clientSecret: this.cfg.getOrThrow("OIDC_CLIENT_SECRET"),
};
await this.kc.auth(creds);
const accessToken = await this.kc.getAccessToken();
const payload = decodeJwt(accessToken!);
const refreshMs =
Math.max(5, payload.exp! - Date.now() / 1000 - 10) * 1000;
// Refresh a bit before expiry
setInterval(async () => {
try {
await this.kc.auth(creds);
} catch (e) {
// log & keep trying on next tick.
}
}, refreshMs);
await this.init();
}
/**
* Checks if all the roles are available in the realm. If not they will be created.
*/
private init() {
const existingRoles: Role[] = [
Role.Tenants,
Role.Tenants,
Role.IssuanceOffer,
Role.Issuances,
Role.PresentationOffer,
Role.Presentations,
];
this.kc.roles
.find()
.then((roles) => {
// Check if all roles exist
const missingRoles = existingRoles.filter(
(role) => !roles.find((r) => r.name === role),
);
if (missingRoles.length) {
// Create missing roles
return Promise.all(
missingRoles.map((role) =>
this.kc.roles.create({ name: role }),
),
);
}
})
.catch((err) => {
console.error("Error initializing Keycloak roles:", err);
});
}
async getClients(tenantId: string): Promise<ClientEntity[]> {
return this.clientRepo.find({
where: { tenant: { id: tenantId } },
});
}
async getClient(tenantId: string, clientId: string) {
return this.clientRepo.findOneByOrFail({
clientId,
tenant: { id: tenantId },
});
}
getClientSecret(sub: string, id: string): Promise<string> {
return this.kc.clients
.find({ clientId: id })
.then((clients) => clients[0].secret!);
}
async addClient(tenantId: string, dto: CreateClientDto) {
dto.clientId = `${tenantId}-${dto.clientId}`; // namespaced
// 1) Create client
const created = await this.kc.clients.create({
clientId: dto.clientId,
description: dto.description,
serviceAccountsEnabled: true,
enabled: true,
publicClient: false,
directAccessGrantsEnabled: false,
standardFlowEnabled: false,
webOrigins: ["*"],
attributes: { tenant_id: tenantId }, // useful marker
protocolMappers: [
// hardcode tenant_id claim into tokens
{
name: "tenant_id",
protocol: "openid-connect",
protocolMapper: "oidc-hardcoded-claim-mapper",
config: {
"claim.value": tenantId,
"claim.name": "tenant_id",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
},
},
// expose realm roles as "roles" claim
{
name: "realm-roles",
protocol: "openid-connect",
protocolMapper: "oidc-usermodel-realm-role-mapper",
config: {
"claim.name": "roles",
"jsonType.label": "String",
multivalued: "true",
"access.token.claim": "true",
},
},
],
});
const id = created.id!;
// 3) Generate secret once (show only on creation)
const secret = await this.kc.clients.generateNewClientSecret({ id });
// 4) Assign realm roles to the service account user
const svcUser = await this.kc.clients.getServiceAccountUser({ id });
const allRealmRoles = await this.kc.roles.find();
const toAssign = dto.roles
.map((r) => allRealmRoles.find((ar) => ar.name === r))
.filter(Boolean) as { id?: string; name?: string }[];
if (toAssign.length) {
await this.kc.users.addRealmRoleMappings({
id: svcUser.id!,
roles: toAssign.map((r) => ({ id: r.id!, name: r.name! })),
});
}
// 5) (Optional) Put a mirror row in your DB (no secret)
const entity = this.clientRepo.create({
clientId: dto.clientId,
description: dto.description,
roles: dto.roles,
tenant: { id: tenantId },
});
await this.clientRepo.save(entity);
return {
clientId: dto.clientId,
description: dto.description,
tenantId,
roles: dto.roles,
clientSecret: secret.value,
};
}
async updateClient(
tenantId: string,
clientId: string,
updateClientDto: UpdateClientDto,
) {
const client = await this.getClient(tenantId, clientId);
// Get service account user
const kcClient = (await this.kc.clients.find({ clientId }))[0];
const svcUser = await this.kc.clients.getServiceAccountUser({
id: kcClient.id!,
});
// Get all realm roles
const allRealmRoles = await this.kc.roles.find();
// Roles to assign
const newRoles = updateClientDto.roles || [];
const toAssign = newRoles
.map((r) => allRealmRoles.find((ar) => ar.name === r))
.filter(Boolean) as { id?: string; name?: string }[];
// Get currently assigned roles
const currentRoles = await this.kc.users.listRealmRoleMappings({
id: svcUser.id!,
});
// Roles to remove
const toRemove = currentRoles
.filter((cr) => !newRoles.includes(cr.name as Role))
.map((r) => ({ id: r.id!, name: r.name! }));
// Remove roles no longer assigned
if (toRemove.length) {
await this.kc.users.delRealmRoleMappings({
id: svcUser.id!,
roles: toRemove,
});
}
// Add new roles
if (toAssign.length) {
await this.kc.users.addRealmRoleMappings({
id: svcUser.id!,
roles: toAssign.map((r) => ({ id: r.id!, name: r.name! })),
});
}
// Update client in Keycloak
await this.kc.clients.update(
{ id: kcClient.id! },
{
description: updateClientDto.description ?? client.description,
},
);
// Optionally update your DB mirror
await this.clientRepo.update(
{ clientId, tenant: { id: tenantId } },
{ ...updateClientDto },
);
return this.getClient(tenantId, clientId);
}
async removeClient(tenantId: string, clientId: string) {
const kcClient = (await this.kc.clients.find({ clientId }))[0];
if (kcClient?.id) await this.kc.clients.del({ id: kcClient.id });
await this.clientRepo.delete({ clientId, tenant: { id: tenantId } });
}
}