Compare commits
1 Commits
1a5a3ed1b1
...
0bdc432215
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bdc432215 |
@@ -1,5 +1,6 @@
|
|||||||
"""Shared auth dependencies."""
|
"""Shared auth dependencies."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
|
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
|
||||||
@@ -12,6 +13,8 @@ from app.core.config import get_db, settings
|
|||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.apikey import APIKey
|
from app.models.apikey import APIKey
|
||||||
|
|
||||||
|
logger = logging.getLogger("harborforge.deps")
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token", auto_error=False)
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token", auto_error=False)
|
||||||
apikey_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
apikey_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
@@ -86,6 +89,10 @@ async def get_current_user_or_apikey(
|
|||||||
"""Authenticate via JWT token (Authorization: Bearer <jwt>) OR API key
|
"""Authenticate via JWT token (Authorization: Bearer <jwt>) OR API key
|
||||||
(X-API-Key: <key>, OR — as a convenience for CLI clients that only know
|
(X-API-Key: <key>, OR — as a convenience for CLI clients that only know
|
||||||
Bearer — Authorization: Bearer <api-key>; falls back when JWT decode fails).
|
Bearer — Authorization: Bearer <api-key>; falls back when JWT decode fails).
|
||||||
|
|
||||||
|
Bearer tokens are tried in order: local HS256 JWT → external Tessera
|
||||||
|
(OIDC) RS256 access token → API key. The Tessera path is purely additive
|
||||||
|
and never affects local-JWT/API-key callers.
|
||||||
"""
|
"""
|
||||||
# Native X-API-Key header
|
# Native X-API-Key header
|
||||||
if api_key:
|
if api_key:
|
||||||
@@ -93,15 +100,31 @@ async def get_current_user_or_apikey(
|
|||||||
if user:
|
if user:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
# Bearer header — try JWT first, then API key on decode failure
|
# Bearer header — local JWT first, then Tessera, then API key.
|
||||||
if token:
|
if token:
|
||||||
try:
|
try:
|
||||||
return await get_current_user(token=token, db=db)
|
return await get_current_user(token=token, db=db)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# External Tessera (OIDC) RS256 access token.
|
||||||
|
try:
|
||||||
|
from app.api.tessera import authenticate_tessera
|
||||||
|
return authenticate_tessera(db, token)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
except Exception: # JWKS fetch / unexpected verifier error → don't 500
|
||||||
|
logger.warning("Tessera token verification error", exc_info=True)
|
||||||
|
|
||||||
|
# Bearer-carried API key (CLI convenience).
|
||||||
user = _lookup_api_key(db, token)
|
user = _lookup_api_key(db, token)
|
||||||
if user:
|
if user:
|
||||||
return user
|
return user
|
||||||
raise
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
|||||||
231
app/api/tessera.py
Normal file
231
app/api/tessera.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"""Tessera (external OIDC, Keycloak-compatible) access-token verification.
|
||||||
|
|
||||||
|
Accepts RS256 access tokens issued by the configured Tessera realm as API
|
||||||
|
bearer tokens. This is ADDITIVE to the existing local HS256 JWT and API-key
|
||||||
|
auth — see app/api/deps.get_current_user_or_apikey.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
* fetch + cache the realm JWKS ({issuer}/protocol/openid-connect/certs),
|
||||||
|
* select the JWK by the token header `kid`,
|
||||||
|
* verify the RS256 signature, `iss == TESSERA_ISSUER`,
|
||||||
|
`aud` contains TESSERA_AUDIENCE, and require `exp`/`iat`.
|
||||||
|
|
||||||
|
Verified claims are mapped to / provision an hf models.User, mirroring the
|
||||||
|
OIDC login callback provisioning (app/api/routers/oidc.py).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from jose import jwt
|
||||||
|
from jose.exceptions import JWTError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models import models
|
||||||
|
from app.models.role_permission import Role
|
||||||
|
|
||||||
|
logger = logging.getLogger("harborforge.tessera")
|
||||||
|
|
||||||
|
# JWKS cache: refetched when a token's kid is unknown, and at most once per
|
||||||
|
# _JWKS_TTL otherwise (so rotated/revoked keys are picked up within the TTL).
|
||||||
|
_JWKS_TTL = 3600
|
||||||
|
_jwks_lock = threading.Lock()
|
||||||
|
_jwks: dict | None = None
|
||||||
|
_jwks_fetched_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _jwks_url() -> str:
|
||||||
|
return settings.TESSERA_ISSUER.rstrip("/") + "/protocol/openid-connect/certs"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_jwks() -> dict:
|
||||||
|
resp = requests.get(_jwks_url(), timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwks(force: bool = False) -> dict:
|
||||||
|
global _jwks, _jwks_fetched_at
|
||||||
|
with _jwks_lock:
|
||||||
|
now = time.time()
|
||||||
|
if force or _jwks is None or (now - _jwks_fetched_at) > _JWKS_TTL:
|
||||||
|
_jwks = _fetch_jwks()
|
||||||
|
_jwks_fetched_at = now
|
||||||
|
return _jwks
|
||||||
|
|
||||||
|
|
||||||
|
def _key_for_kid(kid: str | None) -> dict | None:
|
||||||
|
keys = (_get_jwks() or {}).get("keys") or []
|
||||||
|
for k in keys:
|
||||||
|
if k.get("kid") == kid:
|
||||||
|
return k
|
||||||
|
# Unknown kid → keys may have rotated; force a refresh once and retry.
|
||||||
|
keys = (_get_jwks(force=True) or {}).get("keys") or []
|
||||||
|
for k in keys:
|
||||||
|
if k.get("kid") == kid:
|
||||||
|
return k
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_tessera_token(token: str) -> dict:
|
||||||
|
"""Verify a Tessera RS256 access token and return its decoded claims.
|
||||||
|
|
||||||
|
Raises HTTPException(401) on any failure (so callers can fall through to
|
||||||
|
the next auth method without leaking which check failed).
|
||||||
|
"""
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="No token")
|
||||||
|
try:
|
||||||
|
header = jwt.get_unverified_header(token)
|
||||||
|
except JWTError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail="Malformed token") from exc
|
||||||
|
|
||||||
|
if header.get("alg") != "RS256":
|
||||||
|
raise HTTPException(status_code=401, detail="Unexpected token algorithm")
|
||||||
|
|
||||||
|
key = _key_for_kid(header.get("kid"))
|
||||||
|
if key is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Unknown signing key")
|
||||||
|
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
issuer=settings.TESSERA_ISSUER,
|
||||||
|
audience=settings.TESSERA_AUDIENCE,
|
||||||
|
options={"require_exp": True, "require_iat": True},
|
||||||
|
)
|
||||||
|
except JWTError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid Tessera token") from exc
|
||||||
|
|
||||||
|
if not claims.get("sub"):
|
||||||
|
raise HTTPException(status_code=401, detail="Token missing subject")
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_roles(claims: dict) -> set[str]:
|
||||||
|
"""Tessera/Keycloak roles: realm_access.roles + resource_access.<client>.roles.
|
||||||
|
|
||||||
|
Mirrors app/api/routers/oidc._collect_roles (normalised lower-case, no
|
||||||
|
leading slash).
|
||||||
|
"""
|
||||||
|
roles: set[str] = set()
|
||||||
|
ra = claims.get("realm_access")
|
||||||
|
if isinstance(ra, dict):
|
||||||
|
roles.update(ra.get("roles") or [])
|
||||||
|
res = claims.get("resource_access")
|
||||||
|
if isinstance(res, dict):
|
||||||
|
for v in res.values():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
roles.update(v.get("roles") or [])
|
||||||
|
return {str(r).strip().lstrip("/").lower() for r in roles if r}
|
||||||
|
|
||||||
|
|
||||||
|
# Token roles (lower-case) mapped to an hf global role name, highest first.
|
||||||
|
# The first match wins. Token "admin" → hf admin (sets is_admin); others map
|
||||||
|
# onto the existing global role hierarchy.
|
||||||
|
_ROLE_PRIORITY = ["admin", "mgr", "dev", "member", "viewer", "guest"]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_global_role(db: Session, token_roles: set[str]) -> Role | None:
|
||||||
|
for name in _ROLE_PRIORITY:
|
||||||
|
if name in token_roles:
|
||||||
|
role = db.query(Role).filter(
|
||||||
|
Role.is_global == True, # noqa: E712
|
||||||
|
Role.name == name,
|
||||||
|
).first()
|
||||||
|
if role:
|
||||||
|
return role
|
||||||
|
# No recognised role → fall back to guest (least privilege).
|
||||||
|
return db.query(Role).filter(
|
||||||
|
Role.is_global == True, # noqa: E712
|
||||||
|
Role.name == "guest",
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_username(db: Session, base: str) -> str:
|
||||||
|
base = (base or "tessera-user").strip() or "tessera-user"
|
||||||
|
candidate = base
|
||||||
|
n = 1
|
||||||
|
while db.query(models.User).filter(models.User.username == candidate).first():
|
||||||
|
n += 1
|
||||||
|
candidate = f"{base}-{n}"
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_or_provision_user(db: Session, claims: dict) -> models.User:
|
||||||
|
"""Resolve the hf User for a verified Tessera token, auto-provisioning one
|
||||||
|
if no match exists. Mirrors the OIDC callback binding/provisioning."""
|
||||||
|
issuer = claims.get("iss") or settings.TESSERA_ISSUER
|
||||||
|
subject = claims.get("sub")
|
||||||
|
email = (claims.get("email") or "").strip() or None
|
||||||
|
username = (claims.get("preferred_username") or "").strip() or None
|
||||||
|
|
||||||
|
token_roles = _collect_roles(claims)
|
||||||
|
is_admin = "admin" in token_roles
|
||||||
|
|
||||||
|
# 1) by (issuer, subject)
|
||||||
|
user = db.query(models.User).filter(
|
||||||
|
models.User.oidc_issuer == issuer,
|
||||||
|
models.User.oidc_subject == subject,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# 2) by email — bind this Tessera identity onto the existing account.
|
||||||
|
if user is None and email:
|
||||||
|
user = db.query(models.User).filter(models.User.email == email).first()
|
||||||
|
if user is not None:
|
||||||
|
if user.oidc_subject and user.oidc_subject != subject:
|
||||||
|
# Email belongs to a user already bound to a different identity.
|
||||||
|
raise HTTPException(status_code=401, detail="Account already bound to another identity")
|
||||||
|
user.oidc_issuer = issuer
|
||||||
|
user.oidc_subject = subject
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 3) auto-provision
|
||||||
|
if user is None:
|
||||||
|
role = _resolve_global_role(db, token_roles)
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(status_code=500, detail="No global role available (DB not seeded)")
|
||||||
|
uname = _unique_username(db, username or (email.split("@")[0] if email else None) or f"tessera-{subject[:8]}")
|
||||||
|
# Email is NOT NULL + unique; synthesise a stable placeholder if absent.
|
||||||
|
eff_email = email or f"{subject}@tessera.local"
|
||||||
|
if db.query(models.User).filter(models.User.email == eff_email).first():
|
||||||
|
eff_email = f"{subject}@tessera.local"
|
||||||
|
user = models.User(
|
||||||
|
username=uname,
|
||||||
|
email=eff_email,
|
||||||
|
full_name=(claims.get("name") or username or uname),
|
||||||
|
hashed_password=None,
|
||||||
|
oidc_issuer=issuer,
|
||||||
|
oidc_subject=subject,
|
||||||
|
is_active=True,
|
||||||
|
is_admin=is_admin,
|
||||||
|
role_id=role.id,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
logger.info("Tessera: provisioned user '%s' (admin=%s) for subject %s", user.username, is_admin, subject)
|
||||||
|
return user
|
||||||
|
|
||||||
|
if not user.is_active or user.username in ("acc-mgr", "deleted-user"):
|
||||||
|
raise HTTPException(status_code=401, detail="User is not permitted to sign in")
|
||||||
|
|
||||||
|
# Keep admin status in sync with the token's realm/client roles on each
|
||||||
|
# request so role changes in Tessera take effect without re-provisioning.
|
||||||
|
if bool(user.is_admin) != is_admin:
|
||||||
|
user.is_admin = is_admin
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_tessera(db: Session, token: str) -> models.User:
|
||||||
|
"""Verify a Tessera bearer token and return the resolved hf User."""
|
||||||
|
claims = verify_tessera_token(token)
|
||||||
|
return resolve_or_provision_user(db, claims)
|
||||||
@@ -26,6 +26,13 @@ class Settings(BaseSettings):
|
|||||||
# for production; set SESSION_COOKIE_SECURE=false for plain-HTTP local dev.
|
# for production; set SESSION_COOKIE_SECURE=false for plain-HTTP local dev.
|
||||||
SESSION_COOKIE_SECURE: bool = True
|
SESSION_COOKIE_SECURE: bool = True
|
||||||
|
|
||||||
|
# External OIDC provider ("Tessera", Keycloak-compatible) whose RS256
|
||||||
|
# access tokens are accepted as API bearer tokens (additive to local
|
||||||
|
# HS256 JWT + API keys). Tokens are verified against the issuer's JWKS;
|
||||||
|
# `iss` must equal TESSERA_ISSUER and `aud` must contain TESSERA_AUDIENCE.
|
||||||
|
TESSERA_ISSUER: str = "https://login.hangman-lab.top/realms/Hangman-Lab"
|
||||||
|
TESSERA_AUDIENCE: str = "harbor-forge"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user