Compare commits

..

8 Commits

Author SHA1 Message Date
1daa6f6e90 feat(sso): replace local auth with Keycloak OIDC callback flow 2026-03-01 00:51:14 +01:00
f28240c37f feat(sso): update User model and schemas for Keycloak 2026-03-01 00:50:44 +01:00
95fbe99b61 feat(sso): migration — add keycloak_sub, make hashed_password nullable 2026-03-01 00:50:20 +01:00
ff4aa155d1 feat(sso): add KEYCLOAK_CLIENT_SECRET to secrets script and backend deployment 2026-03-01 00:45:37 +01:00
924e51ffaa feat(sso): add Keycloak settings to database.py and Helm ConfigMap 2026-03-01 00:45:07 +01:00
58f0fd50d8 feat(sso): replace passlib/bcrypt with authlib + httpx 2026-03-01 00:44:18 +01:00
40113bc634 docs: add Keycloak SSO integration design 2026-03-01 00:37:49 +01:00
4c4cdf0a52 fix: route all traffic through nginx; remove direct /api->backend ingress rule
Traefik forwards /api/auth/login to the backend verbatim, causing 404.
Nginx already strips the /api prefix correctly via proxy_pass with trailing
slash. Routing everything through frontend/nginx avoids the double-routing
and the need for a StripPrefix middleware.
2026-03-01 00:12:33 +01:00
14 changed files with 232 additions and 59 deletions

View File

@@ -0,0 +1,25 @@
"""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")

View File

@@ -1,43 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status
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, hash_password, verify_password
from app.database import get_db
from app.auth import create_access_token, get_current_user, oauth
from app.database import get_db, settings
router = APIRouter()
@router.post("/register", response_model=schemas.UserOut, status_code=201)
def register(body: schemas.UserCreate, db: Session = Depends(get_db)) -> models.User:
if db.query(models.User).filter(models.User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already registered")
if db.query(models.User).filter(models.User.email == body.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = models.User(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
FIREWALL_ADMINS_GROUP = "firewall admins"
@router.post("/login")
def login(body: schemas.LoginRequest, response: Response, db: Session = Depends(get_db)) -> dict:
user = db.query(models.User).filter(models.User.username == body.username).first()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(user.id)
@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=token,
value=access_token,
httponly=True,
samesite="lax",
max_age=3600,
)
return {"message": "Logged in"}
return response
@router.post("/logout")

View File

@@ -1,21 +1,23 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
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
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
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:

View File

@@ -8,6 +8,11 @@ class Settings(BaseSettings):
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"

View File

@@ -1,9 +1,12 @@
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"],

View File

@@ -12,7 +12,8 @@ class User(Base):
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] = mapped_column(String(255), 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")

View File

@@ -1,15 +1,9 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel
# --- Auth ---
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserOut(BaseModel):
id: int
username: str
@@ -19,11 +13,6 @@ class UserOut(BaseModel):
model_config = {"from_attributes": True}
class LoginRequest(BaseModel):
username: str
password: str
# --- Config ---
class ConfigCreate(BaseModel):
name: str

View File

@@ -4,8 +4,9 @@ sqlalchemy==2.0.30
alembic==1.13.1
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt<4.0.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

View File

@@ -0,0 +1,75 @@
# Keycloak SSO Integration Design
**Goal:** Replace local username/password auth with Keycloak OIDC using a backend callback flow.
**Architecture:** Backend acts as OIDC confidential client. Browser is redirected to Keycloak, which redirects back to a backend callback endpoint. Backend validates the token, checks group membership, provisions the user, and issues its own httpOnly JWT cookie. Frontend is unchanged except the login page.
**Tech Stack:** authlib (OIDC), FastAPI, Alembic, React
---
## Auth Flow
```
Browser → GET /api/auth/oidc/login
→ backend generates state, stores in short-lived cookie, redirects to Keycloak
Keycloak → user authenticates → redirects to:
GET /api/auth/oidc/callback?code=...&state=...
Backend:
1. Validates state cookie (CSRF protection)
2. Exchanges code for tokens via Keycloak token endpoint
3. Validates ID token signature via Keycloak JWKS (authlib handles this)
4. Checks groups claim for "firewall admins" → 403 if absent
5. Looks up user by keycloak_sub → auto-provisions row if first login
6. Issues httpOnly JWT cookie (same mechanism as before)
7. Redirects browser to /
```
Removed endpoints: `POST /auth/login`, `POST /auth/register`
Kept endpoints: `GET /auth/me`, `POST /auth/logout`
## Data Model
New Alembic migration:
- Add `keycloak_sub VARCHAR(255) UNIQUE` to `users` table
- Make `hashed_password` nullable (always NULL for SSO users; kept for schema stability)
## Configuration
**ConfigMap** (non-secret):
- `KEYCLOAK_URL`: `https://sso.baumann.gr`
- `KEYCLOAK_REALM`: `homelab`
- `KEYCLOAK_CLIENT_ID`: `shorefront`
**Secret** (added to `scripts/create-secrets.sh`):
- `KEYCLOAK_CLIENT_SECRET`
**Redirect URI** (backend callback, registered in Keycloak):
- `https://shorefront.baumann.gr/api/auth/oidc/callback`
## Backend Changes
- Add `authlib` + `httpx` to `requirements.txt`
- Add `keycloak_url`, `keycloak_realm`, `keycloak_client_id`, `keycloak_client_secret` to `Settings`
- Add `keycloak_sub` column to `User` model
- New migration: add `keycloak_sub`, make `hashed_password` nullable
- Replace `backend/app/api/auth.py` with OIDC endpoints:
- `GET /auth/oidc/login` — generate state, redirect to Keycloak
- `GET /auth/oidc/callback` — exchange code, validate token, check group, provision user, set cookie, redirect
- Keep `POST /auth/logout`, `GET /auth/me`
- Remove `hash_password`, `verify_password` from `auth.py`
## Frontend Changes
- `Login.tsx`: replace username/password form with a single "Sign in with SSO" button (`window.location.href = '/api/auth/oidc/login'`)
- All other components unchanged
## Keycloak Manual Setup (pre-deploy)
1. Create client `shorefront`, access type: confidential
2. Set Valid Redirect URIs: `https://shorefront.baumann.gr/api/auth/oidc/callback`
3. Set Web Origins: `https://shorefront.baumann.gr`
4. Add Group Membership mapper on client: include groups in ID token, claim name `groups`
5. Create group `firewall admins`, add users to it

View File

@@ -33,6 +33,31 @@ spec:
key: JWT_SECRET_KEY
- name: DATABASE_URL
value: "postgresql://{{ .Values.postgres.user }}:$(POSTGRES_PASSWORD)@postgres:5432/{{ .Values.postgres.database }}"
- 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
containers:
- name: backend
image: "{{ .Values.backend.image }}:{{ .Values.containers.version }}"
@@ -60,6 +85,31 @@ spec:
configMapKeyRef:
name: shorefront-config
key: JWT_EXPIRE_MINUTES
- 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
ports:
- containerPort: 8000
resources:

View File

@@ -10,3 +10,7 @@ data:
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 }}

View File

@@ -13,13 +13,6 @@ spec:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: backend
port:
number: 8000
- path: /
pathType: Prefix
backend:

View File

@@ -35,5 +35,11 @@ ingress:
host: shorefront.baumann.gr
ingressClassName: traefik
keycloak:
url: https://sso.baumann.gr
realm: homelab
clientId: shorefront
redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback
containers:
version: "0.002"

View File

@@ -12,6 +12,7 @@ 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 -
@@ -21,6 +22,7 @@ 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}'."