|
|
|
|
@@ -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()
|
|
|
|
|
|