Files
alembic/app/routers/auth.py
T
andrew b135f11557 Security hardening and first-run/portability improvements
Security (P0):
- Remove committed session-secret default; auto-generate and persist a
  random secret to the config volume when SESSION_SECRET is unset
  (prevents forgeable session cookies / auth bypass).
- Validate playlist names to a safe charset and render sldl configs via
  literal Python substitution instead of sed (closes a command-injection
  and path-traversal path through playlist names).
- shlex-quote credential values written to shell-sourced env files, and
  strip newlines from values patched into .conf files.
- Render playlist .conf files 0600; warn at startup if the master key is
  co-located with the config volume; document keeping it separate.

Portability:
- Configurable timezone via TZ (default UTC) instead of hardcoded Edmonton.
- Remove personal defaults (navidrome user "andrew", ephemeral.club URLs).
- Ship generic example seeds; move the cross-album dedup keep-list and the
  legacy playlist import to editable config files; drop the personal
  _upgrade.csv.
- Generic VPN reference in docker-compose.snippet.yml.

First-run experience:
- Redirect to /setup instead of 500 when OIDC is unconfigured; surface a
  missing master key inline; entrypoint exits with an actionable message
  when the config folder is not writable.
- Add unauthenticated /health (JSON) and /setup (checklist) diagnostics.

Docs:
- Write docs/ARCHITECTURE.md and docs/MIGRATION.md (previously referenced
  but missing); expand README with ownership, backups, advanced settings,
  and migration guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:25:55 -06:00

60 lines
2.0 KiB
Python

import time
from fastapi import APIRouter, 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
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()
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="/")