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:
h z
2026-05-17 20:22:04 +01:00
parent 90b494f097
commit 94155614f5
9 changed files with 280 additions and 5 deletions

View File

@@ -42,5 +42,11 @@ COPY requirements.txt ./
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
# OIDC-only mode: when "true", password login is rejected, user creation
# ignores passwords (passwordless users that sign in via a bound OIDC
# identity / API keys). Overridable at runtime via the same env var.
ARG HARBORFORGE_OIDC_ONLY=false
ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY}
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -18,6 +18,8 @@ router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Password login is disabled (OIDC only)")
user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password or ""):
raise HTTPException(status_code=401, detail="Incorrect username or password",

145
app/api/routers/oidc.py Normal file
View File

@@ -0,0 +1,145 @@
"""OIDC (OpenID Connect) login + public auth-config.
Generic OIDC via discovery. The backend performs the Authorization Code
flow, then issues its own existing HS256 JWT (same as password login) so
the rest of the app is unchanged.
Sign-in policy: an OIDC identity must already be bound to an hf user
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected — no
auto-provisioning.
"""
from datetime import timedelta
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.core.config import get_db, settings
from app.models import models
from app.api.deps import create_access_token, get_current_user
router = APIRouter(prefix="/auth", tags=["Auth"])
# Authlib registry — only configured when OIDC is enabled.
_oauth = None
def _get_oidc():
"""Lazily build the Authlib OIDC client; 503 if not configured."""
global _oauth
if not (settings.OIDC_ENABLED and settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID):
raise HTTPException(status_code=503, detail="OIDC is not configured")
if _oauth is None:
from authlib.integrations.starlette_client import OAuth
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=settings.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration",
client_id=settings.OIDC_CLIENT_ID,
client_secret=settings.OIDC_CLIENT_SECRET,
client_kwargs={"scope": settings.OIDC_SCOPES},
)
_oauth = oauth
return _oauth.oidc
def _frontend(suffix_qs: dict | None = None, fragment: str | None = None) -> str:
"""Build the post-login frontend redirect (never client-controlled)."""
base = settings.OIDC_POST_LOGIN_REDIRECT or "/"
url = base
if suffix_qs:
url += ("&" if "?" in base else "?") + urlencode(suffix_qs)
if fragment:
url += "#" + fragment
return url
@router.get("/config")
def auth_config():
"""Public: lets the frontend decide which login UI to render."""
return {
"oidc_enabled": bool(settings.OIDC_ENABLED and settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID),
"oidc_only": bool(settings.HARBORFORGE_OIDC_ONLY),
"password_login": not bool(settings.HARBORFORGE_OIDC_ONLY),
"oidc_login_url": "/auth/oidc/login",
}
@router.get("/oidc/login")
async def oidc_login(request: Request):
"""Start the OIDC Authorization Code flow for sign-in."""
oidc = _get_oidc()
request.session.pop("hf_oidc_mode", None)
request.session.pop("hf_oidc_uid", None)
request.session["hf_oidc_mode"] = "login"
return await oidc.authorize_redirect(request, settings.OIDC_REDIRECT_URI)
@router.get("/oidc/link")
async def oidc_link(request: Request, current_user: models.User = Depends(get_current_user)):
"""Self-service: bind the caller's own account to an OIDC identity.
Only available when NOT in OIDC-only mode (admins use the binding API
in that mode)."""
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Self-service linking is disabled in OIDC-only mode")
oidc = _get_oidc()
request.session["hf_oidc_mode"] = "link"
request.session["hf_oidc_uid"] = current_user.id
return await oidc.authorize_redirect(request, settings.OIDC_REDIRECT_URI)
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)):
"""OIDC redirect target. Resolves identity → hf user (must be bound)."""
oidc = _get_oidc()
mode = request.session.pop("hf_oidc_mode", "login")
link_uid = request.session.pop("hf_oidc_uid", None)
try:
token = await oidc.authorize_access_token(request)
except Exception:
return RedirectResponse(_frontend({"oidc_error": "exchange_failed"}))
claims = token.get("userinfo") or {}
if not claims:
try:
claims = await oidc.userinfo(token=token)
except Exception:
claims = {}
subject = claims.get("sub")
issuer = claims.get("iss") or settings.OIDC_ISSUER
if not subject:
return RedirectResponse(_frontend({"oidc_error": "no_subject"}))
if mode == "link":
if settings.HARBORFORGE_OIDC_ONLY or link_uid is None:
return RedirectResponse(_frontend({"oidc_error": "link_not_allowed"}))
user = db.query(models.User).filter(models.User.id == link_uid).first()
if not user:
return RedirectResponse(_frontend({"oidc_error": "user_gone"}))
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
models.User.id != user.id,
).first()
if clash:
return RedirectResponse(_frontend({"oidc_error": "already_bound"}))
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
return RedirectResponse(_frontend({"oidc_linked": "1"}))
# sign-in: identity must already be bound to an active hf user
user = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
if not user or not user.is_active or user.username in ("acc-mgr", "deleted-user"):
return RedirectResponse(_frontend({"oidc_error": "not_linked"}))
access_token = create_access_token(
data={"sub": str(user.id)},
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
)
return RedirectResponse(_frontend(fragment=urlencode({"token": access_token})))

View File

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

View File

@@ -38,6 +38,20 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# --- OIDC (generic, OpenID Connect discovery) ---
OIDC_ENABLED: bool = False
OIDC_ISSUER: str = "" # e.g. https://idp.example.com (we use {issuer}/.well-known/openid-configuration)
OIDC_CLIENT_ID: str = ""
OIDC_CLIENT_SECRET: str = ""
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 "/"
# When true: no password login at all. Password login endpoint rejects,
# user creation ignores any password (passwordless user that can only use
# API keys / OIDC), and the frontend hides all password UI.
HARBORFORGE_OIDC_ONLY: bool = False
class Config:
env_file = ".env"

View File

@@ -1,6 +1,9 @@
"""HarborForge API — Agent/人类协同任务管理平台"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.core.config import settings
app = FastAPI(
title="HarborForge API",
@@ -17,6 +20,17 @@ app.add_middleware(
allow_headers=["*"],
)
# Short-lived signed session cookie — only used to carry the OIDC
# state/nonce between /auth/oidc/login and the callback.
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
session_cookie="hf_oidc",
same_site="lax",
https_only=False,
max_age=600,
)
# Health & version (kept at top level)
@app.get("/health", tags=["System"])
def health_check():
@@ -65,8 +79,10 @@ from app.api.routers.meetings import router as meetings_router
from app.api.routers.essentials import router as essentials_router
from app.api.routers.schedule_type import router as schedule_type_router
from app.api.routers.calendar import router as calendar_router
from app.api.routers.oidc import router as oidc_router
app.include_router(auth_router)
app.include_router(oidc_router)
app.include_router(tasks_router)
app.include_router(projects_router)
app.include_router(users_router)
@@ -277,6 +293,14 @@ def _migrate_schema():
if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"):
db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL"))
# --- users OIDC binding (issuer + subject), unique together ---
if _has_table(db, "users") and not _has_column(db, "users", "oidc_issuer"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_issuer VARCHAR(255) NULL"))
if _has_table(db, "users") and not _has_column(db, "users", "oidc_subject"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_subject VARCHAR(255) NULL"))
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")
# --- 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

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
@@ -66,6 +66,9 @@ class Project(Base):
class User(Base):
__tablename__ = "users"
__table_args__ = (
UniqueConstraint("oidc_issuer", "oidc_subject", name="uq_users_oidc_identity"),
)
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False)
@@ -73,6 +76,10 @@ class User(Base):
hashed_password = Column(String(255), nullable=True)
full_name = Column(String(100), nullable=True)
discord_user_id = Column(String(32), nullable=True)
# OIDC binding: an hf user is linked to at most one external OIDC identity
# (issuer + subject). Unique together so one IdP identity maps to one user.
oidc_issuer = Column(String(255), nullable=True)
oidc_subject = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)

View File

@@ -194,8 +194,10 @@ class UserResponse(UserBase):
role_name: Optional[str] = None
agent_id: Optional[str] = None
discord_user_id: Optional[str] = None
oidc_issuer: Optional[str] = None
oidc_subject: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True

View File

@@ -12,3 +12,5 @@ alembic==1.13.1
python-dotenv==1.0.0
httpx==0.27.0
requests==2.31.0
authlib==1.3.2
itsdangerous==2.2.0