portal: authenticate against Better Auth (public+PKCE); admin via email allowlist
Drops Authentik OIDC + the groups claim. Public PKCE client (Better Auth hashes confidential secrets), scopes openid/email/profile, admin determined by ADMIN_EMAILS.
This commit is contained in:
@@ -36,11 +36,17 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="authentik",
|
||||
name="oidc",
|
||||
server_metadata_url=f"{cfg.oidc_issuer}.well-known/openid-configuration",
|
||||
client_id=cfg.oidc_client_id,
|
||||
client_secret=cfg.oidc_client_secret,
|
||||
client_kwargs={"scope": cfg.oidc_scopes},
|
||||
# Public client: no secret, PKCE instead (Better Auth hashes confidential
|
||||
# secrets, so the portal is registered as a public + PKCE client).
|
||||
client_secret=cfg.oidc_client_secret or None,
|
||||
client_kwargs={
|
||||
"scope": cfg.oidc_scopes,
|
||||
"code_challenge_method": "S256",
|
||||
"token_endpoint_auth_method": "none",
|
||||
},
|
||||
)
|
||||
|
||||
app = FastAPI(title="Intervals.icu MCP portal", root_path=cfg.root_path)
|
||||
@@ -69,25 +75,25 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
|
||||
@app.get("/auth/login")
|
||||
async def auth_login(request: Request): # pragma: no cover - OIDC redirect (integration)
|
||||
return await oauth.authentik.authorize_redirect(request, cfg.oidc_redirect_url)
|
||||
return await oauth.oidc.authorize_redirect(request, cfg.oidc_redirect_url)
|
||||
|
||||
@app.get("/auth/callback")
|
||||
async def auth_callback(request: Request): # pragma: no cover - OIDC callback (integration)
|
||||
token = await oauth.authentik.authorize_access_token(request)
|
||||
token = await oauth.oidc.authorize_access_token(request)
|
||||
claims = dict(token.get("userinfo") or {})
|
||||
sub = claims.get("sub")
|
||||
if not sub:
|
||||
return redirect("/login")
|
||||
email = claims.get("email", "")
|
||||
name = claims.get("name")
|
||||
groups = claims.get("groups") or []
|
||||
async with db.sessionmaker()() as session:
|
||||
await db.upsert_login(session, sub, email, name)
|
||||
request.session["user"] = {
|
||||
"sub": sub,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"is_admin": cfg.admin_group in groups,
|
||||
# Admin is an email allowlist now (Better Auth has no group claims).
|
||||
"is_admin": bool(email) and email.lower() in cfg.admin_emails,
|
||||
}
|
||||
return redirect("/account")
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ from dataclasses import dataclass
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
database_url: str
|
||||
oidc_issuer: str # Authentik application base, e.g. https://auth.farh.net/application/o/<slug>/
|
||||
oidc_issuer: str # OIDC provider base, e.g. https://intervalsicu.farhoodlabs.com/api/auth/
|
||||
oidc_client_id: str
|
||||
oidc_client_secret: str
|
||||
oidc_redirect_url: str # https://<host>/auth/callback
|
||||
oidc_client_secret: str # empty for a public + PKCE client
|
||||
oidc_redirect_url: str # https://<host>/portal/auth/callback
|
||||
session_secret: str
|
||||
admin_group: str
|
||||
admin_emails: frozenset[str] # lower-cased allowlist; admin has no group claim
|
||||
intervals_api_base: str
|
||||
oidc_scopes: str
|
||||
root_path: str # public path prefix when served behind a gateway (e.g. "/portal")
|
||||
@@ -26,12 +26,14 @@ def load_config() -> Config:
|
||||
database_url=os.environ["DATABASE_URL"],
|
||||
oidc_issuer=os.environ["OIDC_ISSUER"].rstrip("/") + "/",
|
||||
oidc_client_id=os.environ["OIDC_CLIENT_ID"],
|
||||
oidc_client_secret=os.environ["OIDC_CLIENT_SECRET"],
|
||||
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET", ""),
|
||||
oidc_redirect_url=os.environ["OIDC_REDIRECT_URL"],
|
||||
session_secret=os.environ["SESSION_SECRET"],
|
||||
admin_group=os.environ.get("ADMIN_GROUP", "intervalsicu-mcp-admins"),
|
||||
admin_emails=frozenset(
|
||||
e.strip().lower() for e in os.environ.get("ADMIN_EMAILS", "").split(",") if e.strip()
|
||||
),
|
||||
intervals_api_base=os.environ.get("INTERVALS_API_BASE_URL", "https://intervals.icu/api/v1"),
|
||||
oidc_scopes=os.environ.get("OIDC_SCOPES", "openid email profile groups"),
|
||||
oidc_scopes=os.environ.get("OIDC_SCOPES", "openid email profile"),
|
||||
root_path=os.environ.get("ROOT_PATH", "").rstrip("/"),
|
||||
mcp_url=os.environ.get("MCP_URL", "https://intervalsicu.farhoodlabs.com/mcp"),
|
||||
)
|
||||
|
||||
+2
-2
@@ -38,9 +38,9 @@ def app_url(tmp_path, monkeypatch):
|
||||
oidc_client_secret="sec",
|
||||
oidc_redirect_url="https://ui.example/auth/callback",
|
||||
session_secret="x" * 32,
|
||||
admin_group="intervalsicu-mcp-admins",
|
||||
admin_emails=frozenset({"admin@x.com"}),
|
||||
intervals_api_base="https://intervals.icu/api/v1",
|
||||
oidc_scopes="openid email profile groups",
|
||||
oidc_scopes="openid email profile",
|
||||
root_path="",
|
||||
mcp_url="https://intervalsicu.farhoodlabs.com/mcp",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user