Files
alembic/app/routers/auth.py
T
andrew 2641a1b874 P2 cleanups: pin deps, prune job-run rows, unmask non-secret creds, enforce allowlist at login
- R11: pin requirements.txt to the versions running in the image, so a rebuild
  can't pull a breaking upstream release. Documented how to upgrade.
- R12: log rotation now also prunes job_runs rows older than 90 days (nulling
  the manual_imports FK reference first), so the table doesn't grow without
  bound. dedup/genre candidates are review state and left alone.
- U5: credential form shows non-secret fields (URLs, usernames, prefs) as plain
  text so they can be verified while typing; only real secrets stay masked.
  Dashboard IP card reworded from gluetun-specific to generic "Outbound IP".
- S9: enforce ALLOWED_EMAIL at the OIDC callback, before any user row or session
  is created, instead of only on later requests.

Verified: pinned build resolves; prune deletes only old rows and keeps the
manual_imports record; credentials page renders text+password inputs; a
disallowed email gets 403 with no user row, an allowed one succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:44:31 -06:00

67 lines
2.4 KiB
Python

import time
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.db import SessionLocal
from app.models import User
from app.security.oidc import oauth
from app.services import diagnostics
from app.settings import settings
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/login")
async def login(request: Request):
# If OIDC isn't configured, the client was never registered and calling it
# would raise an opaque 500. Send the operator to the setup check instead,
# which spells out exactly which variables are missing.
if not diagnostics.oidc_configured():
return RedirectResponse(url="/setup")
redirect_uri = request.url_for("auth_callback")
return await oauth.pocketid.authorize_redirect(request, redirect_uri)
@router.get("/callback", name="auth_callback")
async def auth_callback(request: Request):
token = await oauth.pocketid.authorize_access_token(request)
userinfo = token.get("userinfo") or await oauth.pocketid.userinfo(token=token)
sub = userinfo["sub"]
email = userinfo.get("email")
name = userinfo.get("name")
now = time.time()
# Enforce the allowlist here, before creating any user row or session, so a
# disallowed identity never gets persisted or a cookie (require_auth also
# checks it on every request, but that's after the fact).
if settings.allowed_email and email != settings.allowed_email:
raise HTTPException(status_code=403, detail="This account is not authorized to use alembic.")
with SessionLocal() as db:
user = db.execute(select(User).where(User.oidc_sub == sub)).scalar_one_or_none()
if user is None:
user = User(oidc_sub=sub, email=email, name=name, last_login_at=now)
db.add(user)
else:
user.email = email
user.name = name
user.last_login_at = now
db.commit()
request.session["user"] = {"sub": sub, "email": email, "name": name}
return RedirectResponse(url="/")
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
end_session_endpoint = None
metadata = await oauth.pocketid.load_server_metadata()
end_session_endpoint = metadata.get("end_session_endpoint")
if end_session_endpoint:
return RedirectResponse(url=end_session_endpoint)
return RedirectResponse(url="/")