feat: implement Keycloak SSO integration with JIT provisioning and Next.js login button
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m31s

This commit is contained in:
Kroonk
2026-05-21 10:45:30 +02:00
parent ca64163899
commit d47e87ef82
8 changed files with 350 additions and 1 deletions

View File

@@ -2,7 +2,10 @@ import type { User } from "@michess/types";
import { hash, verify } from "argon2";
import type { Request, Response } from "express";
import xss from "xss";
import crypto from "crypto";
import { Issuer } from "openid-client";
import { db } from "../db/index.js";
import { activeGames } from "../db/models/game.model.js";
import UserModel from "../db/models/user.model.js";
import { io } from "../server.js";
@@ -317,3 +320,123 @@ export const updateUser = async (req: Request, res: Response) => {
res.status(500).end();
}
};
// ==================== KEYCLOAK SSO API ====================
let oidcClient: any = null;
async function getOidcClient() {
if (oidcClient) return oidcClient;
const ssoAuthority = process.env.SSO_AUTHORITY || "https://auth.mischlabs.de/realms/mischlabs";
const ssoClientId = process.env.SSO_CLIENT_ID || "michess";
const ssoClientSecret = process.env.SSO_CLIENT_SECRET;
const ssoRedirectUri = process.env.SSO_REDIRECT_URI || "https://michess.mischlabs.de/v1/auth/sso/callback";
if (!ssoClientSecret) {
throw new Error("SSO_CLIENT_SECRET is not configured");
}
const issuer = await Issuer.discover(ssoAuthority);
oidcClient = new issuer.Client({
client_id: ssoClientId,
client_secret: ssoClientSecret,
redirect_uris: [ssoRedirectUri],
response_types: ["code"]
});
return oidcClient;
}
export const getSsoConfig = async (req: Request, res: Response) => {
res.json({
enabled: !!(process.env.SSO_CLIENT_SECRET && process.env.SSO_CLIENT_ID && process.env.SSO_AUTHORITY)
});
};
export const ssoLogin = async (req: Request, res: Response) => {
try {
const client = await getOidcClient();
const authorizationUrl = client.authorizationUrl({
scope: "openid email profile",
state: "mischlabs-state"
});
res.redirect(authorizationUrl);
} catch (err: any) {
console.error("SSO Login Error:", err);
res.status(500).send("SSO Login Initialisierung fehlgeschlagen: " + err.message);
}
};
export const ssoCallback = async (req: Request, res: Response) => {
try {
const client = await getOidcClient();
const params = client.callbackParams(req);
const ssoRedirectUri = process.env.SSO_REDIRECT_URI || "https://michess.mischlabs.de/v1/auth/sso/callback";
const tokenSet = await client.callback(ssoRedirectUri, params, { state: "mischlabs-state" });
const userinfo = await client.userinfo(tokenSet.access_token);
const username = (userinfo.preferred_username || userinfo.name || userinfo.sub) as string;
const email = userinfo.email as string;
if (!username) {
return res.status(400).send("Kein Benutzername im OIDC Token gefunden.");
}
// Check if user exists in Michess database
const users = await UserModel.findByNameEmail({ name: username, email: email }, false, 1);
const handleUserLogin = (dbUser: User) => {
req.session.user = {
id: dbUser.id,
name: dbUser.name,
email: dbUser.email,
wins: dbUser.wins,
losses: dbUser.losses,
draws: dbUser.draws,
elo: dbUser.elo,
role: dbUser.role
};
const appUrl = process.env.APP_URL || "https://michess.mischlabs.de";
req.session.save(() => {
res.redirect(appUrl);
});
};
if (users && users.length) {
// User exists, log them in
handleUserLogin(users[0]);
} else {
// User does not exist, provision them Just-In-Time (JIT)
const randomPassword = crypto.randomBytes(32).toString("hex");
const hashedPassword = await hash(randomPassword);
// Determine role: Option A (automatically make 'MrDiderot' an admin, or the first user in an empty DB)
let role = "user";
const countRes = await db.query('SELECT COUNT(*) as count FROM "user"');
const count = parseInt(countRes.rows[0].count, 10);
if (count === 0 || username.toLowerCase() === "mrdiderot") {
role = "admin";
}
// Create user
const newUser = await UserModel.create({ name: username, email: email || "" }, hashedPassword);
if (!newUser || !newUser.id) {
return res.status(500).send("JIT-Benutzererstellung fehlgeschlagen");
}
// If we assigned admin role, update it in DB
if (role === "admin") {
await UserModel.adminUpdate(newUser.id as number, { role: "admin" });
newUser.role = "admin";
}
handleUserLogin(newUser);
}
} catch (err: any) {
console.error("SSO Callback Error:", err);
res.status(500).send("SSO Authentifizierung fehlgeschlagen: " + err.message);
}
};