Files
shorefront/docs/plans/2026-03-01-keycloak-sso.md
Adrian A. Baumann 08dddb7297
All checks were successful
Build containers when image tags change / build-if-image-changed (backend, shorefront-backend, shorefront backend, backend/Dockerfile, git.baumann.gr/adebaumann/shorefront-backend, .backend.image) (push) Successful in 32s
Build containers when image tags change / build-if-image-changed (frontend, shorefront-frontend, shorefront frontend, frontend/Dockerfile, git.baumann.gr/adebaumann/shorefront-frontend, .frontend.image) (push) Successful in 1m27s
fix: convert empty select values to null before submitting
2026-03-01 11:37:25 +01:00

17 KiB

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

cat backend/requirements.txt

Step 3: Commit

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:

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:

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:):

keycloak:
  url: https://sso.baumann.gr
  realm: homelab
  clientId: shorefront
  redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback

Step 4: Commit

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:

#!/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:

            - 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

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:

"""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

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:

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

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:

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:

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:

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

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:

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 (
    <Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: '#f5f7fa' }}>
      <Card sx={{ width: 380, boxShadow: 3 }}>
        <CardContent sx={{ p: 4 }}>
          <Typography variant="h5" fontWeight={700} gutterBottom>Shorefront</Typography>
          <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>Sign in to manage your Shorewall configs</Typography>
          <Button
            variant="contained"
            fullWidth
            onClick={() => { window.location.href = '/api/auth/oidc/login' }}
          >
            Sign in with SSO
          </Button>
        </CardContent>
      </Card>
    </Box>
  )
}

Step 2: Commit

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:

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

export POSTGRES_PASSWORD=<existing-password>
export JWT_SECRET_KEY=<existing-key>
export KEYCLOAK_CLIENT_SECRET=<from-keycloak-client-credentials-tab>
bash scripts/create-secrets.sh

Step 3: Commit

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