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 = 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,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
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user