Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ KEYCLOAK_REALM=cell-explorer
KEYCLOAK_CLIENT_ID=cell-explorer-app
KEYCLOAK_CLIENT_SECRET=

# OIDC provider (generic). AUTH_PROVIDER picks preset defaults: keycloak (default) | entra | oidc.
# For Keycloak the KEYCLOAK_* vars above are sufficient — no OIDC_* needed.
# AUTH_PROVIDER=keycloak
#
# Microsoft Entra:
# AUTH_PROVIDER=entra
# OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
# OIDC_CLIENT_ID=<app-registration-client-id>
# OIDC_CLIENT_SECRET=<client-secret>
# Define App Roles in the Entra app registration; they arrive in the `roles` claim.
# offline_access is added automatically for refresh tokens.
# If your Entra access-token `aud` differs from the client id (often api://<client-id>),
# set OIDC_AUDIENCE to match, or token validation will 401.
#
# Generic OIDC: AUTH_PROVIDER=oidc plus OIDC_ISSUER, OIDC_CLIENT_ID/SECRET, and
# OIDC_ROLES_CLAIMS (comma-separated dotted claim-paths, e.g. "roles").
# OIDC_SCOPES / OIDC_AUDIENCE / OIDC_ROLES_CLAIMS override the preset defaults.

# Session cookie lifetimes (seconds). Tune REFRESH_COOKIE_MAX_AGE to match the
# realm's ssoSessionMaxLifespan (currently 24h = 86400). Setting it higher than
# Keycloak allows just causes refresh to fail before the cookie expires.
Expand Down
8 changes: 4 additions & 4 deletions packages/api/src/cell_explorer_api/auth/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ async def require_admin(
if credentials and credentials.credentials == settings.admin_api_key:
return

# Try Keycloak JWT with admin role
# Try OIDC JWT with admin role
if settings.auth_enabled:
access_token = request.cookies.get("cce_access")
if access_token:
try:
from cell_explorer_api.auth.keycloak import KeycloakClient
from cell_explorer_api.auth.oidc import OidcClient

keycloak: KeycloakClient = request.app.state.keycloak
user = keycloak.decode_token(access_token)
oidc: OidcClient = request.app.state.oidc
user = oidc.decode_token(access_token)
if "admin" in user.roles:
return
except Exception:
Expand Down
12 changes: 6 additions & 6 deletions packages/api/src/cell_explorer_api/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from fastapi import HTTPException, Request

from cell_explorer_api.auth.keycloak import KeycloakClient
from cell_explorer_api.auth.oidc import OidcClient
from cell_explorer_api.auth.models import User

logger = logging.getLogger(__name__)
Expand All @@ -15,19 +15,19 @@ async def require_auth(request: Request) -> User:
access_token = request.cookies.get("cce_access")
refresh_token = request.cookies.get("cce_refresh")

# No credentials at all — short-circuit before hitting Keycloak config.
# No credentials at all — short-circuit before hitting OIDC config.
if not access_token and not refresh_token:
raise HTTPException(status_code=401, detail="Not authenticated")

if not request.app.state.settings.auth_enabled:
raise HTTPException(status_code=501, detail="Authentication is not configured")

keycloak: KeycloakClient = request.app.state.keycloak
oidc: OidcClient = request.app.state.oidc

# Happy path: try to decode the access token if we have one.
if access_token:
try:
return keycloak.decode_token(access_token)
return oidc.decode_token(access_token)
except Exception as e:
logger.warning("Access token decode failed: %s", e)

Expand All @@ -38,8 +38,8 @@ async def require_auth(request: Request) -> User:

try:
logger.info("Attempting token refresh")
tokens = await keycloak.refresh_token(refresh_token)
user = keycloak.decode_token(tokens["access_token"])
tokens = await oidc.refresh_token(refresh_token)
user = oidc.decode_token(tokens["access_token"])
request.state.new_access_token = tokens["access_token"]
request.state.new_refresh_token = tokens.get("refresh_token", refresh_token)
return user
Expand Down
131 changes: 0 additions & 131 deletions packages/api/src/cell_explorer_api/auth/keycloak.py

This file was deleted.

152 changes: 152 additions & 0 deletions packages/api/src/cell_explorer_api/auth/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Generic OIDC client — discovery, token validation, auth URLs, token exchange.

Works with any OIDC provider (Keycloak, Microsoft Entra, ...). Endpoints are
resolved from the provider's .well-known/openid-configuration; roles are read
from configurable dotted claim-paths. Provider presets live in Settings.
"""

import logging
from urllib.parse import urlencode

import httpx
import jwt
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
load_pem_public_key,
)

from cell_explorer_api.auth.models import User
from cell_explorer_api.config import Settings

logger = logging.getLogger(__name__)


def extract_roles(claims: dict, paths: list[str]) -> list[str]:
"""Merge role lists found at each dotted JSON-path into a sorted, deduped list."""
roles: set[str] = set()
for path in paths:
node = claims
for part in path.split("."):
if not isinstance(node, dict) or part not in node:
node = None
break
node = node[part]
if isinstance(node, list):
roles.update(str(r) for r in node)
return sorted(roles)


class OidcClient:
"""Provider-agnostic OIDC operations, driven by resolved Settings + discovery."""

def __init__(self, settings: Settings) -> None:
self._settings = settings
self._jwks: dict[str, bytes] = {} # kid → PEM public key bytes
self._authorization_endpoint: str | None = None
self._token_endpoint: str | None = None
self._jwks_uri: str | None = None
self._issuer: str | None = None
self._end_session_endpoint: str | None = None

def _apply_discovery(self, doc: dict) -> None:
self._authorization_endpoint = doc.get("authorization_endpoint")
self._token_endpoint = doc.get("token_endpoint")
self._jwks_uri = doc.get("jwks_uri")
self._issuer = doc.get("issuer")
self._end_session_endpoint = doc.get("end_session_endpoint")

async def discover(self) -> None:
"""Fetch .well-known/openid-configuration and cache the endpoints."""
url = self._settings.discovery_url
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
self._apply_discovery(response.json())

def authorization_url(self, redirect_uri: str, state: str) -> str:
params = {
"client_id": self._settings.resolved_client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"state": state,
"scope": self._settings.resolved_scopes,
}
if self._settings.resolved_idp_hint:
params["kc_idp_hint"] = self._settings.resolved_idp_hint
return f"{self._authorization_endpoint}?{urlencode(params)}"

async def exchange_code(self, code: str, redirect_uri: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(
self._token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._settings.resolved_client_id,
"client_secret": self._settings.resolved_client_secret,
},
)
response.raise_for_status()
return response.json()

async def refresh_token(self, refresh_token: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.post(
self._token_endpoint,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": self._settings.resolved_client_id,
"client_secret": self._settings.resolved_client_secret,
},
)
response.raise_for_status()
return response.json()

async def fetch_jwks(self) -> None:
async with httpx.AsyncClient() as client:
response = await client.get(self._jwks_uri)
response.raise_for_status()
jwks = response.json()
self._jwks = {}
for key_data in jwks.get("keys", []):
kid = key_data.get("kid")
if kid:
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key_data)
self._jwks[kid] = public_key.public_bytes(
encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo,
)

def decode_token(self, token: str) -> User:
"""Decode and validate a JWT access token. Raises on invalid/expired."""
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if kid not in self._jwks:
raise jwt.InvalidTokenError(f"Unknown kid: {kid}")

public_key = load_pem_public_key(self._jwks[kid])
# leeway=30s tolerates small clock skew between the IdP and this
# container (see issue #131).
claims = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience=self._settings.resolved_audience,
issuer=self._issuer or self._settings.resolved_issuer,
leeway=30,
)
return User(
sub=claims["sub"],
name=claims.get("name"),
email=claims.get("email"),
roles=extract_roles(claims, self._settings.resolved_roles_claims),
)

def logout_url(self, redirect_uri: str) -> str:
params = {
"client_id": self._settings.resolved_client_id,
"post_logout_redirect_uri": redirect_uri,
}
return f"{self._end_session_endpoint}?{urlencode(params)}"
6 changes: 3 additions & 3 deletions packages/api/src/cell_explorer_api/auth/optional.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ async def optional_auth(request: Request) -> User | None:
return None

try:
from cell_explorer_api.auth.keycloak import KeycloakClient
from cell_explorer_api.auth.oidc import OidcClient

keycloak: KeycloakClient = request.app.state.keycloak
return keycloak.decode_token(access_token)
oidc: OidcClient = request.app.state.oidc
return oidc.decode_token(access_token)
except Exception:
logger.debug("Optional auth: token decode failed, treating as anonymous")
return None
Loading
Loading