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
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m31s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user