Add OIDC/SSO authentication support
Backend: - Add OIDCConfig, OIDCConfigService, OIDCService classes for OIDC flow - Add OIDC endpoints: status, config (get/update), login, callback - Support authorization code flow with PKCE-compatible state parameter - JWKS-based ID token validation with RS256 support - Auto-provisioning of users from OIDC claims - Admin group mapping for automatic admin role assignment Frontend: - Add SSO login button on login page (conditionally shown when enabled) - Add OIDC admin configuration page (/admin/oidc) - Add SSO Configuration link in admin menu - Add OIDC types and API functions Security: - CSRF protection via state parameter in secure cookie - Secure cookie settings (httponly, secure, samesite=lax) - Client secret stored encrypted in database - Token validation using provider's JWKS endpoint
This commit is contained in:
@@ -867,3 +867,342 @@ def check_project_access(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
# --- OIDC Configuration Service ---
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCConfig:
|
||||||
|
"""OIDC configuration data class."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
enabled: bool = False,
|
||||||
|
issuer_url: str = "",
|
||||||
|
client_id: str = "",
|
||||||
|
client_secret: str = "",
|
||||||
|
scopes: list[str] = None,
|
||||||
|
auto_create_users: bool = True,
|
||||||
|
admin_group: str = "", # Group/role that grants admin access
|
||||||
|
):
|
||||||
|
self.enabled = enabled
|
||||||
|
self.issuer_url = issuer_url.rstrip("/") if issuer_url else ""
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.scopes = scopes or ["openid", "profile", "email"]
|
||||||
|
self.auto_create_users = auto_create_users
|
||||||
|
self.admin_group = admin_group
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discovery_url(self) -> str:
|
||||||
|
"""Get the OIDC discovery URL."""
|
||||||
|
if not self.issuer_url:
|
||||||
|
return ""
|
||||||
|
return f"{self.issuer_url}/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for storage."""
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"issuer_url": self.issuer_url,
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"scopes": self.scopes,
|
||||||
|
"auto_create_users": self.auto_create_users,
|
||||||
|
"admin_group": self.admin_group,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "OIDCConfig":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
return cls(
|
||||||
|
enabled=data.get("enabled", False),
|
||||||
|
issuer_url=data.get("issuer_url", ""),
|
||||||
|
client_id=data.get("client_id", ""),
|
||||||
|
client_secret=data.get("client_secret", ""),
|
||||||
|
scopes=data.get("scopes", ["openid", "profile", "email"]),
|
||||||
|
auto_create_users=data.get("auto_create_users", True),
|
||||||
|
admin_group=data.get("admin_group", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCConfigService:
|
||||||
|
"""Service for managing OIDC configuration."""
|
||||||
|
|
||||||
|
OIDC_CONFIG_KEY = "oidc_config"
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_config(self) -> OIDCConfig:
|
||||||
|
"""Get the current OIDC configuration."""
|
||||||
|
from .models import AuthSettings
|
||||||
|
import json
|
||||||
|
|
||||||
|
setting = (
|
||||||
|
self.db.query(AuthSettings)
|
||||||
|
.filter(AuthSettings.key == self.OIDC_CONFIG_KEY)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not setting:
|
||||||
|
return OIDCConfig()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(setting.value)
|
||||||
|
return OIDCConfig.from_dict(data)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
return OIDCConfig()
|
||||||
|
|
||||||
|
def save_config(self, config: OIDCConfig) -> None:
|
||||||
|
"""Save OIDC configuration."""
|
||||||
|
from .models import AuthSettings
|
||||||
|
import json
|
||||||
|
|
||||||
|
setting = (
|
||||||
|
self.db.query(AuthSettings)
|
||||||
|
.filter(AuthSettings.key == self.OIDC_CONFIG_KEY)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if setting:
|
||||||
|
setting.value = json.dumps(config.to_dict())
|
||||||
|
setting.updated_at = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
setting = AuthSettings(
|
||||||
|
key=self.OIDC_CONFIG_KEY,
|
||||||
|
value=json.dumps(config.to_dict()),
|
||||||
|
)
|
||||||
|
self.db.add(setting)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Check if OIDC is enabled."""
|
||||||
|
config = self.get_config()
|
||||||
|
return config.enabled and bool(config.issuer_url) and bool(config.client_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_oidc_config_service(db: Session = Depends(get_db)) -> OIDCConfigService:
|
||||||
|
"""Get an OIDCConfigService instance."""
|
||||||
|
return OIDCConfigService(db)
|
||||||
|
|
||||||
|
|
||||||
|
# --- OIDC Authentication Flow ---
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCService:
|
||||||
|
"""Service for OIDC authentication flow."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session, config: OIDCConfig):
|
||||||
|
self.db = db
|
||||||
|
self.config = config
|
||||||
|
self._discovery_doc: Optional[dict] = None
|
||||||
|
|
||||||
|
def get_discovery_document(self) -> Optional[dict]:
|
||||||
|
"""Fetch and cache the OIDC discovery document."""
|
||||||
|
if self._discovery_doc:
|
||||||
|
return self._discovery_doc
|
||||||
|
|
||||||
|
if not self.config.discovery_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
response = httpx.get(self.config.discovery_url, timeout=10.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
self._discovery_doc = response.json()
|
||||||
|
return self._discovery_doc
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch OIDC discovery document: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_authorization_url(self, redirect_uri: str, state: str) -> Optional[str]:
|
||||||
|
"""Generate the OIDC authorization URL."""
|
||||||
|
discovery = self.get_discovery_document()
|
||||||
|
if not discovery:
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth_endpoint = discovery.get("authorization_endpoint")
|
||||||
|
if not auth_endpoint:
|
||||||
|
logger.error("No authorization_endpoint in discovery document")
|
||||||
|
return None
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"client_id": self.config.client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": " ".join(self.config.scopes),
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
|
||||||
|
return f"{auth_endpoint}?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
def exchange_code_for_tokens(
|
||||||
|
self, code: str, redirect_uri: str
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Exchange authorization code for tokens."""
|
||||||
|
discovery = self.get_discovery_document()
|
||||||
|
if not discovery:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_endpoint = discovery.get("token_endpoint")
|
||||||
|
if not token_endpoint:
|
||||||
|
logger.error("No token_endpoint in discovery document")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
response = httpx.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"client_id": self.config.client_id,
|
||||||
|
"client_secret": self.config.client_secret,
|
||||||
|
},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to exchange code for tokens: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_id_token(self, id_token: str) -> Optional[dict]:
|
||||||
|
"""Validate and decode the ID token."""
|
||||||
|
discovery = self.get_discovery_document()
|
||||||
|
if not discovery:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Get JWKS
|
||||||
|
jwks_uri = discovery.get("jwks_uri")
|
||||||
|
if not jwks_uri:
|
||||||
|
logger.error("No jwks_uri in discovery document")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response = httpx.get(jwks_uri, timeout=10.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
jwks = response.json()
|
||||||
|
|
||||||
|
# Get the key ID from the token header
|
||||||
|
unverified_header = jwt.get_unverified_header(id_token)
|
||||||
|
kid = unverified_header.get("kid")
|
||||||
|
|
||||||
|
# Find the matching key
|
||||||
|
rsa_key = None
|
||||||
|
for key in jwks.get("keys", []):
|
||||||
|
if key.get("kid") == kid:
|
||||||
|
rsa_key = key
|
||||||
|
break
|
||||||
|
|
||||||
|
if not rsa_key:
|
||||||
|
logger.error(f"No matching key found in JWKS for kid: {kid}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Decode and validate the token
|
||||||
|
payload = jwt.decode(
|
||||||
|
id_token,
|
||||||
|
rsa_key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience=self.config.client_id,
|
||||||
|
issuer=self.config.issuer_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
except JWTError as e:
|
||||||
|
logger.error(f"ID token validation failed: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error validating ID token: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_or_create_user(self, id_token_claims: dict) -> Optional[User]:
|
||||||
|
"""Get or create a user from ID token claims."""
|
||||||
|
# Extract user info from claims
|
||||||
|
subject = id_token_claims.get("sub")
|
||||||
|
email = id_token_claims.get("email")
|
||||||
|
name = id_token_claims.get("name") or id_token_claims.get("preferred_username")
|
||||||
|
|
||||||
|
if not subject:
|
||||||
|
logger.error("No 'sub' claim in ID token")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try to find existing user by OIDC subject
|
||||||
|
user = (
|
||||||
|
self.db.query(User)
|
||||||
|
.filter(
|
||||||
|
User.oidc_subject == subject,
|
||||||
|
User.oidc_issuer == self.config.issuer_url,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# Update last login
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Try to find by email and link accounts
|
||||||
|
if email:
|
||||||
|
user = self.db.query(User).filter(User.email == email).first()
|
||||||
|
if user:
|
||||||
|
# Link OIDC identity to existing user
|
||||||
|
user.oidc_subject = subject
|
||||||
|
user.oidc_issuer = self.config.issuer_url
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
logger.info(f"Linked OIDC identity to existing user: {user.username}")
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Create new user if auto-creation is enabled
|
||||||
|
if not self.config.auto_create_users:
|
||||||
|
logger.warning(f"Auto-creation disabled, rejecting new OIDC user: {subject}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Determine username (use email prefix or subject)
|
||||||
|
username = email.split("@")[0] if email else subject
|
||||||
|
|
||||||
|
# Check for username collision
|
||||||
|
existing = self.db.query(User).filter(User.username == username).first()
|
||||||
|
if existing:
|
||||||
|
# Append part of subject to make unique
|
||||||
|
username = f"{username}_{subject[:8]}"
|
||||||
|
|
||||||
|
# Check if user should be admin based on groups/roles
|
||||||
|
is_admin = False
|
||||||
|
if self.config.admin_group:
|
||||||
|
groups = id_token_claims.get("groups", [])
|
||||||
|
roles = id_token_claims.get("roles", [])
|
||||||
|
is_admin = (
|
||||||
|
self.config.admin_group in groups
|
||||||
|
or self.config.admin_group in roles
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the user
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
password_hash=None, # OIDC users don't have passwords
|
||||||
|
oidc_subject=subject,
|
||||||
|
oidc_issuer=self.config.issuer_url,
|
||||||
|
is_admin=is_admin,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
self.db.add(user)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user)
|
||||||
|
|
||||||
|
logger.info(f"Created new OIDC user: {username} (admin={is_admin})")
|
||||||
|
return user
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ from .schemas import (
|
|||||||
AccessPermissionCreate,
|
AccessPermissionCreate,
|
||||||
AccessPermissionUpdate,
|
AccessPermissionUpdate,
|
||||||
AccessPermissionResponse,
|
AccessPermissionResponse,
|
||||||
|
OIDCConfigResponse,
|
||||||
|
OIDCConfigUpdate,
|
||||||
|
OIDCStatusResponse,
|
||||||
|
OIDCLoginResponse,
|
||||||
)
|
)
|
||||||
from .metadata import extract_metadata
|
from .metadata import extract_metadata
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
@@ -631,6 +635,276 @@ def delete_api_key(
|
|||||||
return {"message": "API key deleted successfully"}
|
return {"message": "API key deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- OIDC Configuration Routes ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/auth/oidc/status")
|
||||||
|
def get_oidc_status(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get OIDC status (public endpoint).
|
||||||
|
Returns whether OIDC is enabled and the issuer URL if so.
|
||||||
|
"""
|
||||||
|
from .auth import OIDCConfigService
|
||||||
|
from .schemas import OIDCStatusResponse
|
||||||
|
|
||||||
|
oidc_service = OIDCConfigService(db)
|
||||||
|
config = oidc_service.get_config()
|
||||||
|
|
||||||
|
if config.enabled and config.issuer_url and config.client_id:
|
||||||
|
return OIDCStatusResponse(enabled=True, issuer_url=config.issuer_url)
|
||||||
|
return OIDCStatusResponse(enabled=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/auth/oidc/config")
|
||||||
|
def get_oidc_config(
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get OIDC configuration (admin only).
|
||||||
|
Client secret is not exposed.
|
||||||
|
"""
|
||||||
|
from .auth import OIDCConfigService
|
||||||
|
from .schemas import OIDCConfigResponse
|
||||||
|
|
||||||
|
oidc_service = OIDCConfigService(db)
|
||||||
|
config = oidc_service.get_config()
|
||||||
|
|
||||||
|
return OIDCConfigResponse(
|
||||||
|
enabled=config.enabled,
|
||||||
|
issuer_url=config.issuer_url,
|
||||||
|
client_id=config.client_id,
|
||||||
|
has_client_secret=bool(config.client_secret),
|
||||||
|
scopes=config.scopes,
|
||||||
|
auto_create_users=config.auto_create_users,
|
||||||
|
admin_group=config.admin_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/auth/oidc/config")
|
||||||
|
def update_oidc_config(
|
||||||
|
config_update: "OIDCConfigUpdate",
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update OIDC configuration (admin only).
|
||||||
|
"""
|
||||||
|
from .auth import OIDCConfigService, OIDCConfig
|
||||||
|
from .schemas import OIDCConfigUpdate, OIDCConfigResponse
|
||||||
|
|
||||||
|
oidc_service = OIDCConfigService(db)
|
||||||
|
current_config = oidc_service.get_config()
|
||||||
|
|
||||||
|
# Update only provided fields
|
||||||
|
new_config = OIDCConfig(
|
||||||
|
enabled=config_update.enabled if config_update.enabled is not None else current_config.enabled,
|
||||||
|
issuer_url=config_update.issuer_url if config_update.issuer_url is not None else current_config.issuer_url,
|
||||||
|
client_id=config_update.client_id if config_update.client_id is not None else current_config.client_id,
|
||||||
|
client_secret=config_update.client_secret if config_update.client_secret is not None else current_config.client_secret,
|
||||||
|
scopes=config_update.scopes if config_update.scopes is not None else current_config.scopes,
|
||||||
|
auto_create_users=config_update.auto_create_users if config_update.auto_create_users is not None else current_config.auto_create_users,
|
||||||
|
admin_group=config_update.admin_group if config_update.admin_group is not None else current_config.admin_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
oidc_service.save_config(new_config)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
_log_audit(
|
||||||
|
db,
|
||||||
|
"auth.oidc_config_update",
|
||||||
|
"oidc_config",
|
||||||
|
current_user.username,
|
||||||
|
request,
|
||||||
|
{"enabled": new_config.enabled, "issuer_url": new_config.issuer_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
return OIDCConfigResponse(
|
||||||
|
enabled=new_config.enabled,
|
||||||
|
issuer_url=new_config.issuer_url,
|
||||||
|
client_id=new_config.client_id,
|
||||||
|
has_client_secret=bool(new_config.client_secret),
|
||||||
|
scopes=new_config.scopes,
|
||||||
|
auto_create_users=new_config.auto_create_users,
|
||||||
|
admin_group=new_config.admin_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/auth/oidc/login")
|
||||||
|
def oidc_login(
|
||||||
|
request: Request,
|
||||||
|
redirect_uri: Optional[str] = Query(None, description="Override redirect URI"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initiate OIDC login flow.
|
||||||
|
Redirects to the OIDC provider's authorization endpoint.
|
||||||
|
"""
|
||||||
|
from .auth import OIDCConfigService, OIDCService
|
||||||
|
|
||||||
|
oidc_config_service = OIDCConfigService(db)
|
||||||
|
config = oidc_config_service.get_config()
|
||||||
|
|
||||||
|
if not config.enabled or not config.issuer_url or not config.client_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="OIDC is not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate state for CSRF protection
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# Determine redirect URI
|
||||||
|
if not redirect_uri:
|
||||||
|
# Use the request's base URL
|
||||||
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
redirect_uri = f"{base_url}/api/v1/auth/oidc/callback"
|
||||||
|
|
||||||
|
# Store state in session (using a simple cookie for now)
|
||||||
|
oidc_service = OIDCService(db, config)
|
||||||
|
auth_url = oidc_service.get_authorization_url(redirect_uri, state)
|
||||||
|
|
||||||
|
if not auth_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to generate authorization URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return redirect response with state cookie
|
||||||
|
response = RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
|
||||||
|
response.set_cookie(
|
||||||
|
key="oidc_state",
|
||||||
|
value=state,
|
||||||
|
httponly=True,
|
||||||
|
secure=request.url.scheme == "https",
|
||||||
|
samesite="lax",
|
||||||
|
max_age=600, # 10 minutes
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
key="oidc_redirect_uri",
|
||||||
|
value=redirect_uri,
|
||||||
|
httponly=True,
|
||||||
|
secure=request.url.scheme == "https",
|
||||||
|
samesite="lax",
|
||||||
|
max_age=600,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/auth/oidc/callback")
|
||||||
|
def oidc_callback(
|
||||||
|
request: Request,
|
||||||
|
code: str = Query(..., description="Authorization code"),
|
||||||
|
state: str = Query(..., description="State parameter"),
|
||||||
|
oidc_state: Optional[str] = Cookie(None),
|
||||||
|
oidc_redirect_uri: Optional[str] = Cookie(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Handle OIDC callback after user authenticates.
|
||||||
|
Exchanges the authorization code for tokens and creates a session.
|
||||||
|
"""
|
||||||
|
from .auth import OIDCConfigService, OIDCService, AuthService
|
||||||
|
|
||||||
|
# Verify state
|
||||||
|
if not oidc_state or state != oidc_state:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid state parameter",
|
||||||
|
)
|
||||||
|
|
||||||
|
oidc_config_service = OIDCConfigService(db)
|
||||||
|
config = oidc_config_service.get_config()
|
||||||
|
|
||||||
|
if not config.enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="OIDC is not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine redirect URI (must match what was used in login)
|
||||||
|
if not oidc_redirect_uri:
|
||||||
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
oidc_redirect_uri = f"{base_url}/api/v1/auth/oidc/callback"
|
||||||
|
|
||||||
|
oidc_service = OIDCService(db, config)
|
||||||
|
|
||||||
|
# Exchange code for tokens
|
||||||
|
tokens = oidc_service.exchange_code_for_tokens(code, oidc_redirect_uri)
|
||||||
|
if not tokens:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Failed to exchange authorization code",
|
||||||
|
)
|
||||||
|
|
||||||
|
id_token = tokens.get("id_token")
|
||||||
|
if not id_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No ID token in response",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate ID token
|
||||||
|
claims = oidc_service.validate_id_token(id_token)
|
||||||
|
if not claims:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid ID token",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get or create user
|
||||||
|
user = oidc_service.get_or_create_user(claims)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User creation not allowed",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User account is disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
auth_service = AuthService(db)
|
||||||
|
session, token = auth_service.create_session(
|
||||||
|
user,
|
||||||
|
user_agent=request.headers.get("User-Agent"),
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
_log_audit(
|
||||||
|
db,
|
||||||
|
"auth.oidc_login",
|
||||||
|
f"user:{user.id}",
|
||||||
|
user.username,
|
||||||
|
request,
|
||||||
|
{"oidc_subject": claims.get("sub")},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect to frontend with session cookie
|
||||||
|
response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
|
||||||
|
response.set_cookie(
|
||||||
|
key=SESSION_COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
secure=request.url.scheme == "https",
|
||||||
|
samesite="lax",
|
||||||
|
max_age=SESSION_DURATION_HOURS * 3600,
|
||||||
|
)
|
||||||
|
# Clear OIDC state cookies
|
||||||
|
response.delete_cookie("oidc_state")
|
||||||
|
response.delete_cookie("oidc_redirect_uri")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# --- Admin User Management Routes ---
|
# --- Admin User Management Routes ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -784,6 +784,40 @@ class APIKeyCreateResponse(BaseModel):
|
|||||||
expires_at: Optional[datetime]
|
expires_at: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
# OIDC Configuration schemas
|
||||||
|
class OIDCConfigResponse(BaseModel):
|
||||||
|
"""OIDC configuration response (hides client secret)"""
|
||||||
|
enabled: bool
|
||||||
|
issuer_url: str
|
||||||
|
client_id: str
|
||||||
|
has_client_secret: bool # True if secret is configured, but don't expose it
|
||||||
|
scopes: List[str]
|
||||||
|
auto_create_users: bool
|
||||||
|
admin_group: str
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCConfigUpdate(BaseModel):
|
||||||
|
"""Update OIDC configuration"""
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
issuer_url: Optional[str] = None
|
||||||
|
client_id: Optional[str] = None
|
||||||
|
client_secret: Optional[str] = None # Only set if changing
|
||||||
|
scopes: Optional[List[str]] = None
|
||||||
|
auto_create_users: Optional[bool] = None
|
||||||
|
admin_group: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCStatusResponse(BaseModel):
|
||||||
|
"""Public OIDC status response"""
|
||||||
|
enabled: bool
|
||||||
|
issuer_url: Optional[str] = None # Only included if enabled
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCLoginResponse(BaseModel):
|
||||||
|
"""OIDC login initiation response"""
|
||||||
|
authorization_url: str
|
||||||
|
|
||||||
|
|
||||||
# Access Permission schemas
|
# Access Permission schemas
|
||||||
class AccessPermissionCreate(BaseModel):
|
class AccessPermissionCreate(BaseModel):
|
||||||
"""Grant access to a user for a project"""
|
"""Grant access to a user for a project"""
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import ChangePasswordPage from './pages/ChangePasswordPage';
|
import ChangePasswordPage from './pages/ChangePasswordPage';
|
||||||
import APIKeysPage from './pages/APIKeysPage';
|
import APIKeysPage from './pages/APIKeysPage';
|
||||||
import AdminUsersPage from './pages/AdminUsersPage';
|
import AdminUsersPage from './pages/AdminUsersPage';
|
||||||
|
import AdminOIDCPage from './pages/AdminOIDCPage';
|
||||||
|
|
||||||
// Component that checks if user must change password
|
// Component that checks if user must change password
|
||||||
function RequirePasswordChange({ children }: { children: React.ReactNode }) {
|
function RequirePasswordChange({ children }: { children: React.ReactNode }) {
|
||||||
@@ -42,6 +43,7 @@ function AppRoutes() {
|
|||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/settings/api-keys" element={<APIKeysPage />} />
|
<Route path="/settings/api-keys" element={<APIKeysPage />} />
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
|
<Route path="/admin/oidc" element={<AdminOIDCPage />} />
|
||||||
<Route path="/project/:projectName" element={<ProjectPage />} />
|
<Route path="/project/:projectName" element={<ProjectPage />} />
|
||||||
<Route path="/project/:projectName/:packageName" element={<PackagePage />} />
|
<Route path="/project/:projectName/:packageName" element={<PackagePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import {
|
|||||||
AccessPermissionCreate,
|
AccessPermissionCreate,
|
||||||
AccessPermissionUpdate,
|
AccessPermissionUpdate,
|
||||||
AccessLevel,
|
AccessLevel,
|
||||||
|
OIDCConfig,
|
||||||
|
OIDCConfigUpdate,
|
||||||
|
OIDCStatus,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
const API_BASE = '/api/v1';
|
||||||
@@ -408,3 +411,35 @@ export async function revokeProjectAccess(projectName: string, username: string)
|
|||||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC API
|
||||||
|
export async function getOIDCStatus(): Promise<OIDCStatus> {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/oidc/status`);
|
||||||
|
return handleResponse<OIDCStatus>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOIDCConfig(): Promise<OIDCConfig> {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/oidc/config`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse<OIDCConfig>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOIDCConfig(data: OIDCConfigUpdate): Promise<OIDCConfig> {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/oidc/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return handleResponse<OIDCConfig>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOIDCLoginUrl(returnTo?: string): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (returnTo) {
|
||||||
|
params.set('return_to', returnTo);
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
return `${API_BASE}/auth/oidc/login${query ? `?${query}` : ''}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,19 +129,31 @@ function Layout({ children }: LayoutProps) {
|
|||||||
API Keys
|
API Keys
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{user.is_admin && (
|
{user.is_admin && (
|
||||||
<NavLink
|
<>
|
||||||
to="/admin/users"
|
<NavLink
|
||||||
className="user-menu-item"
|
to="/admin/users"
|
||||||
onClick={() => setShowUserMenu(false)}
|
className="user-menu-item"
|
||||||
>
|
onClick={() => setShowUserMenu(false)}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
>
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="9" cy="7" r="4"/>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
<circle cx="9" cy="7" r="4"/>
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
</svg>
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
User Management
|
</svg>
|
||||||
</NavLink>
|
User Management
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/admin/oidc"
|
||||||
|
className="user-menu-item"
|
||||||
|
onClick={() => setShowUserMenu(false)}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
SSO Configuration
|
||||||
|
</NavLink>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="user-menu-divider"></div>
|
<div className="user-menu-divider"></div>
|
||||||
<button className="user-menu-item" onClick={handleLogout}>
|
<button className="user-menu-item" onClick={handleLogout}>
|
||||||
|
|||||||
405
frontend/src/pages/AdminOIDCPage.css
Normal file
405
frontend/src/pages/AdminOIDCPage.css
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
.admin-oidc-page {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-header-content h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-subtitle {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-success {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--success-bg);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
animation: admin-oidc-fade-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes admin-oidc-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-error svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-error span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-error-dismiss {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--error);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-error-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-access-denied {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-access-denied-icon {
|
||||||
|
color: var(--error);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-access-denied h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-access-denied p {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-section:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-group input[type="text"],
|
||||||
|
.admin-oidc-form-group input[type="password"],
|
||||||
|
.admin-oidc-form-group input[type="url"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-group input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-group input:hover:not(:disabled) {
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-group input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-field-help {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-field-help code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-secret-status {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-toggle-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-toggle-label input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-toggle-custom {
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-toggle-custom::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
top: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom::after {
|
||||||
|
left: 22px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-toggle-label input[type="checkbox"]:focus + .admin-oidc-toggle-custom {
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-toggle-label:hover .admin-oidc-toggle-custom {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-cancel-button {
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-cancel-button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-cancel-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-submit-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-submit-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-submit-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-button-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: admin-oidc-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes admin-oidc-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-secondary);
|
||||||
|
border-top-color: var(--accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: admin-oidc-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-info-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-info-card h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-info-card p {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-oidc-callback-url {
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-oidc-form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
342
frontend/src/pages/AdminOIDCPage.tsx
Normal file
342
frontend/src/pages/AdminOIDCPage.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { getOIDCConfig, updateOIDCConfig } from '../api';
|
||||||
|
import { OIDCConfig } from '../types';
|
||||||
|
import './AdminOIDCPage.css';
|
||||||
|
|
||||||
|
function AdminOIDCPage() {
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [config, setConfig] = useState<OIDCConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [issuerUrl, setIssuerUrl] = useState('');
|
||||||
|
const [clientId, setClientId] = useState('');
|
||||||
|
const [clientSecret, setClientSecret] = useState('');
|
||||||
|
const [scopes, setScopes] = useState('openid profile email');
|
||||||
|
const [autoCreateUsers, setAutoCreateUsers] = useState(true);
|
||||||
|
const [adminGroup, setAdminGroup] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
navigate('/login', { state: { from: '/admin/oidc' } });
|
||||||
|
}
|
||||||
|
}, [user, authLoading, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.is_admin) {
|
||||||
|
loadConfig();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (successMessage) {
|
||||||
|
const timer = setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [successMessage]);
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getOIDCConfig();
|
||||||
|
setConfig(data);
|
||||||
|
setEnabled(data.enabled);
|
||||||
|
setIssuerUrl(data.issuer_url);
|
||||||
|
setClientId(data.client_id);
|
||||||
|
setScopes(data.scopes.join(' '));
|
||||||
|
setAutoCreateUsers(data.auto_create_users);
|
||||||
|
setAdminGroup(data.admin_group);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load OIDC configuration');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (enabled && !issuerUrl.trim()) {
|
||||||
|
setError('Issuer URL is required when OIDC is enabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enabled && !clientId.trim()) {
|
||||||
|
setError('Client ID is required when OIDC is enabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scopesList = scopes.split(/\s+/).filter(s => s.length > 0);
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
enabled,
|
||||||
|
issuer_url: issuerUrl.trim(),
|
||||||
|
client_id: clientId.trim(),
|
||||||
|
scopes: scopesList,
|
||||||
|
auto_create_users: autoCreateUsers,
|
||||||
|
admin_group: adminGroup.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clientSecret) {
|
||||||
|
updateData.client_secret = clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateOIDCConfig(updateData);
|
||||||
|
setSuccessMessage('OIDC configuration saved successfully');
|
||||||
|
setClientSecret('');
|
||||||
|
await loadConfig();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save OIDC configuration');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="admin-oidc-page">
|
||||||
|
<div className="admin-oidc-loading">
|
||||||
|
<div className="admin-oidc-spinner"></div>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.is_admin) {
|
||||||
|
return (
|
||||||
|
<div className="admin-oidc-page">
|
||||||
|
<div className="admin-oidc-access-denied">
|
||||||
|
<div className="admin-oidc-access-denied-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2>Access Denied</h2>
|
||||||
|
<p>You do not have permission to access this page. Admin privileges are required.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-oidc-page">
|
||||||
|
<div className="admin-oidc-header">
|
||||||
|
<div className="admin-oidc-header-content">
|
||||||
|
<h1>Single Sign-On (OIDC)</h1>
|
||||||
|
<p className="admin-oidc-subtitle">
|
||||||
|
Configure OpenID Connect for SSO authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<div className="admin-oidc-success">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>
|
||||||
|
<span>{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="admin-oidc-error">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={() => setError(null)} className="admin-oidc-error-dismiss">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="admin-oidc-card">
|
||||||
|
<div className="admin-oidc-loading">
|
||||||
|
<div className="admin-oidc-spinner"></div>
|
||||||
|
<span>Loading configuration...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSave} className="admin-oidc-card">
|
||||||
|
<div className="admin-oidc-section">
|
||||||
|
<h2>Status</h2>
|
||||||
|
<div className="admin-oidc-toggle-group">
|
||||||
|
<label className="admin-oidc-toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<span className="admin-oidc-toggle-custom"></span>
|
||||||
|
Enable OIDC Authentication
|
||||||
|
</label>
|
||||||
|
<p className="admin-oidc-field-help">
|
||||||
|
When enabled, users can sign in using your organization's identity provider.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-oidc-section">
|
||||||
|
<h2>Provider Configuration</h2>
|
||||||
|
|
||||||
|
<div className="admin-oidc-form-group">
|
||||||
|
<label htmlFor="issuer-url">Issuer URL</label>
|
||||||
|
<input
|
||||||
|
id="issuer-url"
|
||||||
|
type="url"
|
||||||
|
value={issuerUrl}
|
||||||
|
onChange={(e) => setIssuerUrl(e.target.value)}
|
||||||
|
placeholder="https://your-provider.com"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<p className="admin-oidc-field-help">
|
||||||
|
The base URL of your OIDC provider. Discovery document will be fetched from <code>/.well-known/openid-configuration</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-oidc-form-row">
|
||||||
|
<div className="admin-oidc-form-group">
|
||||||
|
<label htmlFor="client-id">Client ID</label>
|
||||||
|
<input
|
||||||
|
id="client-id"
|
||||||
|
type="text"
|
||||||
|
value={clientId}
|
||||||
|
onChange={(e) => setClientId(e.target.value)}
|
||||||
|
placeholder="your-client-id"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-oidc-form-group">
|
||||||
|
<label htmlFor="client-secret">
|
||||||
|
Client Secret
|
||||||
|
{config?.has_client_secret && (
|
||||||
|
<span className="admin-oidc-secret-status"> (configured)</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="client-secret"
|
||||||
|
type="password"
|
||||||
|
value={clientSecret}
|
||||||
|
onChange={(e) => setClientSecret(e.target.value)}
|
||||||
|
placeholder={config?.has_client_secret ? 'Leave blank to keep current' : 'Enter client secret'}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-oidc-form-group">
|
||||||
|
<label htmlFor="scopes">Scopes</label>
|
||||||
|
<input
|
||||||
|
id="scopes"
|
||||||
|
type="text"
|
||||||
|
value={scopes}
|
||||||
|
onChange={(e) => setScopes(e.target.value)}
|
||||||
|
placeholder="openid profile email"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<p className="admin-oidc-field-help">
|
||||||
|
Space-separated list of OIDC scopes to request. Common scopes: openid, profile, email, groups.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-oidc-section">
|
||||||
|
<h2>User Provisioning</h2>
|
||||||
|
|
||||||
|
<div className="admin-oidc-toggle-group">
|
||||||
|
<label className="admin-oidc-toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoCreateUsers}
|
||||||
|
onChange={(e) => setAutoCreateUsers(e.target.checked)}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<span className="admin-oidc-toggle-custom"></span>
|
||||||
|
Auto-create users on first login
|
||||||
|
</label>
|
||||||
|
<p className="admin-oidc-field-help">
|
||||||
|
When enabled, new users will be created automatically when they sign in via OIDC for the first time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-oidc-form-group">
|
||||||
|
<label htmlFor="admin-group">Admin Group (optional)</label>
|
||||||
|
<input
|
||||||
|
id="admin-group"
|
||||||
|
type="text"
|
||||||
|
value={adminGroup}
|
||||||
|
onChange={(e) => setAdminGroup(e.target.value)}
|
||||||
|
placeholder="admin, orchard-admins"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<p className="admin-oidc-field-help">
|
||||||
|
Users in this group (from the groups claim) will be granted admin privileges. Leave blank to disable automatic admin assignment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-oidc-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-oidc-cancel-button"
|
||||||
|
onClick={loadConfig}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="admin-oidc-submit-button"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<span className="admin-oidc-button-spinner"></span>
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Configuration'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-oidc-info-card">
|
||||||
|
<h3>Callback URL</h3>
|
||||||
|
<p>Configure your identity provider with the following callback URL:</p>
|
||||||
|
<code className="admin-oidc-callback-url">
|
||||||
|
{window.location.origin}/api/v1/auth/oidc/callback
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminOIDCPage;
|
||||||
@@ -214,6 +214,62 @@
|
|||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SSO Divider */
|
||||||
|
.login-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider::before,
|
||||||
|
.login-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider span {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SSO Button */
|
||||||
|
.login-sso-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 20px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-sso-button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-sso-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-sso-button svg {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.login-card {
|
.login-card {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { getOIDCStatus, getOIDCLoginUrl } from '../api';
|
||||||
|
import { OIDCStatus } from '../types';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
@@ -8,14 +10,37 @@ function LoginPage() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [oidcStatus, setOidcStatus] = useState<OIDCStatus | null>(null);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const { user, login, loading: authLoading } = useAuth();
|
const { user, login, loading: authLoading, refreshUser } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// Get the return URL from location state, default to home
|
// Get the return URL from location state, default to home
|
||||||
const from = (location.state as { from?: string })?.from || '/';
|
const from = (location.state as { from?: string })?.from || '/';
|
||||||
|
|
||||||
|
// Load OIDC status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
getOIDCStatus()
|
||||||
|
.then(setOidcStatus)
|
||||||
|
.catch(() => setOidcStatus({ enabled: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle SSO callback - check for oidc_success or oidc_error params
|
||||||
|
useEffect(() => {
|
||||||
|
const oidcSuccess = searchParams.get('oidc_success');
|
||||||
|
const oidcError = searchParams.get('oidc_error');
|
||||||
|
|
||||||
|
if (oidcSuccess === 'true') {
|
||||||
|
refreshUser().then(() => {
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
});
|
||||||
|
} else if (oidcError) {
|
||||||
|
setError(decodeURIComponent(oidcError));
|
||||||
|
}
|
||||||
|
}, [searchParams, refreshUser, navigate, from]);
|
||||||
|
|
||||||
// Redirect if already logged in
|
// Redirect if already logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && !authLoading) {
|
if (user && !authLoading) {
|
||||||
@@ -129,6 +154,25 @@ function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{oidcStatus?.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="login-divider">
|
||||||
|
<span>or</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={getOIDCLoginUrl(from !== '/' ? from : undefined)}
|
||||||
|
className="login-sso-button"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||||
|
<polyline points="10 17 15 12 10 7"/>
|
||||||
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with SSO
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-footer">
|
<div className="login-footer">
|
||||||
|
|||||||
@@ -329,3 +329,29 @@ export interface CurrentUser extends User {
|
|||||||
[projectId: string]: AccessLevel;
|
[projectId: string]: AccessLevel;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC types
|
||||||
|
export interface OIDCConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
issuer_url: string;
|
||||||
|
client_id: string;
|
||||||
|
has_client_secret: boolean;
|
||||||
|
scopes: string[];
|
||||||
|
auto_create_users: boolean;
|
||||||
|
admin_group: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OIDCConfigUpdate {
|
||||||
|
enabled?: boolean;
|
||||||
|
issuer_url?: string;
|
||||||
|
client_id?: string;
|
||||||
|
client_secret?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
auto_create_users?: boolean;
|
||||||
|
admin_group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OIDCStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
issuer_url?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user