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

@@ -24,3 +24,10 @@ MICHESS_API_PORT=3001
# App-URL (für Next.js Metadaten) # App-URL (für Next.js Metadaten)
NEXT_PUBLIC_APP_URL=http://deine-nas-ip:3000 NEXT_PUBLIC_APP_URL=http://deine-nas-ip:3000
NEXT_PUBLIC_API_URL=http://deine-nas-ip:3001 NEXT_PUBLIC_API_URL=http://deine-nas-ip:3001
# Keycloak SSO (OIDC) Integration
SSO_AUTHORITY=https://auth.mischlabs.de/realms/mischlabs
SSO_CLIENT_ID=michess
SSO_CLIENT_SECRET=dein_client_secret_aus_keycloak
SSO_REDIRECT_URI=https://michess.mischlabs.de/v1/auth/sso/callback
APP_URL=https://michess.mischlabs.de

127
brain.old Normal file
View File

@@ -0,0 +1,127 @@
# Brain.md — MiChess Project Memory
> Dieses Dokument ist mein aktives Gedächtnis während der Entwicklung von MiChess.
> Ich denke hier, notiere Entscheidungen, Probleme und Fortschritt.
---
## Projektziel
MiChess = Lichess-inspirierte Schachplattform, self-hosted auf einer NAS, in Docker, mit Stockfish-Engine.
---
## Tech Stack (Entschieden)
| Schicht | Technologie | Grund |
|---|---|---|
| Frontend | Next.js 14 + React + Tailwind + daisyUI | Von chessu geerbt, modern, SSR-fähig |
| Backend | Node.js + Express + Socket.io | Von chessu geerbt, real-time-ready |
| Datenbank | PostgreSQL | Von chessu geerbt, solide |
| Auth | express-session + argon2 | Bereits vorhanden, sicher |
| Schach-Logik | chess.js | Bereits vorhanden |
| Schachbrett UI | react-chessboard | Bereits vorhanden |
| Stockfish | stockfish npm-Paket | Einfache Integration im Backend |
| Container | Docker Compose | NAS-Deployment |
| Package Manager | pnpm Workspaces | Monorepo-Setup |
---
## Basis-Projekt
- **Geclont von:** `dotnize/chessu` (MIT-Lizenz)
- **Warum:** TypeScript, React/Next.js, Express, Socket.io, PostgreSQL, chess.js — perfekter Ausgangspunkt
- **Was chessu schon hat:**
- User-Accounts (name, email, password, wins/losses/draws)
- Session-Auth mit argon2-Passwort-Hashing
- Real-time Multiplayer via Socket.io
- Spiele mit PGN-Speicherung
- Public Games Liste
- Archiv (gespielte Partien)
- User-Profilseite
---
## Was ich bauen muss (Delta zu chessu)
1. **Stockfish AI-Gegner** — verschiedene Schwierigkeitsgrade (Level 1-8, ELO-basiert)
2. **Admin-Panel** — Nutzerverwaltung, Git-Pull-Button, Statistiken
3. **Freundessystem** — Freundschaftsanfragen, Freundesliste, Freunde herausfordern
4. **Branding** — Alles auf "MiChess" umbenennen
5. **Docker NAS-Deployment** — docker-compose für NAS, Auto-Update-Script
6. **DB-Schema Erweiterungen** — admin-Flag, friends-Tabelle, friend_requests-Tabelle
7. **E-Mail-basierte Registrierung** — (email-Feld schon da, braucht UI-Flow: email → unique username wählen)
---
## Architektur-Entscheidungen
### Stockfish im Backend
- Stockfish läuft als Child-Process auf dem Server (NAS)
- Kommunikation über UCI-Protokoll
- Schwierigkeitsgrade via `Skill Level` (0-20) und `Depth`
- Pro aktiver AI-Partie: eigener Stockfish-Prozess (oder Pool)
### Admin-Rolle
- `role` Spalte in `user`-Tabelle (default: 'user', admin: 'admin')
- Erster User mit email aus `ADMIN_EMAIL` env var wird automatisch Admin
- Admin-Middleware schützt `/v1/admin/*` Routen
### Friends-System
- `friends`-Tabelle: user_id_1, user_id_2 (symmetrisch)
- `friend_requests`-Tabelle: from_id, to_id, status (pending/accepted/rejected)
- Nutzer suchen via Username
### Git-Auto-Update (Admin-Feature)
- Admin-Route: `POST /v1/admin/update`
- Führt `git pull` im Container-Verzeichnis aus
- Triggert danach Neustart (via restart-policy des Containers)
- Update-Script wird ins Docker-Image eingebaut
### Docker NAS-Setup
- `docker-compose.yml` mit Services: `michess-app` + `postgres`
- Watchtower oder einfaches update-script für automatische Updates
- Alle Credentials via `.env`-Datei
---
## Aktueller Status
- [x] Basis-Projekt geclont (chessu)
- [x] Brain.md erstellt
- [x] Plan.md erstellt
- [ ] Git remote auf `git.mischlabs.de` gesetzt
- [ ] Projekt auf "MiChess" umbenannt
- [ ] DB-Schema erweitert (admin, friends, friend_requests)
- [ ] Stockfish integriert
- [ ] Admin-Panel gebaut
- [ ] Friends-System gebaut
- [ ] Docker NAS-Deployment finalisiert
- [ ] Alles committed & gepusht
---
## Bekannte Probleme / Offene Fragen
- chessu's Dockerfile nutzt `CMD ["start"]` — muss für MiChess angepasst werden (Server + Client separat oder zusammen)
- Stockfish binary muss im Docker-Image vorhanden sein (Alpine Linux: `apk add stockfish` oder npm-Paket)
- Admin-Git-Pull: funktioniert nur wenn Container read-write Zugriff auf sein eigenes Verzeichnis hat — besser: Host-Script via webhook triggern
---
## Lessons Learned
*(wird im Verlauf befüllt)*
---
## Nächste Schritte (immer aktuell)
1. Git remote setzen → push
2. Umbenennen chessu → MiChess
3. DB-Schema erweitern
4. Stockfish backend einbauen
5. Admin-Panel UI + Backend
6. Friends-System
7. Docker finalisieren
8. Issues aus Git bearbeiten

View File

@@ -4,6 +4,7 @@ import { SessionContext } from "@/context/session";
import { login, register, setGuestSession } from "@/lib/auth"; import { login, register, setGuestSession } from "@/lib/auth";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { API_URL } from "@/config";
import Guest from "./Guest"; import Guest from "./Guest";
import Login from "./Login"; import Login from "./Login";
@@ -14,8 +15,20 @@ export default function AuthModal() {
const [activeTab, setActiveTab] = useState<"guest" | "login" | "register">("guest"); const [activeTab, setActiveTab] = useState<"guest" | "login" | "register">("guest");
const [serverMessage, setServerMessage] = useState<string | null>(null); const [serverMessage, setServerMessage] = useState<string | null>(null);
const [buttonLoading, setButtonLoading] = useState(false); const [buttonLoading, setButtonLoading] = useState(false);
const [ssoEnabled, setSsoEnabled] = useState(false);
const modalToggleRef = useRef<HTMLInputElement>(null); const modalToggleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch(`${API_URL}/v1/auth/sso/config`, { credentials: "include" })
.then((res) => res.json())
.then((data) => {
if (data && data.enabled) {
setSsoEnabled(true);
}
})
.catch((err) => console.error("Error loading SSO config:", err));
}, []);
function closeModal() { function closeModal() {
if (modalToggleRef.current?.checked) { if (modalToggleRef.current?.checked) {
modalToggleRef.current.checked = false; modalToggleRef.current.checked = false;
@@ -151,6 +164,30 @@ export default function AuthModal() {
{activeTab === "register" && "Register"} {activeTab === "register" && "Register"}
</button> </button>
</div> </div>
{activeTab !== "guest" && ssoEnabled && (
<>
<div className="divider opacity-70">oder</div>
<a
href={`${API_URL}/v1/auth/sso/login`}
className="btn btn-accent btn-outline w-full gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<path
fillRule="evenodd"
d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z"
clipRule="evenodd"
/>
</svg>
Login mit Keycloak
</a>
</>
)}
</form> </form>
</label> </label>
</label> </label>

View File

@@ -15,6 +15,11 @@ services:
CORS_ORIGIN: ${CORS_ORIGIN:-https://michess.mischlabs.de} CORS_ORIGIN: ${CORS_ORIGIN:-https://michess.mischlabs.de}
ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-}
APP_DIR: /opt/michess APP_DIR: /opt/michess
SSO_AUTHORITY: ${SSO_AUTHORITY:-}
SSO_CLIENT_ID: ${SSO_CLIENT_ID:-}
SSO_CLIENT_SECRET: ${SSO_CLIENT_SECRET:-}
SSO_REDIRECT_URI: ${SSO_REDIRECT_URI:-}
APP_URL: ${APP_URL:-}
ports: ports:
- "${MICHESS_PORT:-3000}:3000" - "${MICHESS_PORT:-3000}:3000"
- "${MICHESS_API_PORT:-3001}:3001" - "${MICHESS_API_PORT:-3001}:3001"

44
pnpm-lock.yaml generated
View File

@@ -105,6 +105,9 @@ importers:
nanoid: nanoid:
specifier: ^5.0.7 specifier: ^5.0.7
version: 5.1.7 version: 5.1.7
openid-client:
specifier: ^5.6.0
version: 5.7.1
pg: pg:
specifier: ^8.12.0 specifier: ^8.12.0
version: 8.20.0 version: 8.20.0
@@ -482,6 +485,7 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1': '@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@@ -1521,6 +1525,9 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true hasBin: true
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1583,6 +1590,10 @@ packages:
lru-cache@10.4.3: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
map-stream@0.1.0: map-stream@0.1.0:
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
@@ -1715,6 +1726,10 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
object-hash@3.0.0: object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -1747,6 +1762,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
oidc-token-hash@5.2.0:
resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==}
engines: {node: ^10.13.0 || >=12.0.0}
on-finished@2.4.1: on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1758,6 +1777,9 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
openid-client@5.7.1:
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
optionator@0.9.4: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -2549,6 +2571,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@21.1.1: yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -4175,6 +4200,8 @@ snapshots:
jiti@1.21.7: {} jiti@1.21.7: {}
jose@4.15.9: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:
@@ -4231,6 +4258,10 @@ snapshots:
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
map-stream@0.1.0: {} map-stream@0.1.0: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
@@ -4336,6 +4367,8 @@ snapshots:
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-hash@2.2.0: {}
object-hash@3.0.0: {} object-hash@3.0.0: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -4378,6 +4411,8 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
oidc-token-hash@5.2.0: {}
on-finished@2.4.1: on-finished@2.4.1:
dependencies: dependencies:
ee-first: 1.1.1 ee-first: 1.1.1
@@ -4388,6 +4423,13 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
openid-client@5.7.1:
dependencies:
jose: 4.15.9
lru-cache: 6.0.0
object-hash: 2.2.0
oidc-token-hash: 5.2.0
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
@@ -5245,6 +5287,8 @@ snapshots:
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@4.0.0: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yargs@17.7.2: yargs@17.7.2:

View File

@@ -20,7 +20,8 @@
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"pg": "^8.12.0", "pg": "^8.12.0",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"xss": "^1.0.15" "xss": "^1.0.15",
"openid-client": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@michess/types": "workspace:*", "@michess/types": "workspace:*",

View File

@@ -2,7 +2,10 @@ import type { User } from "@michess/types";
import { hash, verify } from "argon2"; import { hash, verify } from "argon2";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import xss from "xss"; 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 { activeGames } from "../db/models/game.model.js";
import UserModel from "../db/models/user.model.js"; import UserModel from "../db/models/user.model.js";
import { io } from "../server.js"; import { io } from "../server.js";
@@ -317,3 +320,123 @@ export const updateUser = async (req: Request, res: Response) => {
res.status(500).end(); 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);
}
};

View File

@@ -14,4 +14,9 @@ router.route("/logout").post(controller.logoutSession);
router.route("/register").post(controller.registerUser); router.route("/register").post(controller.registerUser);
router.route("/login").post(controller.loginUser); router.route("/login").post(controller.loginUser);
// SSO Keycloak
router.route("/sso/config").get(controller.getSsoConfig);
router.route("/sso/login").get(controller.ssoLogin);
router.route("/sso/callback").get(controller.ssoCallback);
export default router; export default router;