api keycloak setup

This commit is contained in:
tw-blue
2025-11-29 17:58:02 +01:00
parent a9fb52fd4d
commit d6e7de09a4
27 changed files with 548 additions and 68 deletions
View File
+6
View File
@@ -0,0 +1,6 @@
{
"tabWidth": 4,
"arrowParens": "avoid",
"bracketSameLine": true,
"semi": true
}
@@ -1,4 +1,7 @@
import { Controller, Get, Route, SuccessResponse } from 'tsoa';
import { Controller, Get, Post, Route, Security, SuccessResponse } from "tsoa";
import { inject } from "../infrastructure/di/index.js";
import { KC_SECURITY_NAME } from "../services/keycloak/user/index.js";
import { MessagingService } from "../services/messagingService.js";
export interface User {
email: string;
@@ -8,13 +11,21 @@ export interface User {
export type UserCreationParams = Pick<User, "email" | "name" | "phoneNumbers">;
@Route('health')
@Route("health")
export class HealthCheckController extends Controller {
@Get('ok')
@SuccessResponse('200', 'OK')
public async getOk(
): Promise<string> {
this.setStatus(200);
return 'Ok';
private readonly messaging = inject(MessagingService);
@Get("ok")
@SuccessResponse("200", "OK")
public async getOk() {}
@Get("auth")
@Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK")
public async getAuth() {}
@Post("testmessage")
public async sendTestMessage() {
await this.messaging.sendTestMessage();
}
}
@@ -0,0 +1,13 @@
import { Controller, Route } from "@tsoa/runtime";
import { inject } from "../infrastructure/di/index.js";
import { MembersService } from "../services/membersService.js";
@Route("members")
export class MembersContoller extends Controller {
private readonly membersService = inject(MembersService);
// @Security(KC_SECURITY_NAME)
// @Get()
// public async getMembers(): Promise<Member[]> {
// return await this.membersService.getMembers();
// }
}
@@ -0,0 +1,47 @@
import {
Body,
BodyProp,
Controller,
Get,
Path,
Put,
Query,
Route,
Security,
} from "@tsoa/runtime";
import { inject } from "../infrastructure/di";
import { Claim } from "../models/claim";
import { Topic } from "../models/topic";
import { User } from "../models/user";
import { KC_SECURITY_NAME } from "../services/keycloak/user/index.js";
import { UserService } from "../services/userService";
@Route("users")
export class UsersController extends Controller {
private readonly userService = inject(UserService);
@Security(KC_SECURITY_NAME, [Claim.UserAdmin])
@Get()
public async getUsers(): Promise<User[]> {
return await this.userService.getUsers();
}
@Security(KC_SECURITY_NAME, [Claim.UserAdmin])
@Put("/claims/{uid}")
public async setClaims(
@Path() uid: string,
@BodyProp() claim: Claim,
@Query() value: boolean,
): Promise<void> {
await this.userService.setClaim(uid, claim, value);
}
@Security(KC_SECURITY_NAME)
@Put("/subscriptions/{token}")
public async setSubscriptions(
@Body() topics: Topic[],
@Path() token: string,
) {
await this.userService.setSubscriptions(token, topics);
}
}
+7 -2
View File
@@ -1,9 +1,14 @@
import express from 'express';
import { RegisterRoutes } from './generated/routes.js';
import express from "express";
import { RegisterRoutes } from "./generated/routes.js";
import { sessionHandler } from "./infrastructure/session.js";
import { keycloak } from "./services/keycloak/user/index.js";
const app = express();
const port = 3000;
app.use(sessionHandler);
app.use(keycloak.middleware());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
@@ -0,0 +1,16 @@
import { Pool } from "pg";
const dbUser = process.env["TKD_DB_USER"];
const dbPassword = process.env["TKD_DB_PASSWORD"];
if (dbUser === undefined || dbPassword === undefined) {
throw new Error("missing db configuration (user/password)");
}
export const DB_POOL = new Pool({
user: dbUser,
password: dbPassword,
host: "localhost",
port: 5432,
database: "taekwondo",
});
@@ -0,0 +1,2 @@
export { Injectable } from "./injectable-decorator.js";
export { inject } from "./injector.js";
@@ -0,0 +1,23 @@
import { type Token } from "./types.js";
const INJECTABLE_META = Symbol("injectable");
const INJECTABLE_REGISTRY = new Set<Token>();
export function Injectable(): ClassDecorator {
return function (target: any) {
Reflect.defineProperty(target, INJECTABLE_META, {
value: true,
writable: false,
});
INJECTABLE_REGISTRY.add(target);
};
}
export function isInjectable(targert: any) {
return !!targert[INJECTABLE_META];
}
export function getAllInjectables(): Token[] {
return Array.from(INJECTABLE_REGISTRY);
}
@@ -0,0 +1,56 @@
import { getAllInjectables, isInjectable } from "./injectable-decorator.js";
import { type Provider, type Token } from "./types.js";
const currentInjector: Injector = createGlobalInjector();
export function inject<T>(token: Token<T>): T {
if (!currentInjector) {
throw new Error("inject() called outside of injection context");
}
return currentInjector.get(token);
}
export class Injector {
private instances = new Map<Token, any>();
private records = new Map<Token, Provider>();
constructor(providers: Provider[]) {
providers.forEach(p => this.records.set(p.provide, p));
}
get<T>(token: Token<T>): T {
if (this.instances.has(token)) {
return this.instances.get(token);
}
const provider = this.records.get(token);
if (!provider) {
throw new Error("No provider for token");
}
let value = this.instantiate(token);
this.instances.set(token, value);
return value;
}
private instantiate<T>(token: Token<T>): T {
return new token();
}
}
export function createInjector(injectables: Token[]) {
const providers: Provider[] = injectables.map(injectable => {
if (!isInjectable(injectable)) {
throw new Error(`${injectable.name} is not injectable`);
}
return { provide: injectable, useClass: injectable };
});
return new Injector(providers);
}
export function createGlobalInjector() {
const injectables = getAllInjectables();
return createInjector(injectables);
}
@@ -0,0 +1,4 @@
export type Token<T = any> = new (...args: any[]) => T;
export interface Provider<T = any> {
provide: Token<T>;
}
@@ -0,0 +1,29 @@
import pgSession from "connect-pg-simple";
import { type RequestHandler } from "express";
import session from "express-session";
import { DB_POOL } from "./database.js";
const cookieSecret = process.env["TKD_COOKIE_SECRET"];
if (cookieSecret === undefined) {
throw new Error("TKD_COOKIE_SECRET missing");
}
const pgSessionFunc = pgSession(session);
export const sessionStore = new pgSessionFunc({
pool: DB_POOL,
tableName: "session",
});
const sessionConfig = {
store: sessionStore,
name: "SID",
secret: cookieSecret,
resave: false,
saveUninitialized: true,
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7,
sameSite: true,
secure: true, //TODO false for development
},
};
export const sessionHandler: RequestHandler = session(sessionConfig);
+42 -5
View File
@@ -1,10 +1,47 @@
import * as express from 'express';
import { type Request, type RequestHandler } from "express";
import { KC_SECURITY_NAME, keycloak } from "../services/keycloak/user/index.js";
function promisifyMiddleware(
request: Request,
middleware: RequestHandler,
response?: Response,
): Promise<any> {
return new Promise((resolve, reject) => {
middleware(request, response || ({} as any), (result: any) => {
result instanceof Error ? reject(result) : resolve(result);
});
});
}
async function doKeycloakAuth(
request: Request,
roles?: string[],
response?: Response,
): Promise<any> {
if (roles) {
return await promisifyMiddleware(
request,
keycloak.enforcer(roles?.map(role => `realm:${role}`)),
);
} else {
return await promisifyMiddleware(request, keycloak.protect(), response);
}
}
export async function expressAuthentication(
request: express.Request,
request: Request,
securityName: string,
scopes?: string[]
roles?: string[],
response?: Response,
): Promise<any> {
console.dir({ request, securityName, scopes });
return {};
console.dir({ request, securityName, roles });
switch (securityName) {
case KC_SECURITY_NAME:
return doKeycloakAuth(request, roles, response);
case undefined:
return Promise.resolve();
default:
return Promise.reject("Unknown security name");
}
}
+5
View File
@@ -0,0 +1,5 @@
/// Sync to Keycloak realm roles
export enum Claim {
MemberAdmin = "memberadmin",
UserAdmin = "useradmin",
}
+17
View File
@@ -0,0 +1,17 @@
export interface Member {
id?: string | undefined;
vorname?: string | undefined;
nachname?: string | undefined;
geburtsdatum?: string | undefined;
geburtsort?: string | undefined;
geschlecht?: string | undefined;
passnummer?: string | undefined;
uvertrag?: boolean | undefined;
verein?: boolean | undefined;
graduierung?: string | undefined;
letztePruefung?: string | undefined;
kontakt?: string | undefined;
kuendigungzum?: string | undefined;
loeschenam?: string | undefined;
level?: string | undefined;
}
+3
View File
@@ -0,0 +1,3 @@
export enum Topic {
test = "test",
}
+7
View File
@@ -0,0 +1,7 @@
import { Claim } from "./claim";
export interface User {
readonly id?: string;
readonly username: string;
readonly roles: Claim[];
}
@@ -0,0 +1 @@
export * from "./keycloak-admin.js";
@@ -0,0 +1,74 @@
import RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
import { Injectable } from "../../../infrastructure/di/injectable-decorator";
import kcClientConfig from "./nodejs-api.json" with { type: "json" };
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import { Claim } from "../../../models/claim";
import { User } from "../../../models/user";
@Injectable()
export class KeycloakAdmin {
private readonly kcAdminClient: Promise<KeycloakAdminClient>;
constructor() {
this.kcAdminClient = new Promise(async resolve => {
const client = new KeycloakAdminClient({
baseUrl: kcClientConfig.baseUrl,
realmName: "taekwondo",
});
await client.auth({
...kcClientConfig,
grantType: "client_credentials",
});
resolve(client);
});
}
public async addRoleToUser(uid: string, roleName: string): Promise<void> {
const client = await this.kcAdminClient;
const role = await this.getRole(roleName);
if (!role.id || !role.name) {
throw new Error(`Role ${roleName} is missing id or name`);
}
await client.users.addRealmRoleMappings({
id: uid,
roles: [{ id: role.id, name: role.name }],
});
}
public async removeRoleFromUser(uid: string, claim: Claim): Promise<void> {
const client = await this.kcAdminClient;
const role = await this.getRole(claim);
if (!role.id || !role.name) {
throw new Error(`Role ${claim} is missing id or name`);
}
await client.users.delRealmRoleMappings({
id: uid,
roles: [{ id: role.id, name: role.name }],
});
}
public async getUsers(): Promise<User[]> {
const client = await this.kcAdminClient;
const kcUsers = await client.users.find();
return kcUsers.map(kcUser => ({
id: kcUser.id,
username: kcUser.username || "",
roles: (kcUser.realmRoles || []).map(role => role as Claim),
}));
}
private async getRole(name: string): Promise<RoleRepresentation> {
const client = await this.kcAdminClient;
const role = await client.roles.findOneByName({
name: name,
realm: "taekwondo",
});
if (!role) {
throw new Error(`Role ${name} not found`);
}
return role;
}
}
@@ -0,0 +1,60 @@
{
"clientId": "nodejs-api",
"name": "NodejsApi",
"description": "",
"rootUrl": "https://tkd-api.toni714.de",
"adminUrl": "https://tkd-api.toni714.de",
"baseUrl": "https://tkd-api.toni714.de",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"secret": "btdeoaq2mgtoa7SpCEL1phLYcw2gGhGp",
"redirectUris": ["https://tkd-api.toni714.de/*"],
"webOrigins": ["https://tkd-api.toni714.de"],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"realm_client": "false",
"oidc.ciba.grant.enabled": "false",
"client.secret.creation.time": "1762463686",
"backchannel.logout.session.required": "true",
"standard.token.exchange.enabled": "true",
"oauth2.device.authorization.grant.enabled": "false",
"pkce.code.challenge.method": "S256",
"backchannel.logout.revoke.offline.tokens": "false",
"dpop.bound.access.tokens": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"service_account",
"web-origins",
"acr",
"roles",
"profile",
"basic",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"organization",
"offline_access",
"microprofile-jwt"
],
"access": {
"view": true,
"configure": true,
"manage": true
}
}
@@ -0,0 +1 @@
export * from "./keycloak-user.js";
@@ -0,0 +1,11 @@
import KeycloakConnect from "keycloak-connect";
import { sessionStore } from "../../../infrastructure/session.js";
import keycloakConfig from "./keycloak.json" with { type: "json" };
export const KC_SECURITY_NAME = "bearerAuth";
export const keycloak = new KeycloakConnect(
{ store: sessionStore },
keycloakConfig,
);
export const kcUserConfig = keycloakConfig;
@@ -0,0 +1,10 @@
{
"realm": "taekwondo",
"auth-server-url": "https://keycloak.toni714.de:8443",
"ssl-required": "external",
"resource": "nodejs-api",
"credentials": {
"secret": "btdeoaq2mgtoa7SpCEL1phLYcw2gGhGp"
},
"confidential-port": 0
}
@@ -0,0 +1,6 @@
import { Injectable } from "../infrastructure/di/index.js";
@Injectable()
export class MembersService {
// public async getMembers(): Promise<Member[]> {}
}
@@ -0,0 +1,8 @@
import { Injectable } from "../infrastructure/di/index.js";
@Injectable()
export class MessagingService {
public async sendTestMessage(): Promise<void> {
throw new Error("Method not implemented.");
}
}
+28
View File
@@ -0,0 +1,28 @@
import { inject } from "../infrastructure/di/index.js";
import { Claim } from "../models/claim";
import { Topic } from "../models/topic.js";
import { User } from "../models/user.js";
import { KeycloakAdmin } from "./keycloak/admin/index.js";
export class UserService {
private readonly keycloakAdmin = inject(KeycloakAdmin);
public async setClaim(
uid: string,
claim: Claim,
value: boolean,
): Promise<void> {
if (value) {
return await this.keycloakAdmin.addRoleToUser(uid, claim);
} else {
return await this.keycloakAdmin.removeRoleFromUser(uid, claim);
}
}
public async getUsers(): Promise<User[]> {
return await this.keycloakAdmin.getUsers();
}
public async setSubscriptions(token: string, topics: Topic[]) {
throw new Error("Method not implemented.");
}
}
+1 -1
View File
@@ -54,7 +54,7 @@
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"forceConsistentCasingInFileNames": true,
"forceConsistentCasingInFileNames": true
},
"files": ["./src/index.ts"]
}