portal: authenticate against Better Auth (public+PKCE); admin via email allowlist
build-image / test (push) Successful in 10s
build-image / build (push) Successful in 19s

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:
2026-07-05 22:14:43 -04:00
parent 3908251f6e
commit c6c602fd20
4 changed files with 24 additions and 16 deletions
BIN
View File
Binary file not shown.
+13 -7
View File
@@ -36,11 +36,17 @@ def create_app(config: Config | None = None) -> FastAPI:
oauth = OAuth() oauth = OAuth()
oauth.register( oauth.register(
name="authentik", name="oidc",
server_metadata_url=f"{cfg.oidc_issuer}.well-known/openid-configuration", server_metadata_url=f"{cfg.oidc_issuer}.well-known/openid-configuration",
client_id=cfg.oidc_client_id, client_id=cfg.oidc_client_id,
client_secret=cfg.oidc_client_secret, # Public client: no secret, PKCE instead (Better Auth hashes confidential
client_kwargs={"scope": cfg.oidc_scopes}, # 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) 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") @app.get("/auth/login")
async def auth_login(request: Request): # pragma: no cover - OIDC redirect (integration) 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") @app.get("/auth/callback")
async def auth_callback(request: Request): # pragma: no cover - OIDC callback (integration) 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 {}) claims = dict(token.get("userinfo") or {})
sub = claims.get("sub") sub = claims.get("sub")
if not sub: if not sub:
return redirect("/login") return redirect("/login")
email = claims.get("email", "") email = claims.get("email", "")
name = claims.get("name") name = claims.get("name")
groups = claims.get("groups") or []
async with db.sessionmaker()() as session: async with db.sessionmaker()() as session:
await db.upsert_login(session, sub, email, name) await db.upsert_login(session, sub, email, name)
request.session["user"] = { request.session["user"] = {
"sub": sub, "sub": sub,
"email": email, "email": email,
"name": name, "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") return redirect("/account")
+9 -7
View File
@@ -9,12 +9,12 @@ from dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)
class Config: class Config:
database_url: str 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_id: str
oidc_client_secret: str oidc_client_secret: str # empty for a public + PKCE client
oidc_redirect_url: str # https://<host>/auth/callback oidc_redirect_url: str # https://<host>/portal/auth/callback
session_secret: str session_secret: str
admin_group: str admin_emails: frozenset[str] # lower-cased allowlist; admin has no group claim
intervals_api_base: str intervals_api_base: str
oidc_scopes: str oidc_scopes: str
root_path: str # public path prefix when served behind a gateway (e.g. "/portal") 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"], database_url=os.environ["DATABASE_URL"],
oidc_issuer=os.environ["OIDC_ISSUER"].rstrip("/") + "/", oidc_issuer=os.environ["OIDC_ISSUER"].rstrip("/") + "/",
oidc_client_id=os.environ["OIDC_CLIENT_ID"], 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"], oidc_redirect_url=os.environ["OIDC_REDIRECT_URL"],
session_secret=os.environ["SESSION_SECRET"], 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"), 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("/"), root_path=os.environ.get("ROOT_PATH", "").rstrip("/"),
mcp_url=os.environ.get("MCP_URL", "https://intervalsicu.farhoodlabs.com/mcp"), mcp_url=os.environ.get("MCP_URL", "https://intervalsicu.farhoodlabs.com/mcp"),
) )
+2 -2
View File
@@ -38,9 +38,9 @@ def app_url(tmp_path, monkeypatch):
oidc_client_secret="sec", oidc_client_secret="sec",
oidc_redirect_url="https://ui.example/auth/callback", oidc_redirect_url="https://ui.example/auth/callback",
session_secret="x" * 32, session_secret="x" * 32,
admin_group="intervalsicu-mcp-admins", admin_emails=frozenset({"admin@x.com"}),
intervals_api_base="https://intervals.icu/api/v1", intervals_api_base="https://intervals.icu/api/v1",
oidc_scopes="openid email profile groups", oidc_scopes="openid email profile",
root_path="", root_path="",
mcp_url="https://intervalsicu.farhoodlabs.com/mcp", mcp_url="https://intervalsicu.farhoodlabs.com/mcp",
) )