feat(auth): OIDC login + identity binding + HARBORFORGE_OIDC_ONLY
- Generic OIDC (Authlib discovery) Authorization Code flow; backend
issues the existing HS256 JWT on success. Unbound identities are
rejected (no auto-provisioning).
- User.oidc_issuer/oidc_subject (unique together) + startup migration.
- PUT/DELETE /users/{id}/oidc-binding (admin or account-manager;
JWT or API key; 409 on conflict). Self-link /auth/oidc/link
(non-OIDC_ONLY only). Public GET /auth/config.
- HARBORFORGE_OIDC_ONLY: /auth/token rejected, create/update ignore
password (passwordless users; API keys + OIDC still work).
- Dockerfile ARG/ENV HARBORFORGE_OIDC_ONLY; authlib+itsdangerous deps;
SessionMiddleware for OIDC state. Fixed _user_response to expose
the new binding fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
|
||||
from app.core.config import get_db
|
||||
from app.core.config import get_db, settings
|
||||
from app.init_wizard import DELETED_USER_USERNAME
|
||||
from app.models import models
|
||||
from app.models.agent import Agent
|
||||
@@ -32,6 +32,8 @@ def _user_response(user: models.User) -> dict:
|
||||
"role_name": user.role_name,
|
||||
"agent_id": user.agent.agent_id if user.agent else None,
|
||||
"discord_user_id": user.discord_user_id,
|
||||
"oidc_issuer": user.oidc_issuer,
|
||||
"oidc_subject": user.oidc_subject,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
return data
|
||||
@@ -111,7 +113,13 @@ def create_user(
|
||||
raise HTTPException(status_code=400, detail="agent_id already in use")
|
||||
|
||||
assigned_role = _resolve_user_role(db, user.role_id)
|
||||
hashed_password = get_password_hash(user.password) if user.password else None
|
||||
# In OIDC-only mode, ignore any supplied password: the user is created
|
||||
# passwordless (cannot password-login) and is expected to sign in via a
|
||||
# bound OIDC identity. API keys still work for such users.
|
||||
if settings.HARBORFORGE_OIDC_ONLY:
|
||||
hashed_password = None
|
||||
else:
|
||||
hashed_password = get_password_hash(user.password) if user.password else None
|
||||
db_user = models.User(
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
@@ -191,7 +199,7 @@ def update_user(
|
||||
if payload.full_name is not None:
|
||||
user.full_name = payload.full_name
|
||||
|
||||
if payload.password is not None and payload.password.strip():
|
||||
if payload.password is not None and payload.password.strip() and not settings.HARBORFORGE_OIDC_ONLY:
|
||||
user.hashed_password = get_password_hash(payload.password)
|
||||
|
||||
if payload.role_id is not None:
|
||||
@@ -414,3 +422,68 @@ def list_user_worklogs(
|
||||
if current_user.id != user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
|
||||
|
||||
|
||||
# ---- OIDC identity binding ------------------------------------------------
|
||||
|
||||
class OidcBindingRequest(BaseModel):
|
||||
issuer: str
|
||||
subject: str
|
||||
|
||||
|
||||
class OidcBindingResponse(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
oidc_issuer: str | None = None
|
||||
oidc_subject: str | None = None
|
||||
|
||||
|
||||
@router.put("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
|
||||
def bind_user_oidc(
|
||||
identifier: str,
|
||||
payload: OidcBindingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: models.User = Depends(require_account_creator),
|
||||
):
|
||||
"""Bind an hf user to an external OIDC identity (issuer + subject).
|
||||
|
||||
Admin or account-manager only (JWT or API key). One OIDC identity maps
|
||||
to at most one user."""
|
||||
issuer = (payload.issuer or "").strip()
|
||||
subject = (payload.subject or "").strip()
|
||||
if not issuer or not subject:
|
||||
raise HTTPException(status_code=400, detail="issuer and subject are required")
|
||||
user = _find_user_by_id_or_username(db, identifier)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
clash = db.query(models.User).filter(
|
||||
models.User.oidc_issuer == issuer,
|
||||
models.User.oidc_subject == subject,
|
||||
models.User.id != user.id,
|
||||
).first()
|
||||
if clash:
|
||||
raise HTTPException(status_code=409, detail=f"OIDC identity already bound to user '{clash.username}'")
|
||||
user.oidc_issuer = issuer
|
||||
user.oidc_subject = subject
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return OidcBindingResponse(user_id=user.id, username=user.username,
|
||||
oidc_issuer=user.oidc_issuer, oidc_subject=user.oidc_subject)
|
||||
|
||||
|
||||
@router.delete("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
|
||||
def unbind_user_oidc(
|
||||
identifier: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: models.User = Depends(require_account_creator),
|
||||
):
|
||||
"""Remove a user's OIDC binding. Admin or account-manager only."""
|
||||
user = _find_user_by_id_or_username(db, identifier)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user.oidc_issuer = None
|
||||
user.oidc_subject = None
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return OidcBindingResponse(user_id=user.id, username=user.username,
|
||||
oidc_issuer=None, oidc_subject=None)
|
||||
|
||||
Reference in New Issue
Block a user