Replace pal-e-auth with Keycloak OIDC JWT validation #77

Closed
opened 2026-03-14 16:30:07 +00:00 by forgejo_admin · 0 comments

Lineage

plan-2026-03-08-tryout-prep → Phase 5 → Phase 5c (basketball-api OIDC)

Repo

forgejo_admin/basketball-api

User Story

As a platform operator
I want basketball-api to validate Keycloak-issued JWTs instead of using the custom pal-e-auth library
So that the auth chain is unified through Keycloak IdP and we can remove the custom auth dependency

Context

Keycloak IdP is deployed at keycloak.tail5b443a.ts.net with realm westside-basketball. It issues standard OIDC JWTs with roles in realm_access.roles. The custom pal-e-auth-ldraney library wraps Google OAuth + custom JWT issuance — this is being replaced entirely. Keycloak handles authentication; basketball-api only needs to validate incoming JWTs via the JWKS endpoint.

The existing User type from pal_e_auth and require_role() function are used in 3 route files + 2 test files. The new auth module must provide the same interface so route files need minimal changes (just import path).

Architecture decision: No client secret needed in basketball-api. It validates tokens via Keycloak's public JWKS endpoint. Only westside-app (the OIDC Relying Party) needs the client secret.

File Targets

Files to modify or create:

  • src/basketball_api/auth.pyCREATE: new auth module with User dataclass, get_current_user() FastAPI dependency, require_role() factory
  • src/basketball_api/main.py — remove pal_e_auth imports (AuthConfig, auth_router), remove _build_auth_config(), remove auth_router(_auth_config), remove app.state.auth_config. Keep lifespan for Stripe.
  • src/basketball_api/config.py — replace jwt_secret_key, google_client_id, google_client_secret with keycloak_realm_url (default: http://keycloak.keycloak.svc.cluster.local:80/realms/westside-basketball)
  • src/basketball_api/routes/admin.py — change from pal_e_auth import User, require_role to from basketball_api.auth import User, require_role
  • src/basketball_api/routes/roster.py — same import change
  • src/basketball_api/routes/tryouts.py — same import change
  • pyproject.toml — remove pal-e-auth-ldraney>=0.1.0, add python-jose[cryptography]>=3.3 and httpx>=0.27 (httpx already in dev deps, move to main)
  • k8s/deployment.yaml — replace Google OAuth env vars with BASKETBALL_KEYCLOAK_REALM_URL
  • tests/conftest.py — remove BASKETBALL_JWT_SECRET_KEY, BASKETBALL_GOOGLE_CLIENT_ID, BASKETBALL_GOOGLE_CLIENT_SECRET env setup. Add JWKS mock setup.
  • tests/test_admin.py — change from pal_e_auth import User to from basketball_api.auth import User
  • tests/test_tryouts.py — same import change

Files NOT to touch:

  • src/basketball_api/routes/register.py — no auth
  • src/basketball_api/routes/health.py — no auth
  • src/basketball_api/routes/webhooks.py — Stripe webhook verification, not JWT auth
  • src/basketball_api/routes/coach.py — check if it uses auth, modify only if needed
  • Public endpoints in tryouts.py (/api/roster/*, /tryouts/roster/*, /pay) — no auth, don't add any

New auth.py Design

"""Keycloak OIDC JWT validation for FastAPI."""
import httpx
from dataclasses import dataclass
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from basketball_api.config import settings

_security = HTTPBearer(auto_error=False)
_jwks_cache: dict | None = None

@dataclass
class User:
    sub: str
    email: str | None
    username: str | None
    roles: list[str]

async def _get_jwks() -> dict:
    """Fetch and cache Keycloak JWKS."""
    global _jwks_cache
    if _jwks_cache is None:
        url = f"{settings.keycloak_realm_url}/protocol/openid-connect/certs"
        async with httpx.AsyncClient() as client:
            resp = await client.get(url)
            resp.raise_for_status()
            _jwks_cache = resp.json()
    return _jwks_cache

async def get_current_user(
    credentials: HTTPAuthorizationCredentials | None = Depends(_security),
) -> User:
    """Validate JWT and return User. Raises 401 if invalid."""
    if credentials is None:
        raise HTTPException(status_code=401, detail="Not authenticated")
    token = credentials.credentials
    try:
        jwks = await _get_jwks()
        header = jwt.get_unverified_header(token)
        key = next(k for k in jwks["keys"] if k["kid"] == header["kid"])
        payload = jwt.decode(
            token, key, algorithms=["RS256"],
            audience="account",
            options={"verify_aud": False},  # Keycloak audience varies
        )
    except (JWTError, StopIteration, KeyError, httpx.HTTPError) as e:
        raise HTTPException(status_code=401, detail="Invalid token") from e
    
    return User(
        sub=payload["sub"],
        email=payload.get("email"),
        username=payload.get("preferred_username"),
        roles=payload.get("realm_access", {}).get("roles", []),
    )

def require_role(*allowed_roles: str):
    """Factory: returns a FastAPI dependency that checks role membership."""
    async def _check(user: User = Depends(get_current_user)) -> User:
        if not any(r in user.roles for r in allowed_roles):
            raise HTTPException(status_code=403, detail="Insufficient role")
        return user
    return _check

Acceptance Criteria

  • pal-e-auth-ldraney is removed from dependencies and not imported anywhere
  • Protected endpoints (/admin/*, /tenants/*/roster, POST /tryouts/admin/*/checkin/*) return 401 without a valid JWT
  • Protected endpoints return 403 with a valid JWT but wrong role
  • Protected endpoints work with a valid Keycloak JWT containing the required role
  • Public endpoints (/api/roster/*, /tryouts/roster/*, /healthz, /pay) work without any auth header
  • k8s deployment uses BASKETBALL_KEYCLOAK_REALM_URL instead of Google OAuth env vars
  • pal-e-auth-secrets k8s Secret reference is removed from deployment

Test Expectations

  • Unit test: mock JWKS response, verify get_current_user extracts roles correctly
  • Unit test: verify require_role("admin") returns 403 for user with only player role
  • Unit test: verify 401 returned when no Authorization header present
  • Existing admin/tryout tests should pass with updated auth mock
  • Run command: pytest tests/ -v

Constraints

  • The new User dataclass must have a roles field (list of strings) — used by require_role
  • Keep the same require_role("admin") / require_role("admin", "coach") call signatures
  • Module-level require_admin = require_role("admin") pattern must still work (used in admin.py and tryouts.py)
  • JWKS should be fetched lazily and cached (don't hit Keycloak on every request)
  • The get_current_user dependency should be async (Keycloak JWKS fetch is async)
  • Route handler functions that use Depends(require_admin) may need to become async if they aren't already — check and convert as needed
  • Note: moving from sync to async route handlers in FastAPI is safe — FastAPI handles both

Checklist

  • PR opened
  • Tests pass
  • No unrelated changes
  • plan-2026-03-08-tryout-prep — parent plan
  • Phase 5b (Keycloak realm config) — completed, realm westside-basketball is live
  • Phase 5d (westside-app login) — parallel work, separate repo
### Lineage `plan-2026-03-08-tryout-prep` → Phase 5 → Phase 5c (basketball-api OIDC) ### Repo `forgejo_admin/basketball-api` ### User Story As a platform operator I want basketball-api to validate Keycloak-issued JWTs instead of using the custom pal-e-auth library So that the auth chain is unified through Keycloak IdP and we can remove the custom auth dependency ### Context Keycloak IdP is deployed at `keycloak.tail5b443a.ts.net` with realm `westside-basketball`. It issues standard OIDC JWTs with roles in `realm_access.roles`. The custom `pal-e-auth-ldraney` library wraps Google OAuth + custom JWT issuance — this is being replaced entirely. Keycloak handles authentication; basketball-api only needs to **validate** incoming JWTs via the JWKS endpoint. The existing `User` type from pal_e_auth and `require_role()` function are used in 3 route files + 2 test files. The new auth module must provide the same interface so route files need minimal changes (just import path). **Architecture decision:** No client secret needed in basketball-api. It validates tokens via Keycloak's public JWKS endpoint. Only westside-app (the OIDC Relying Party) needs the client secret. ### File Targets Files to modify or create: - `src/basketball_api/auth.py` — **CREATE**: new auth module with `User` dataclass, `get_current_user()` FastAPI dependency, `require_role()` factory - `src/basketball_api/main.py` — remove `pal_e_auth` imports (`AuthConfig`, `auth_router`), remove `_build_auth_config()`, remove `auth_router(_auth_config)`, remove `app.state.auth_config`. Keep lifespan for Stripe. - `src/basketball_api/config.py` — replace `jwt_secret_key`, `google_client_id`, `google_client_secret` with `keycloak_realm_url` (default: `http://keycloak.keycloak.svc.cluster.local:80/realms/westside-basketball`) - `src/basketball_api/routes/admin.py` — change `from pal_e_auth import User, require_role` to `from basketball_api.auth import User, require_role` - `src/basketball_api/routes/roster.py` — same import change - `src/basketball_api/routes/tryouts.py` — same import change - `pyproject.toml` — remove `pal-e-auth-ldraney>=0.1.0`, add `python-jose[cryptography]>=3.3` and `httpx>=0.27` (httpx already in dev deps, move to main) - `k8s/deployment.yaml` — replace Google OAuth env vars with `BASKETBALL_KEYCLOAK_REALM_URL` - `tests/conftest.py` — remove `BASKETBALL_JWT_SECRET_KEY`, `BASKETBALL_GOOGLE_CLIENT_ID`, `BASKETBALL_GOOGLE_CLIENT_SECRET` env setup. Add JWKS mock setup. - `tests/test_admin.py` — change `from pal_e_auth import User` to `from basketball_api.auth import User` - `tests/test_tryouts.py` — same import change Files NOT to touch: - `src/basketball_api/routes/register.py` — no auth - `src/basketball_api/routes/health.py` — no auth - `src/basketball_api/routes/webhooks.py` — Stripe webhook verification, not JWT auth - `src/basketball_api/routes/coach.py` — check if it uses auth, modify only if needed - Public endpoints in tryouts.py (`/api/roster/*`, `/tryouts/roster/*`, `/pay`) — no auth, don't add any ### New auth.py Design ```python """Keycloak OIDC JWT validation for FastAPI.""" import httpx from dataclasses import dataclass from fastapi import Depends, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt from basketball_api.config import settings _security = HTTPBearer(auto_error=False) _jwks_cache: dict | None = None @dataclass class User: sub: str email: str | None username: str | None roles: list[str] async def _get_jwks() -> dict: """Fetch and cache Keycloak JWKS.""" global _jwks_cache if _jwks_cache is None: url = f"{settings.keycloak_realm_url}/protocol/openid-connect/certs" async with httpx.AsyncClient() as client: resp = await client.get(url) resp.raise_for_status() _jwks_cache = resp.json() return _jwks_cache async def get_current_user( credentials: HTTPAuthorizationCredentials | None = Depends(_security), ) -> User: """Validate JWT and return User. Raises 401 if invalid.""" if credentials is None: raise HTTPException(status_code=401, detail="Not authenticated") token = credentials.credentials try: jwks = await _get_jwks() header = jwt.get_unverified_header(token) key = next(k for k in jwks["keys"] if k["kid"] == header["kid"]) payload = jwt.decode( token, key, algorithms=["RS256"], audience="account", options={"verify_aud": False}, # Keycloak audience varies ) except (JWTError, StopIteration, KeyError, httpx.HTTPError) as e: raise HTTPException(status_code=401, detail="Invalid token") from e return User( sub=payload["sub"], email=payload.get("email"), username=payload.get("preferred_username"), roles=payload.get("realm_access", {}).get("roles", []), ) def require_role(*allowed_roles: str): """Factory: returns a FastAPI dependency that checks role membership.""" async def _check(user: User = Depends(get_current_user)) -> User: if not any(r in user.roles for r in allowed_roles): raise HTTPException(status_code=403, detail="Insufficient role") return user return _check ``` ### Acceptance Criteria - [ ] `pal-e-auth-ldraney` is removed from dependencies and not imported anywhere - [ ] Protected endpoints (`/admin/*`, `/tenants/*/roster`, `POST /tryouts/admin/*/checkin/*`) return 401 without a valid JWT - [ ] Protected endpoints return 403 with a valid JWT but wrong role - [ ] Protected endpoints work with a valid Keycloak JWT containing the required role - [ ] Public endpoints (`/api/roster/*`, `/tryouts/roster/*`, `/healthz`, `/pay`) work without any auth header - [ ] k8s deployment uses `BASKETBALL_KEYCLOAK_REALM_URL` instead of Google OAuth env vars - [ ] `pal-e-auth-secrets` k8s Secret reference is removed from deployment ### Test Expectations - [ ] Unit test: mock JWKS response, verify `get_current_user` extracts roles correctly - [ ] Unit test: verify `require_role("admin")` returns 403 for user with only `player` role - [ ] Unit test: verify 401 returned when no Authorization header present - [ ] Existing admin/tryout tests should pass with updated auth mock - Run command: `pytest tests/ -v` ### Constraints - The new `User` dataclass must have a `roles` field (list of strings) — used by `require_role` - Keep the same `require_role("admin")` / `require_role("admin", "coach")` call signatures - Module-level `require_admin = require_role("admin")` pattern must still work (used in admin.py and tryouts.py) - JWKS should be fetched lazily and cached (don't hit Keycloak on every request) - The `get_current_user` dependency should be async (Keycloak JWKS fetch is async) - Route handler functions that use `Depends(require_admin)` may need to become async if they aren't already — check and convert as needed - Note: moving from sync to async route handlers in FastAPI is safe — FastAPI handles both ### Checklist - [ ] PR opened - [ ] Tests pass - [ ] No unrelated changes ### Related - `plan-2026-03-08-tryout-prep` — parent plan - Phase 5b (Keycloak realm config) — completed, realm `westside-basketball` is live - Phase 5d (westside-app login) — parallel work, separate repo
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo_admin/basketball-api#77
No description provided.