From 08dddb72976ac02d16767dada26755eeb5f8ca7d Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Sun, 1 Mar 2026 11:35:53 +0100 Subject: [PATCH] fix: convert empty select values to null before submitting --- docs/plans/2026-03-01-keycloak-sso.md | 584 +++++++++++++++++++++++++ frontend/src/components/EntityForm.tsx | 8 +- helm/shorefront/values.yaml | 2 +- 3 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-03-01-keycloak-sso.md diff --git a/docs/plans/2026-03-01-keycloak-sso.md b/docs/plans/2026-03-01-keycloak-sso.md new file mode 100644 index 0000000..ec4e357 --- /dev/null +++ b/docs/plans/2026-03-01-keycloak-sso.md @@ -0,0 +1,584 @@ +# Keycloak SSO Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace local username/password auth with Keycloak OIDC using a backend callback flow. + +**Architecture:** Backend acts as confidential OIDC client using `authlib`. On login, backend redirects browser to Keycloak; Keycloak redirects back to `/auth/oidc/callback`; backend validates the token, checks the `firewall admins` group claim, auto-provisions the user in the `users` table on first login, then issues its own httpOnly JWT cookie. The frontend only changes the login page. + +**Tech Stack:** authlib, httpx, FastAPI, Alembic, React/MUI + +--- + +### Before starting: Keycloak manual setup + +These steps must be done in Keycloak at `https://sso.baumann.gr` before the code is deployed: + +1. Clients → Create client + - Client ID: `shorefront` + - Client authentication: ON (confidential) + - Valid redirect URIs: `https://shorefront.baumann.gr/api/auth/oidc/callback` + - Web Origins: `https://shorefront.baumann.gr` +2. On the client's **Credentials** tab: copy the client secret (needed for `KEYCLOAK_CLIENT_SECRET`) +3. On the client's **Client scopes** tab → add mapper: + - Type: Group Membership + - Token Claim Name: `groups` + - Add to ID token: ON + - Full group path: OFF +4. Create group `firewall admins` and add your user to it + +--- + +### Task 1: Update dependencies + +**Files:** +- Modify: `backend/requirements.txt` + +**Step 1: Replace passlib/bcrypt with authlib + httpx** + +Replace the content of `backend/requirements.txt` with: + +``` +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +sqlalchemy==2.0.30 +alembic==1.13.1 +psycopg2-binary==2.9.9 +python-jose[cryptography]==3.3.0 +python-multipart==0.0.9 +pydantic[email]==2.7.1 +pydantic-settings==2.2.1 +authlib==1.3.1 +httpx==0.27.0 +itsdangerous==2.2.0 +``` + +Removed: `passlib[bcrypt]`, `bcrypt<4.0.0` +Added: `authlib`, `httpx`, `itsdangerous` (required by Starlette SessionMiddleware) + +**Step 2: Verify the file looks correct** + +```bash +cat backend/requirements.txt +``` + +**Step 3: Commit** + +```bash +git add backend/requirements.txt +git commit -m "feat(sso): replace passlib/bcrypt with authlib + httpx" +``` + +--- + +### Task 2: Add Keycloak settings and ConfigMap + +**Files:** +- Modify: `backend/app/database.py` +- Modify: `helm/shorefront/templates/configmap.yaml` +- Modify: `helm/shorefront/values.yaml` + +**Step 1: Update `backend/app/database.py` — add Keycloak fields to Settings** + +Replace the `Settings` class: + +```python +class Settings(BaseSettings): + database_url: str + jwt_secret_key: str + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 60 + keycloak_url: str + keycloak_realm: str + keycloak_client_id: str + keycloak_client_secret: str + keycloak_redirect_uri: str + + class Config: + env_file = ".env" +``` + +**Step 2: Update `helm/shorefront/templates/configmap.yaml` — add Keycloak non-secret config** + +Replace the file content with: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: shorefront-config + namespace: {{ .Values.namespace }} + labels: + {{- include "shorefront.labels" . | nindent 4 }} +data: + POSTGRES_DB: {{ .Values.postgres.database | quote }} + POSTGRES_USER: {{ .Values.postgres.user | quote }} + JWT_ALGORITHM: "HS256" + JWT_EXPIRE_MINUTES: "60" + KEYCLOAK_URL: {{ .Values.keycloak.url | quote }} + KEYCLOAK_REALM: {{ .Values.keycloak.realm | quote }} + KEYCLOAK_CLIENT_ID: {{ .Values.keycloak.clientId | quote }} + KEYCLOAK_REDIRECT_URI: {{ .Values.keycloak.redirectUri | quote }} +``` + +**Step 3: Update `helm/shorefront/values.yaml` — add keycloak block** + +Add after the `ingress:` block (before `containers:`): + +```yaml +keycloak: + url: https://sso.baumann.gr + realm: homelab + clientId: shorefront + redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback +``` + +**Step 4: Commit** + +```bash +git add backend/app/database.py helm/shorefront/templates/configmap.yaml helm/shorefront/values.yaml +git commit -m "feat(sso): add Keycloak settings to database.py and Helm ConfigMap" +``` + +--- + +### Task 3: Add KEYCLOAK_CLIENT_SECRET to secrets script and deployments + +**Files:** +- Modify: `scripts/create-secrets.sh` +- Modify: `helm/shorefront/templates/backend-deployment.yaml` + +**Step 1: Update `scripts/create-secrets.sh` — add KEYCLOAK_CLIENT_SECRET** + +Replace the existing script content with: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +NAMESPACE="shorefront" + +# --- Preflight checks --- +if ! command -v kubectl &>/dev/null; then + echo "Error: kubectl is not installed or not in PATH" >&2 + exit 1 +fi + +# --- Validate required env vars --- +: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}" +: "${JWT_SECRET_KEY:?JWT_SECRET_KEY is required}" +: "${KEYCLOAK_CLIENT_SECRET:?KEYCLOAK_CLIENT_SECRET is required}" + +echo "Creating namespace '${NAMESPACE}' if it does not exist..." +kubectl create namespace "${NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - + +echo "Creating/updating secret 'shorefront-secret' in namespace '${NAMESPACE}'..." +kubectl create secret generic shorefront-secret \ + --namespace "${NAMESPACE}" \ + --from-literal="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" \ + --from-literal="JWT_SECRET_KEY=${JWT_SECRET_KEY}" \ + --from-literal="KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}" \ + --dry-run=client -o yaml | kubectl apply -f - + +echo "Done. Secret 'shorefront-secret' is ready in namespace '${NAMESPACE}'." +``` + +**Step 2: Update `helm/shorefront/templates/backend-deployment.yaml` — add Keycloak env vars to both init container and main container** + +In the `migrate` init container, after the `DATABASE_URL` env var, add: + +```yaml + - name: KEYCLOAK_URL + valueFrom: + configMapKeyRef: + name: shorefront-config + key: KEYCLOAK_URL + - name: KEYCLOAK_REALM + valueFrom: + configMapKeyRef: + name: shorefront-config + key: KEYCLOAK_REALM + - name: KEYCLOAK_CLIENT_ID + valueFrom: + configMapKeyRef: + name: shorefront-config + key: KEYCLOAK_CLIENT_ID + - name: KEYCLOAK_REDIRECT_URI + valueFrom: + configMapKeyRef: + name: shorefront-config + key: KEYCLOAK_REDIRECT_URI + - name: KEYCLOAK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: shorefront-secret + key: KEYCLOAK_CLIENT_SECRET +``` + +Add the same 5 env vars to the main `backend` container (after `JWT_EXPIRE_MINUTES`). + +**Step 3: Commit** + +```bash +git add scripts/create-secrets.sh helm/shorefront/templates/backend-deployment.yaml +git commit -m "feat(sso): add KEYCLOAK_CLIENT_SECRET to secrets script and backend deployment" +``` + +--- + +### Task 4: Alembic migration — add keycloak_sub, make hashed_password nullable + +**Files:** +- Create: `backend/alembic/versions/0002_keycloak_sso.py` + +**Step 1: Create the migration file** + +Create `backend/alembic/versions/0002_keycloak_sso.py`: + +```python +"""add keycloak_sub, make hashed_password nullable + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-03-01 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("keycloak_sub", sa.String(255), nullable=True)) + op.create_unique_constraint("uq_users_keycloak_sub", "users", ["keycloak_sub"]) + op.alter_column("users", "hashed_password", nullable=True) + + +def downgrade() -> None: + op.alter_column("users", "hashed_password", nullable=False) + op.drop_constraint("uq_users_keycloak_sub", "users", type_="unique") + op.drop_column("users", "keycloak_sub") +``` + +**Step 2: Commit** + +```bash +git add backend/alembic/versions/0002_keycloak_sso.py +git commit -m "feat(sso): migration — add keycloak_sub, make hashed_password nullable" +``` + +--- + +### Task 5: Update User model and schemas + +**Files:** +- Modify: `backend/app/models.py` +- Modify: `backend/app/schemas.py` + +**Step 1: Update `backend/app/models.py` — add keycloak_sub, make hashed_password optional** + +Replace the `User` class: + +```python +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) + keycloak_sub: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + configs: Mapped[list["Config"]] = relationship("Config", back_populates="owner") +``` + +**Step 2: Update `backend/app/schemas.py` — remove UserCreate and LoginRequest** + +Remove the `UserCreate` and `LoginRequest` classes entirely (lines 7-24). Keep `UserOut` unchanged. + +**Step 3: Commit** + +```bash +git add backend/app/models.py backend/app/schemas.py +git commit -m "feat(sso): update User model and schemas for Keycloak" +``` + +--- + +### Task 6: Replace auth.py and api/auth.py with OIDC implementation + +**Files:** +- Modify: `backend/app/auth.py` +- Modify: `backend/app/api/auth.py` +- Modify: `backend/app/main.py` + +**Step 1: Replace `backend/app/auth.py`** + +Replace the entire file: + +```python +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import JWTError, jwt +from authlib.integrations.starlette_client import OAuth +from fastapi import Cookie, HTTPException, status, Depends +from sqlalchemy.orm import Session +from app.database import get_db, settings +from app import models + +oauth = OAuth() +oauth.register( + name="keycloak", + client_id=settings.keycloak_client_id, + client_secret=settings.keycloak_client_secret, + server_metadata_url=( + f"{settings.keycloak_url}/realms/{settings.keycloak_realm}" + "/.well-known/openid-configuration" + ), + client_kwargs={"scope": "openid email profile"}, +) + + +def create_access_token(user_id: int) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + return jwt.encode( + {"sub": str(user_id), "exp": expire}, + settings.jwt_secret_key, + algorithm=settings.jwt_algorithm, + ) + + +def get_current_user( + access_token: Optional[str] = Cookie(default=None), + db: Session = Depends(get_db), +) -> models.User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + if not access_token: + raise credentials_exception + try: + payload = jwt.decode(access_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = db.get(models.User, int(user_id)) + if user is None or not user.is_active: + raise credentials_exception + return user +``` + +**Step 2: Replace `backend/app/api/auth.py`** + +Replace the entire file: + +```python +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from authlib.integrations.starlette_client import OAuthError +from app import models, schemas +from app.auth import create_access_token, get_current_user, oauth +from app.database import get_db, settings + +router = APIRouter() + +FIREWALL_ADMINS_GROUP = "firewall admins" + + +@router.get("/oidc/login") +async def oidc_login(request: Request) -> RedirectResponse: + return await oauth.keycloak.authorize_redirect(request, settings.keycloak_redirect_uri) + + +@router.get("/oidc/callback") +async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> RedirectResponse: + try: + token = await oauth.keycloak.authorize_access_token(request) + except OAuthError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + userinfo = token.get("userinfo") or {} + groups = userinfo.get("groups", []) + if FIREWALL_ADMINS_GROUP not in groups: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not in firewall admins group") + + sub = userinfo["sub"] + email = userinfo.get("email", "") + username = userinfo.get("preferred_username", sub) + + user = db.query(models.User).filter(models.User.keycloak_sub == sub).first() + if not user: + user = models.User( + keycloak_sub=sub, + email=email, + username=username, + hashed_password=None, + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + + access_token = create_access_token(user.id) + response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + samesite="lax", + max_age=3600, + ) + return response + + +@router.post("/logout") +def logout(response: Response) -> dict: + response.delete_cookie("access_token") + return {"message": "Logged out"} + + +@router.get("/me", response_model=schemas.UserOut) +def me(current_user: models.User = Depends(get_current_user)) -> models.User: + return current_user +``` + +**Step 3: Update `backend/app/main.py` — add SessionMiddleware** + +Add the SessionMiddleware import and registration. The final file: + +```python +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware +from app.api import auth, configs, zones, interfaces, policies, rules, masq +from app.database import settings + +app = FastAPI(title="Shorefront", version="0.1.0") + +app.add_middleware(SessionMiddleware, secret_key=settings.jwt_secret_key) +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:80"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix="/auth", tags=["auth"]) +app.include_router(configs.router, prefix="/configs", tags=["configs"]) +app.include_router(zones.router, prefix="/configs", tags=["zones"]) +app.include_router(interfaces.router, prefix="/configs", tags=["interfaces"]) +app.include_router(policies.router, prefix="/configs", tags=["policies"]) +app.include_router(rules.router, prefix="/configs", tags=["rules"]) +app.include_router(masq.router, prefix="/configs", tags=["masq"]) + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} +``` + +**Step 4: Commit** + +```bash +git add backend/app/auth.py backend/app/api/auth.py backend/app/main.py +git commit -m "feat(sso): replace local auth with Keycloak OIDC callback flow" +``` + +--- + +### Task 7: Update frontend Login page + +**Files:** +- Modify: `frontend/src/routes/Login.tsx` + +**Step 1: Replace `frontend/src/routes/Login.tsx`** + +Replace the entire file: + +```tsx +import Box from '@mui/material/Box' +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' + +export default function Login() { + return ( + + + + Shorefront + Sign in to manage your Shorewall configs + + + + + ) +} +``` + +**Step 2: Commit** + +```bash +git add frontend/src/routes/Login.tsx +git commit -m "feat(sso): replace login form with SSO redirect button" +``` + +--- + +### Task 8: Bump container version and update secrets + +**Files:** +- Modify: `helm/shorefront/values.yaml` + +**Step 1: Bump `containers.version`** + +In `helm/shorefront/values.yaml`, update: + +```yaml +containers: + version: "0.003" +``` + +(or whatever the next version number is — check current value first with `grep version helm/shorefront/values.yaml`) + +**Step 2: Re-run create-secrets.sh with the new variable** + +```bash +export POSTGRES_PASSWORD= +export JWT_SECRET_KEY= +export KEYCLOAK_CLIENT_SECRET= +bash scripts/create-secrets.sh +``` + +**Step 3: Commit** + +```bash +git add helm/shorefront/values.yaml +git commit -m "feat(sso): bump container version for SSO release" +``` + +--- + +### Verification (after deployment) + +1. Visit `https://shorefront.baumann.gr` — should redirect to login page +2. Click "Sign in with SSO" — should redirect to `https://sso.baumann.gr/realms/homelab/...` +3. Log in with a user in the `firewall admins` group — should redirect back and land on `/configs` +4. Log in with a user NOT in `firewall admins` — should get a 403 error +5. Check database: `SELECT id, username, email, keycloak_sub, hashed_password FROM users;` — should show new row with `keycloak_sub` set and `hashed_password` NULL diff --git a/frontend/src/components/EntityForm.tsx b/frontend/src/components/EntityForm.tsx index 483d5d0..b57f4ef 100644 --- a/frontend/src/components/EntityForm.tsx +++ b/frontend/src/components/EntityForm.tsx @@ -38,7 +38,13 @@ export default function EntityForm({ open, title, fields, initialValues, onClose const handleSubmit = async () => { setSubmitting(true) - try { await onSubmit(values) } finally { setSubmitting(false) } + const submitted = Object.fromEntries( + Object.entries(values).map(([k, v]) => { + const field = fields.find((f) => f.name === k) + return [k, field?.type === 'select' && v === '' ? null : v] + }) + ) + try { await onSubmit(submitted) } finally { setSubmitting(false) } } return ( diff --git a/helm/shorefront/values.yaml b/helm/shorefront/values.yaml index 7cc8fac..a8cce1b 100644 --- a/helm/shorefront/values.yaml +++ b/helm/shorefront/values.yaml @@ -42,4 +42,4 @@ keycloak: redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback containers: - version: "0.009" + version: "0.010"