# 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