feature/oidc-login #17

Merged
hzhang merged 6 commits from feature/oidc-login into main 2026-05-17 21:27:39 +00:00
5 changed files with 80 additions and 1 deletions
Showing only changes of commit 9429e37542 - Show all commits

View File

@@ -7,6 +7,7 @@ override the OIDC_* env vars; env values act as bootstrap defaults.
Sign-in policy: an OIDC identity must already be bound to an hf user
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected.
"""
import logging
from datetime import timedelta
from urllib.parse import urlencode
@@ -21,13 +22,14 @@ from app.models.oidc_settings import OidcSettings
from app.api.deps import create_access_token, get_current_user, get_current_user_or_apikey
router = APIRouter(prefix="/auth", tags=["Auth"])
logger = logging.getLogger("harborforge.oidc")
# ---- effective config (DB row overrides env) ------------------------------
class EffectiveOidc:
def __init__(self, enabled, issuer, client_id, client_secret,
redirect_uri, scopes, post_login_redirect):
redirect_uri, scopes, post_login_redirect, admin_role):
self.enabled = enabled
self.issuer = issuer
self.client_id = client_id
@@ -35,6 +37,7 @@ class EffectiveOidc:
self.redirect_uri = redirect_uri
self.scopes = scopes or "openid email profile"
self.post_login_redirect = post_login_redirect
self.admin_role = (admin_role or "admin").strip() or "admin"
@property
def configured(self) -> bool:
@@ -58,6 +61,7 @@ def get_effective_oidc(db: Session) -> EffectiveOidc:
settings.OIDC_ENABLED, settings.OIDC_ISSUER, settings.OIDC_CLIENT_ID,
settings.OIDC_CLIENT_SECRET, settings.OIDC_REDIRECT_URI,
settings.OIDC_SCOPES, settings.OIDC_POST_LOGIN_REDIRECT,
settings.OIDC_ADMIN_ROLE,
)
return EffectiveOidc(
bool(row.enabled),
@@ -67,6 +71,7 @@ def get_effective_oidc(db: Session) -> EffectiveOidc:
pick(row.redirect_uri, settings.OIDC_REDIRECT_URI),
pick(row.scopes, settings.OIDC_SCOPES),
pick(row.post_login_redirect, settings.OIDC_POST_LOGIN_REDIRECT),
pick(getattr(row, "admin_role", None), settings.OIDC_ADMIN_ROLE),
)
@@ -100,6 +105,39 @@ def _invalidate_client():
_oauth_fp = None
def _collect_roles(claims: dict, token: dict) -> set[str]:
"""Roles from common OIDC claim shapes, across the ID-token/userinfo
claims and the (unverified) access token — Keycloak puts realm/client
roles in the access token by default."""
pools = [claims if isinstance(claims, dict) else {}]
at = token.get("access_token")
if at:
try:
from jose import jwt as _jwt
pools.append(_jwt.get_unverified_claims(at))
except Exception:
pass
roles: set[str] = set()
for p in pools:
if not isinstance(p, dict):
continue
ra = p.get("realm_access")
if isinstance(ra, dict):
roles.update(ra.get("roles") or [])
res = p.get("resource_access")
if isinstance(res, dict):
for v in res.values():
if isinstance(v, dict):
roles.update(v.get("roles") or [])
for key in ("roles", "role", "groups"):
val = p.get(key)
if isinstance(val, str):
roles.add(val)
elif isinstance(val, (list, tuple)):
roles.update(str(x) for x in val)
return {str(r).strip().lstrip("/").lower() for r in roles if r}
def _frontend(cfg: EffectiveOidc, qs: dict | None = None, fragment: str | None = None) -> str:
base = cfg.post_login_redirect or "/"
url = base
@@ -190,6 +228,33 @@ async def oidc_callback(request: Request, db: Session = Depends(get_db)):
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
# OIDC-only bootstrap: before any admin is linked, an IdP user whose
# token carries the configured admin role auto-connects to the unbound
# hf admin. Self-closes once any admin is bound.
if user is None and settings.HARBORFORGE_OIDC_ONLY:
any_admin_bound = db.query(models.User).filter(
models.User.is_admin == True, # noqa: E712
models.User.oidc_subject.isnot(None),
).first()
if not any_admin_bound and cfg.admin_role.lower() in _collect_roles(claims, token):
taken = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
if taken is None:
boot = db.query(models.User).filter(
models.User.is_admin == True, # noqa: E712
models.User.is_active == True, # noqa: E712
models.User.oidc_subject.is_(None),
).order_by(models.User.id).first()
if boot is not None:
boot.oidc_issuer = issuer
boot.oidc_subject = subject
db.commit()
logger.info("OIDC bootstrap: auto-connected admin '%s' via admin role", boot.username)
user = boot
if not user or not user.is_active or user.username in ("acc-mgr", "deleted-user"):
return RedirectResponse(_frontend(cfg, {"oidc_error": "not_linked"}))
@@ -218,6 +283,7 @@ class OidcSettingsIn(BaseModel):
redirect_uri: str | None = None
scopes: str | None = None
post_login_redirect: str | None = None
admin_role: str | None = None
class OidcSettingsOut(BaseModel):
@@ -228,6 +294,7 @@ class OidcSettingsOut(BaseModel):
redirect_uri: str | None
scopes: str | None
post_login_redirect: str | None
admin_role: str
oidc_only: bool # read-only (deploy env)
effective_enabled: bool # provider actually usable
source: str # "db" or "env"
@@ -245,6 +312,7 @@ def get_oidc_settings(db: Session = Depends(get_db), _: models.User = Depends(_r
redirect_uri=(row.redirect_uri if row else None) or settings.OIDC_REDIRECT_URI or None,
scopes=(row.scopes if row else None) or settings.OIDC_SCOPES or None,
post_login_redirect=(row.post_login_redirect if row else None) or settings.OIDC_POST_LOGIN_REDIRECT or None,
admin_role=cfg.admin_role,
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
effective_enabled=cfg.configured,
source="db" if row else "env",
@@ -274,6 +342,8 @@ def update_oidc_settings(payload: OidcSettingsIn, db: Session = Depends(get_db),
row.scopes = payload.scopes.strip() or None
if payload.post_login_redirect is not None:
row.post_login_redirect = payload.post_login_redirect.strip() or None
if payload.admin_role is not None:
row.admin_role = payload.admin_role.strip() or None
db.commit()
_invalidate_client()

View File

@@ -46,6 +46,7 @@ class Settings(BaseSettings):
OIDC_REDIRECT_URI: str = "" # backend callback, e.g. https://hf-api.example.com/auth/oidc/callback
OIDC_SCOPES: str = "openid email profile"
OIDC_POST_LOGIN_REDIRECT: str = "" # frontend URL to return to (token in fragment). Falls back to "/"
OIDC_ADMIN_ROLE: str = "admin" # OIDC role name that bootstraps the unbound hf admin (OIDC-only)
# When true: no password login at all. Password login endpoint rejects,
# user creation ignores any password (passwordless user that can only use

View File

@@ -351,6 +351,7 @@ def init_oidc_settings(db: Session, oidc_cfg: dict, admin_user: models.User | No
redirect_uri=(oidc_cfg.get("redirect_uri") or "").strip() or None,
scopes=(oidc_cfg.get("scopes") or "").strip() or None,
post_login_redirect=(oidc_cfg.get("post_login_redirect") or "").strip() or None,
admin_role=(oidc_cfg.get("admin_role") or "").strip() or None,
))
db.commit()
logger.info("OIDC settings bootstrapped from wizard config")

View File

@@ -301,6 +301,10 @@ def _migrate_schema():
if _has_table(db, "users") and _has_column(db, "users", "oidc_subject"):
_ensure_unique_index(db, "users", "uq_users_oidc_identity", "oidc_issuer, oidc_subject")
# --- oidc_settings.admin_role (added after the table shipped) ---
if _has_table(db, "oidc_settings") and not _has_column(db, "oidc_settings", "admin_role"):
db.execute(text("ALTER TABLE oidc_settings ADD COLUMN admin_role VARCHAR(128) NULL"))
# --- monitored_servers.api_key for heartbeat v2 ---
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))

View File

@@ -19,4 +19,7 @@ class OidcSettings(Base):
redirect_uri = Column(String(512), nullable=True)
scopes = Column(String(255), nullable=True)
post_login_redirect = Column(String(512), nullable=True)
# OIDC role name that, in OIDC-only mode, auto-connects an unbound
# hf admin on first login (bootstrap). Default "admin".
admin_role = Column(String(128), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())