Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
4.0 KiB
Python
125 lines
4.0 KiB
Python
import logging
|
|
import urllib.parse
|
|
|
|
import httpx
|
|
|
|
from scribe.config import Config
|
|
from scribe.models import async_session
|
|
from scribe.models.user import User
|
|
from sqlalchemy import select
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_oidc_config: dict | None = None
|
|
|
|
|
|
async def get_oidc_config() -> dict:
|
|
global _oidc_config
|
|
if _oidc_config is not None:
|
|
return _oidc_config
|
|
|
|
discovery_url = Config.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration"
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(discovery_url, follow_redirects=True)
|
|
resp.raise_for_status()
|
|
_oidc_config = resp.json()
|
|
logger.info("OIDC config loaded from %s", discovery_url)
|
|
return _oidc_config
|
|
|
|
|
|
async def build_auth_url(state: str, code_challenge: str) -> str:
|
|
oidc = await get_oidc_config()
|
|
redirect_uri = Config.BASE_URL.rstrip("/") + "/api/auth/oauth/callback"
|
|
params = {
|
|
"response_type": "code",
|
|
"client_id": Config.OIDC_CLIENT_ID,
|
|
"redirect_uri": redirect_uri,
|
|
"scope": Config.OIDC_SCOPES,
|
|
"state": state,
|
|
"code_challenge": code_challenge,
|
|
"code_challenge_method": "S256",
|
|
}
|
|
return oidc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
|
|
|
|
|
|
async def exchange_code(code: str, redirect_uri: str, code_verifier: str) -> dict:
|
|
oidc = await get_oidc_config()
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(
|
|
oidc["token_endpoint"],
|
|
data={
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": redirect_uri,
|
|
"client_id": Config.OIDC_CLIENT_ID,
|
|
"client_secret": Config.OIDC_CLIENT_SECRET,
|
|
"code_verifier": code_verifier,
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def get_userinfo(access_token: str) -> dict:
|
|
oidc = await get_oidc_config()
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(
|
|
oidc["userinfo_endpoint"],
|
|
headers={"Authorization": f"Bearer {access_token}"},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def find_or_create_oauth_user(
|
|
sub: str, email: str, preferred_username: str
|
|
) -> User:
|
|
async with async_session() as session:
|
|
# 1. Look up by oauth_sub
|
|
result = await session.execute(select(User).where(User.oauth_sub == sub))
|
|
user = result.scalars().first()
|
|
if user:
|
|
return user
|
|
|
|
# 2. Look up by email — auto-link
|
|
if email:
|
|
from sqlalchemy import func
|
|
result = await session.execute(
|
|
select(User).where(func.lower(User.email) == email.lower())
|
|
)
|
|
user = result.scalars().first()
|
|
if user:
|
|
user.oauth_sub = sub
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
logger.info(
|
|
"Linked OAuth sub %s to existing user %d (%s) via email",
|
|
sub, user.id, user.username,
|
|
)
|
|
return user
|
|
|
|
# 3. Create new user — pick a non-colliding username
|
|
base_username = preferred_username or (email.split("@")[0] if email else "user")
|
|
candidate = base_username
|
|
suffix = 2
|
|
while True:
|
|
result = await session.execute(
|
|
select(User).where(User.username == candidate)
|
|
)
|
|
if not result.scalars().first():
|
|
break
|
|
candidate = f"{base_username}_{suffix}"
|
|
suffix += 1
|
|
|
|
user = User(
|
|
username=candidate,
|
|
email=email or None,
|
|
password_hash=None,
|
|
oauth_sub=sub,
|
|
)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
logger.info("Auto-provisioned OAuth user %d (%s) for sub %s", user.id, user.username, sub)
|
|
return user
|