Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/oauth/google/callback
FRONTEND_URL=http://localhost:5173

# Generic OIDC SSO (Authelia, Keycloak, Authentik, Okta, etc.)
# Leave OIDC_ISSUER_URL / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET blank to hide
# the SSO button. Endpoints are auto-discovered from
# {OIDC_ISSUER_URL}/.well-known/openid-configuration.
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=http://localhost:8000/api/v1/auth/oauth/oidc/callback
OIDC_SCOPES=openid email profile
# Display name shown on the login button ("Continue with <name>").
OIDC_PROVIDER_NAME=SSO

# =============================================================================
# REQUIRED: Agent platform encryption key
# =============================================================================
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,10 +329,11 @@ make dev-infra && make db-upgrade
- [x] API keys + webhooks
- [x] Packs, pinned, search
- [x] Per-user undo & redo (Phase 1)
- [x] AI agents (supervisor + GitHub repo researcher, Langfuse tracing)
- [x] Per-diagram export — Mermaid / PlantUML / Structurizr DSL / JSON
- [x] SSO (OIDC — Authelia, Keycloak, Authentik, Okta, …)
- [ ] Per-user undo — stale-detection (Phase 2)
- [ ] Import from Structurizr DSL
- [ ] Export to Mermaid / PlantUML
- [ ] SSO (OIDC)
- [ ] Deployment diagrams (C4 L4)

See [`docs/architecture/`](docs/architecture/) for ADRs and ongoing design.
Expand Down
19 changes: 19 additions & 0 deletions backend/app/api/v1/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_current_user
from app.core.config import settings
from app.core.database import get_db
from app.core.security import (
create_access_token,
Expand All @@ -18,6 +19,24 @@
router = APIRouter(prefix="/auth", tags=["auth"])


@router.get("/config")
async def auth_config():
"""Public — tells the SPA which SSO buttons to render.

Read on the login page so we can hide buttons whose backend creds are
blank instead of showing buttons that 503.
"""
return {
"google_enabled": bool(settings.google_client_id and settings.google_client_secret),
"oidc_enabled": bool(
settings.oidc_issuer_url
and settings.oidc_client_id
and settings.oidc_client_secret
),
"oidc_provider_name": settings.oidc_provider_name,
}


@router.post("/register", response_model=TokenResponse, status_code=201)
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.email == data.email))
Expand Down
20 changes: 19 additions & 1 deletion backend/app/api/v1/oauth_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, FRONTEND_URL). When any is
missing both endpoints return 503 so the SPA can fall back to email/password.
"""
import logging
from urllib.parse import urlencode

import httpx
Expand All @@ -24,6 +25,8 @@
from app.models.user import User
from app.services import workspace_service

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/auth/oauth", tags=["oauth"])

GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
Expand Down Expand Up @@ -67,7 +70,15 @@ async def callback(
"grant_type": "authorization_code",
})
if token_resp.status_code != 200:
raise HTTPException(400, f"Google token exchange failed: {token_resp.text}")
# Generic error to the client; full provider response logged
# server-side. Same hardening as the OIDC flow — don't leak
# client_id / scope details into the browser.
logger.warning(
"Google token exchange failed: status=%s body=%s",
token_resp.status_code,
token_resp.text,
)
raise HTTPException(400, "Google token exchange failed")
google_access = token_resp.json().get("access_token")

ui_resp = await client.get(
Expand All @@ -81,6 +92,13 @@ async def callback(
email = info.get("email")
if not email:
raise HTTPException(400, "Google account returned no email")
# Google's userinfo always includes verified_email for Google-hosted
# accounts, but Workspace admins can let users add unverified addresses.
# Without this check, an attacker with an unverified Google-side email
# could claim an arbitrary address and take over an existing local
# account in the upsert below.
if not info.get("verified_email", False):
raise HTTPException(400, "Google email is not verified")
name = info.get("name") or email.split("@")[0].title()

existing = (
Expand Down
183 changes: 183 additions & 0 deletions backend/app/api/v1/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Generic OIDC SSO — Authorization Code flow.

GET /api/v1/auth/oauth/oidc/login
→ 302 to the provider's authorization endpoint (or 503 if not configured).
GET /api/v1/auth/oauth/oidc/callback?code=...
→ exchange code → userinfo → upsert user → issue app JWTs
→ 302 to frontend /auth/callback with tokens in URL fragment.

Works with any OIDC-compliant provider (Authelia, Keycloak, Authentik, Okta,
Google, etc.). Endpoints are discovered from
``{OIDC_ISSUER_URL}/.well-known/openid-configuration``; we cache the document
in-process per issuer so we don't hammer the IdP on every login click.

Configured via OIDC_* env vars in app/core/config.py. When any of issuer_url,
client_id, or client_secret is missing both endpoints return 503 so the SPA
can fall back to email/password.

Mirrors the Google OAuth pattern in oauth_stub.py (same user upsert, same
fragment-based token delivery) — chose composition over abstraction to keep
each provider's quirks contained.
"""
import logging
from urllib.parse import urlencode

import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import settings
from app.core.database import get_db
from app.core.security import create_access_token, create_refresh_token, hash_password
from app.models.user import User
from app.services import workspace_service

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/auth/oauth", tags=["oauth"])

# Discovery doc cache keyed by issuer URL. OIDC discovery responses are stable
# and the IdP signals rotation via the keys endpoint, not this doc — caching
# for the process lifetime is the standard pattern. Cleared by tests.
_discovery_cache: dict[str, dict] = {}

# Endpoints we require from the discovery document. If any is missing the
# provider isn't usable — fail at discovery time with 502 instead of throwing
# KeyError later when we try to dereference it.
_REQUIRED_DISCOVERY_KEYS = (
"authorization_endpoint",
"token_endpoint",
"userinfo_endpoint",
)


def _oidc_enabled() -> bool:
return bool(
settings.oidc_issuer_url
and settings.oidc_client_id
and settings.oidc_client_secret
)


async def _get_discovery(client: httpx.AsyncClient) -> dict:
issuer = settings.oidc_issuer_url
cached = _discovery_cache.get(issuer)
if cached is not None:
return cached
url = f"{issuer.rstrip('/')}/.well-known/openid-configuration"
resp = await client.get(url)
if resp.status_code != 200:
raise HTTPException(502, f"OIDC discovery failed: {resp.status_code}")
doc = resp.json()
missing = [k for k in _REQUIRED_DISCOVERY_KEYS if not doc.get(k)]
if missing:
raise HTTPException(
502, f"OIDC discovery doc missing required endpoint(s): {', '.join(missing)}"
)
_discovery_cache[issuer] = doc
return doc


@router.get("/oidc/login")
async def oidc_login():
if not _oidc_enabled():
raise HTTPException(503, "OIDC not configured")
async with httpx.AsyncClient(timeout=10) as client:
disc = await _get_discovery(client)
qs = urlencode({
"client_id": settings.oidc_client_id,
"redirect_uri": settings.oidc_redirect_uri,
"response_type": "code",
"scope": settings.oidc_scopes,
})
return RedirectResponse(f"{disc['authorization_endpoint']}?{qs}")


@router.get("/oidc/callback")
async def oidc_callback(
code: str = Query(...),
db: AsyncSession = Depends(get_db),
):
if not _oidc_enabled():
raise HTTPException(503, "OIDC not configured")

async with httpx.AsyncClient(timeout=10) as client:
disc = await _get_discovery(client)

token_resp = await client.post(
disc["token_endpoint"],
data={
"code": code,
"client_id": settings.oidc_client_id,
"client_secret": settings.oidc_client_secret,
"redirect_uri": settings.oidc_redirect_uri,
"grant_type": "authorization_code",
},
)
if token_resp.status_code != 200:
# Log the provider's response server-side for operators; return a
# generic message to the user so we don't leak provider config
# (client_id, missing scopes, etc.) into a browser response.
logger.warning(
"OIDC token exchange failed: status=%s body=%s",
token_resp.status_code,
token_resp.text,
)
raise HTTPException(400, "OIDC token exchange failed")
provider_access = token_resp.json().get("access_token")
if not provider_access:
raise HTTPException(400, "OIDC token response missing access_token")

ui_resp = await client.get(
disc["userinfo_endpoint"],
headers={"Authorization": f"Bearer {provider_access}"},
)
if ui_resp.status_code != 200:
logger.warning(
"OIDC userinfo fetch failed: status=%s body=%s",
ui_resp.status_code,
ui_resp.text,
)
raise HTTPException(400, "OIDC userinfo fetch failed")
info = ui_resp.json()

email = info.get("email")
if not email:
raise HTTPException(400, "OIDC account returned no email claim")
# Reject if the IdP can't vouch that the user actually controls this
# address. Without this check, an attacker with control of any OIDC IdP
# (or a user with an unverified email on a public IdP) could claim an
# arbitrary email and take over an existing local account in the upsert
# below. Default-deny: if the provider doesn't send the claim at all,
# treat it as not-verified.
if not info.get("email_verified", False):
raise HTTPException(400, "OIDC email is not verified by the provider")
name = info.get("name") or email.split("@")[0].title()

existing = (
await db.execute(select(User).where(User.email == email))
).scalar_one_or_none()

if existing is None:
user = User(
email=email,
name=name,
# Random hash no one can log in with — they must keep using SSO.
password_hash=hash_password("oidc-only:" + email),
auth_provider="oidc",
)
db.add(user)
await db.flush()
await db.refresh(user)
await workspace_service.create_personal_workspace(db, user)
else:
user = existing

# Fragment-based delivery so tokens never show up in server access logs.
frag = urlencode({
"access_token": create_access_token(str(user.id)),
"refresh_token": create_refresh_token(str(user.id)),
})
return RedirectResponse(f"{settings.frontend_url}/auth/callback#{frag}")
13 changes: 13 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ class Settings(BaseSettings):
google_redirect_uri: str = "http://localhost:8000/api/v1/auth/oauth/google/callback"
frontend_url: str = "http://localhost:5173"

# Generic OIDC SSO (opt-in — works with Authelia, Keycloak, Authentik,
# Okta, etc.). Endpoints are discovered from
# {OIDC_ISSUER_URL}/.well-known/openid-configuration at request time.
# Leave issuer/client_id/secret blank to hide the SSO button.
oidc_issuer_url: str | None = None
oidc_client_id: str | None = None
oidc_client_secret: str | None = None
oidc_redirect_uri: str = "http://localhost:8000/api/v1/auth/oauth/oidc/callback"
oidc_scopes: str = "openid email profile"
# Display name shown on the "Continue with …" button. Defaults to a
# generic label; set to "Authelia", "Keycloak", "Okta", etc. to brand it.
oidc_provider_name: str = "SSO"

# Agent platform — Fernet key for encrypting workspace LLM provider keys + Langfuse keys.
# Must be a 32-byte url-safe base64-encoded string (44 chars).
# Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # noqa: E501
Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from app.api.v1.notifications import router as notifications_router
from app.api.v1.oauth_stub import router as oauth_router
from app.api.v1.objects import router as objects_router
from app.api.v1.oidc import router as oidc_router
from app.api.v1.packs import router as packs_router
from app.api.v1.repos import router as repos_router
from app.api.v1.teams import router as teams_router
Expand Down Expand Up @@ -96,6 +97,7 @@ def create_app() -> FastAPI:
app.include_router(technologies_router, prefix="/api/v1")
app.include_router(diagram_access_router, prefix="/api/v1")
app.include_router(oauth_router, prefix="/api/v1")
app.include_router(oidc_router, prefix="/api/v1")
app.include_router(invites_router, prefix="/api/v1")
app.include_router(my_invites_router, prefix="/api/v1")
app.include_router(versions_router, prefix="/api/v1")
Expand Down
Loading
Loading