Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ KKTIX_ORGANIZATION=example
# ── Dashboard (OAuth) ─────────────────────────────────────────────────────────

# Google OAuth 2.0 credentials — https://console.cloud.google.com/apis/credentials
# Authorized redirect URI must include: <your-domain>/dashboard/oauth/callback
# Authorized redirect URIs must include: <your-domain>/dashboard/oauth/callback
# and <your-domain>/dashboard/oauth/callback/spa
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=

Expand All @@ -37,3 +38,25 @@ SESSION_SECRET=

# Comma-separated email allowlist for dashboard access
ALLOWED_EMAILS=alice@example.com,bob@example.com

# Comma-separated list of frontend origins allowed to call the API cross-origin
# (e.g. "http://localhost:3000,https://dashboard.example.com"). Leave unset to
# disable CORS entirely — only the legacy same-origin dashboard will work.
# Do not use a wildcard (e.g. "*") or otherwise overly-broad value: any origin
# in this list can read /health and probe the API cross-origin. This isn't a
# CSRF risk (allow_credentials is never set) but is still unnecessary exposure
# — keep this an explicit, minimal list of real frontend origins.
FRONTEND_ORIGINS=

# Where the SPA OAuth callback (/dashboard/oauth/callback/spa) redirects after
# login, with the API token appended as a URL fragment (or ?error=... on
# failure). Must match a page the separated frontend actually serves.
# Must be a bare URL with no existing query string or fragment: the backend
# appends "?error=..." or "#token=..." via plain string concatenation, so a
# value that already contains "?" or "#" will produce a malformed redirect.
FRONTEND_REDIRECT_URL=

# Lifetime, in seconds, of tokens issued to the SPA frontend (default: 86400 = 24h).
# Logging out is client-side only (the token isn't server-revocable before it
# expires) — keep this short if that matters for your deployment.
AUTH_TOKEN_TTL_SECONDS=86400
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ uv sync
| `LOG_LEVEL` | `INFO` | Python application log level |
| `ALLOWED_EMAILS` | — | Comma-separated email allowlist for dashboard access |
| `ARGUS_HTTPS_ONLY` | `0` | Set to `1` to mark session cookies as Secure |
| `FRONTEND_ORIGINS` | — | Comma-separated list of frontend origins allowed to call the API cross-origin (e.g. `http://localhost:3000,https://dashboard.example.com`) |
| `FRONTEND_REDIRECT_URL` | — | Where the SPA OAuth callback redirects after login (with the API token appended as a URL fragment) |
| `AUTH_TOKEN_TTL_SECONDS` | `86400` | Lifetime in seconds of tokens issued to the SPA frontend (default: 24h) |

## Usage

Expand Down Expand Up @@ -76,7 +79,9 @@ A Google-OAuth-protected web UI for viewing per-event registration time series.
2. Create an **OAuth 2.0 Client ID** (Application type: **Web application**).
3. Under **Authorized redirect URIs**, add:
- `http://localhost:8000/dashboard/oauth/callback` (for local dev)
- `http://localhost:8000/dashboard/oauth/callback/spa` (for local dev, separated SPA frontend)
- `https://<your-deploy-domain>/dashboard/oauth/callback` (for production)
- `https://<your-deploy-domain>/dashboard/oauth/callback/spa` (for production, separated SPA frontend)
4. Copy the **Client ID** and **Client secret** into `.env` as `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`.
5. Generate a session secret:
```bash
Expand All @@ -95,6 +100,19 @@ uv run uvicorn argus.main:app --host 0.0.0.0 --port 8000

You will be redirected to Google to sign in. Only emails in `ALLOWED_EMAILS` are granted access.

### Separated frontend (SPA)

A future statically-exported (pure client-side) frontend, hosted on a
separate origin, can consume this backend's `/dashboard/api/*` JSON API
directly, authenticating via a Bearer token instead of the session cookie
used above. See [SPEC.md → SPA Authentication](SPEC.md#spa-authentication-separated-frontend)
for the full flow. Configure `FRONTEND_ORIGINS` and `FRONTEND_REDIRECT_URL`
to enable it.

The legacy server-rendered pages (`/dashboard`, `/dashboard/events/{slug}`,
`/dashboard/webhook-logs`) keep working unchanged and will be removed once
the separated frontend reaches parity.

## Production / Deployment

When deploying (e.g. to Railway):
Expand Down
72 changes: 61 additions & 11 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,25 @@ Argus uses a **vertical slice** layout: each feature owns its full stack (HTTP r
| `GET` | `/health` | — | Liveness + DB readiness check | [Health Check](#health-check) |
| `GET` | `/dashboard/login` | — | Start Google OAuth flow | [Dashboard](#dashboard) |
| `GET` | `/dashboard/oauth/callback` | — | OAuth redirect target | [Dashboard](#dashboard) |
| `GET` | `/dashboard/login/spa` | — | Start Google OAuth flow for the separated SPA frontend | [Dashboard](#dashboard) |
| `GET` | `/dashboard/oauth/callback/spa` | — | OAuth redirect target for the SPA; issues a Bearer token | [Dashboard](#dashboard) |
| `GET` | `/dashboard/api/me` | session or Bearer (401) | JSON: currently authenticated user's email | [Dashboard](#dashboard) |
| `GET` | `/dashboard/logout` | — | Clear session, redirect to login | [Dashboard](#dashboard) |
| `GET` | `/dashboard` | session (HTML) | Event list page | [Dashboard](#dashboard) |
| `GET` | `/dashboard/events/{slug}` | session (HTML) | Per-event chart page | [Dashboard](#dashboard) |
| `GET` | `/dashboard/webhook-logs` | session (HTML) | Webhook log viewer page | [Dashboard](#dashboard) |
| `GET` | `/dashboard/api/events` | session (401) | JSON: event list | [Dashboard](#dashboard) |
| `GET` | `/dashboard/api/events/{slug}/timeseries` | session (401) | JSON: per-event time series | [Dashboard](#dashboard) |
| `DELETE` | `/dashboard/api/events/{slug}` | session (401) | Permanently delete event + its tickets | [Dashboard](#dashboard) |
| `GET` | `/dashboard/api/webhook-logs` | session (401) | JSON: paginated webhook log entries | [Dashboard](#dashboard) |
| `DELETE` | `/dashboard/api/webhook-logs/{id}` | session (401) | Delete a single webhook log entry | [Dashboard](#dashboard) |
| `DELETE` | `/dashboard/api/webhook-logs` | session (401) | Clear all webhook log entries | [Dashboard](#dashboard) |
| `POST` | `/dashboard/api/report/trigger` | session (401) | Run the daily Discord report immediately | [Dashboard](#dashboard) |
| `GET` | `/dashboard/api/events` | session or Bearer (401) | JSON: event list | [Dashboard](#dashboard) |
| `GET` | `/dashboard/api/events/{slug}/timeseries` | session or Bearer (401) | JSON: per-event time series | [Dashboard](#dashboard) |
| `DELETE` | `/dashboard/api/events/{slug}` | session or Bearer (401) | Permanently delete event + its tickets | [Dashboard](#dashboard) |
| `GET` | `/dashboard/api/webhook-logs` | session or Bearer (401) | JSON: paginated webhook log entries | [Dashboard](#dashboard) |
| `DELETE` | `/dashboard/api/webhook-logs/{id}` | session or Bearer (401) | Delete a single webhook log entry | [Dashboard](#dashboard) |
| `DELETE` | `/dashboard/api/webhook-logs` | session or Bearer (401) | Clear all webhook log entries | [Dashboard](#dashboard) |
| `POST` | `/dashboard/api/report/trigger` | session or Bearer (401) | Run the daily Discord report immediately | [Dashboard](#dashboard) |

**Auth column legend:**
- `x-kktix-secret header` — request must include header matching `WEBHOOK_SECRET` (constant-time compared)
- `session (HTML)` — protected by signed session cookie; missing/invalid → 302 to `/dashboard/login`
- `session (401)` — same protection but JSON routes return 401 instead of redirecting
- `session or Bearer (401)` — protected by `require_login`, which accepts either the signed session cookie or an `Authorization: Bearer <token>` header; missing/invalid → 401 instead of redirecting

---

Expand Down Expand Up @@ -155,6 +158,9 @@ argus/
| `LOG_LEVEL` | `INFO` | Python application log level |
| `ALLOWED_EMAILS` | — | Comma-separated email allowlist for dashboard access |
| `ARGUS_HTTPS_ONLY` | `0` | Set to `1` to mark session cookies as Secure |
| `FRONTEND_ORIGINS` | — | Comma-separated list of frontend origins allowed to call the API cross-origin (e.g. `http://localhost:3000,https://dashboard.example.com`); leave unset to disable CORS |
| `FRONTEND_REDIRECT_URL` | — | Where the SPA OAuth callback redirects after login (with the API token appended as a URL fragment) |
| `AUTH_TOKEN_TTL_SECONDS` | `86400` | Lifetime in seconds of tokens issued to the SPA frontend (default: 86400 = 24h) |

Config is loaded at startup via `Settings.from_env()` and `Secrets.from_env()` in `config.py`. Secret values are masked in `__repr__`.

Expand Down Expand Up @@ -391,7 +397,7 @@ A web UI that visualizes registration trends per event over time. Implemented as

### Routes

See [API Reference](#api-reference) for the canonical list. All routes under `/dashboard/*` (except `login` and `oauth/callback`) require an authenticated session. HTML routes redirect to `/dashboard/login` on failure; JSON API routes return `401`.
See [API Reference](#api-reference) for the canonical list. All routes under `/dashboard/*` (except `login`, `oauth/callback`, `login/spa`, and `oauth/callback/spa`) require an authenticated session (or, for JSON API routes, a Bearer token). HTML routes redirect to `/dashboard/login` on failure; JSON API routes return `401`.

### Authentication

Expand All @@ -408,6 +414,49 @@ Server-side OAuth 2.0 with Google as the identity provider. After successful OAu

Session is signed using `SESSION_SECRET` via Starlette's `SessionMiddleware`.

### SPA Authentication (separated frontend)

A future separated frontend — a statically-exported (pure client-side) app
hosted on a different origin — cannot rely on the same-origin session
cookie used by the legacy server-rendered pages above. Instead it uses a
stateless Bearer token:

1. Frontend navigates the browser to `/dashboard/login/spa`.
2. Backend completes the Google OAuth flow exactly as above, but at
`/dashboard/oauth/callback/spa`. This path must also be registered as an
Authorized redirect URI in Google Cloud Console, alongside the legacy
`/dashboard/oauth/callback` (see [One-time Google OAuth setup](README.md#one-time-google-oauth-setup)).
3. On success, backend redirects to `{FRONTEND_REDIRECT_URL}#token=<token>`.
The token is a `URLSafeTimedSerializer`-signed payload (signed with
`SESSION_SECRET`, same secret as the session cookie), valid for
`AUTH_TOKEN_TTL_SECONDS`. On failure, redirects to
`{FRONTEND_REDIRECT_URL}?error=oauth_exchange_failed` or `?error=access_denied`.
4. Frontend reads the token from the URL fragment (never sent to any server —
not even the backend's own access logs), stores it client-side, and sends
it as `Authorization: Bearer <token>` on every subsequent API call. The
frontend should strip the fragment from the URL after reading it (e.g. via
`history.replaceState`) so the token doesn't linger in browser history.
5. `GET /dashboard/api/me` returns the authenticated email — used by the
frontend to bootstrap/validate its session on load.

Logout is client-side only (discard the token); there is no server-side
revocation before expiry. However, removing an address from `ALLOWED_EMAILS`
takes effect immediately for outstanding tokens too: `require_login`
re-checks the allowlist on every request, not just at token-mint time, so a
revoked email's existing tokens stop working as soon as the config changes.
All `/dashboard/api/*` routes accept either this Bearer token or the legacy
session cookie via the same `require_login` dependency — no route-specific
auth logic.

The token is signed (tamper-evident) but not encrypted: its payload,
including the email address, is base64-encoded plaintext and trivially
readable by anyone who has the token. Treat it as a bearer credential, not as
a container for secret data.

CORS is enabled only for origins listed in `FRONTEND_ORIGINS`; requests from
any other origin do not receive `Access-Control-Allow-Origin` and are blocked
by the browser.

### Time Series Computation

No new DB table. Time series is derived from existing `tickets` data using the same logic as report delta calculation.
Expand Down Expand Up @@ -485,8 +534,9 @@ Single Jinja2 template per page; charts rendered client-side with Chart.js (CDN,
| `SESSION_SECRET` | Yes | Random ≥32-byte hex string for signing session cookies |
| `ALLOWED_EMAILS` | Yes | Comma-separated allowlist, e.g. `alice@example.com,bob@example.com` |

Google OAuth redirect URI to register in Google Cloud Console:
`https://<your-domain>/dashboard/oauth/callback`
Google OAuth redirect URIs to register in Google Cloud Console:
`https://<your-domain>/dashboard/oauth/callback` and
`https://<your-domain>/dashboard/oauth/callback/spa`

### Dependencies (additions)

Expand Down
45 changes: 44 additions & 1 deletion src/argus/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from authlib.integrations.starlette_client import OAuth
from fastapi import HTTPException, Request, status
from itsdangerous import BadData, URLSafeTimedSerializer

from argus import config

Expand Down Expand Up @@ -33,14 +34,56 @@ def reset_oauth() -> None:
_oauth = None


_API_TOKEN_SALT = "argus-spa-api-token"


def issue_api_token(email: str) -> str:
"""Mint a signed, time-limited API token for the separated SPA frontend."""
serializer = URLSafeTimedSerializer(
config.secrets.require_session_secret(), salt=_API_TOKEN_SALT
)
return serializer.dumps({"email": email})


def verify_api_token(token: str) -> str | None:
"""Verify a signed API token and return its email, or None if invalid/expired."""
serializer = URLSafeTimedSerializer(
config.secrets.require_session_secret(), salt=_API_TOKEN_SALT
)
try:
data = serializer.loads(token, max_age=config.settings.auth_token_ttl_seconds)
except BadData:
return None
if not isinstance(data, dict):
return None
return data.get("email")


def is_email_allowed(email: str) -> bool:
if not email:
return False
return email.lower() in {e.lower() for e in config.settings.allowed_emails}


_BEARER_PREFIX = "Bearer "


async def require_login(request: Request) -> str:
"""FastAPI dependency for API routes. Returns email; raises 401 if not authed."""
"""FastAPI dependency for API routes. Returns email; raises 401 if not authed.

Accepts either a signed session cookie (the legacy server-rendered
dashboard) or an `Authorization: Bearer <token>` header (the separated
SPA frontend).
"""
auth_header = request.headers.get("authorization", "")
if auth_header[: len(_BEARER_PREFIX)].lower() == _BEARER_PREFIX.lower():
email = verify_api_token(auth_header[len(_BEARER_PREFIX) :])
if email and is_email_allowed(email):
return email
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="login_required"
)

user = request.session.get("user")
if not user or not user.get("email") or not is_email_allowed(user["email"]):
raise HTTPException(
Expand Down
10 changes: 10 additions & 0 deletions src/argus/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ class Settings:
healthcheck_db_timeout: float
kktix_organization: str
allowed_emails: tuple[str, ...]
frontend_origins: tuple[str, ...]
frontend_redirect_url: str
auth_token_ttl_seconds: int

@classmethod
def from_env(cls) -> "Settings":
Expand All @@ -29,6 +32,13 @@ def from_env(cls) -> "Settings":
for e in os.getenv("ALLOWED_EMAILS", "").split(",")
if e.strip()
),
frontend_origins=tuple(
o.strip()
for o in os.getenv("FRONTEND_ORIGINS", "").split(",")
if o.strip()
),
frontend_redirect_url=os.getenv("FRONTEND_REDIRECT_URL", ""),
auth_token_ttl_seconds=int(os.getenv("AUTH_TOKEN_TTL_SECONDS", "86400")),
)


Expand Down
60 changes: 58 additions & 2 deletions src/argus/dashboard/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@ async def login(request: Request):
return await auth.get_oauth().google.authorize_redirect(request, str(redirect_uri))


@router.get("/dashboard/oauth/callback", name="oauth_callback")
async def oauth_callback(request: Request):
async def _exchange_google_email(request: Request) -> str:
"""Exchange the OAuth authorization code for the verified Google email.

Raises HTTPException(400) if the exchange fails or no email is returned.
"""
try:
token = await auth.get_oauth().google.authorize_access_token(request)
except Exception as e:
Expand All @@ -59,6 +62,12 @@ async def oauth_callback(request: Request):
email = userinfo.get("email")
if not email:
raise HTTPException(status_code=400, detail="no_email_in_token")
return email


@router.get("/dashboard/oauth/callback", name="oauth_callback")
async def oauth_callback(request: Request):
email = await _exchange_google_email(request)

if not auth.is_email_allowed(email):
logger.warning("oauth: rejected email %s", email)
Expand All @@ -72,6 +81,48 @@ async def oauth_callback(request: Request):
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)


def _require_frontend_redirect_url() -> str:
"""Return the configured SPA redirect target, or raise 503 if unset."""
frontend_url = config.settings.frontend_redirect_url
if not frontend_url:
raise HTTPException(
status_code=503, detail="frontend_redirect_url_not_configured"
)
return frontend_url


@router.get("/dashboard/login/spa")
async def login_spa(request: Request):
_require_frontend_redirect_url()
redirect_uri = request.url_for("oauth_callback_spa")
return await auth.get_oauth().google.authorize_redirect(request, str(redirect_uri))


@router.get("/dashboard/oauth/callback/spa", name="oauth_callback_spa")
async def oauth_callback_spa(request: Request):
frontend_url = _require_frontend_redirect_url()
try:
email = await _exchange_google_email(request)
except HTTPException:
return RedirectResponse(
url=f"{frontend_url}?error=oauth_exchange_failed",
status_code=status.HTTP_302_FOUND,
)

if not auth.is_email_allowed(email):
logger.warning("oauth(spa): rejected email %s", email)
return RedirectResponse(
url=f"{frontend_url}?error=access_denied",
status_code=status.HTTP_302_FOUND,
)

api_token = auth.issue_api_token(email)
return RedirectResponse(
url=f"{frontend_url}#token={api_token}",
status_code=status.HTTP_302_FOUND,
)


@router.get("/dashboard/logout")
async def logout(request: Request):
request.session.clear()
Expand Down Expand Up @@ -116,6 +167,11 @@ async def dashboard_event(slug: str, request: Request):
# Protected by `Depends(auth.require_login)`. Returns 401 if not authenticated.


@router.get("/dashboard/api/me")
async def api_me(email: str = Depends(auth.require_login)):
return {"email": email}


@router.get("/dashboard/api/events")
async def api_events(_email: str = Depends(auth.require_login)):
return queries.list_events()
Expand Down
9 changes: 9 additions & 0 deletions src/argus/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware

from argus import config
Expand Down Expand Up @@ -58,6 +59,14 @@ async def lifespan(_app: FastAPI):
https_only=os.getenv("ARGUS_HTTPS_ONLY", "0") == "1",
)

if config.settings.frontend_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=list(config.settings.frontend_origins),
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)

app.include_router(kktix_router)
app.include_router(dashboard_router)
app.include_router(health_router)
Expand Down
Loading