fix: convert empty select values to null before submitting
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
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
This commit is contained in:
584
docs/plans/2026-03-01-keycloak-sso.md
Normal file
584
docs/plans/2026-03-01-keycloak-sso.md
Normal file
@@ -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 (
|
||||||
|
<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**
|
||||||
|
|
||||||
|
```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=<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**
|
||||||
|
|
||||||
|
```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
|
||||||
@@ -38,7 +38,13 @@ export default function EntityForm({ open, title, fields, initialValues, onClose
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSubmitting(true)
|
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 (
|
return (
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ keycloak:
|
|||||||
redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback
|
redirectUri: https://shorefront.baumann.gr/api/auth/oidc/callback
|
||||||
|
|
||||||
containers:
|
containers:
|
||||||
version: "0.009"
|
version: "0.010"
|
||||||
|
|||||||
Reference in New Issue
Block a user