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"