diff --git a/.env.example b/.env.example index b1c9ba6..c3eb6e3 100644 --- a/.env.example +++ b/.env.example @@ -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: /dashboard/oauth/callback +# Authorized redirect URIs must include: /dashboard/oauth/callback +# and /dashboard/oauth/callback/spa GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= @@ -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 diff --git a/README.md b/README.md index 1c26f73..08d18fa 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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:///dashboard/oauth/callback` (for production) + - `https:///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 @@ -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): diff --git a/SPEC.md b/SPEC.md index bc8d94b..1b3684a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 ` header; missing/invalid → 401 instead of redirecting --- @@ -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__`. @@ -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 @@ -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=`. + 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 ` 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. @@ -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:///dashboard/oauth/callback` +Google OAuth redirect URIs to register in Google Cloud Console: +`https:///dashboard/oauth/callback` and +`https:///dashboard/oauth/callback/spa` ### Dependencies (additions) diff --git a/src/argus/auth.py b/src/argus/auth.py index ab13f6a..349efd9 100644 --- a/src/argus/auth.py +++ b/src/argus/auth.py @@ -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 @@ -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 ` 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( diff --git a/src/argus/config.py b/src/argus/config.py index 93c2a2b..02dfa8c 100644 --- a/src/argus/config.py +++ b/src/argus/config.py @@ -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": @@ -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")), ) diff --git a/src/argus/dashboard/router.py b/src/argus/dashboard/router.py index 9229ee4..73f9814 100644 --- a/src/argus/dashboard/router.py +++ b/src/argus/dashboard/router.py @@ -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: @@ -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) @@ -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() @@ -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() diff --git a/src/argus/main.py b/src/argus/main.py index a1e11d0..82c212f 100644 --- a/src/argus/main.py +++ b/src/argus/main.py @@ -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 @@ -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) diff --git a/tests/test_auth.py b/tests/test_auth.py index b9a3a02..cd2d576 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ from dataclasses import replace from urllib.parse import urlsplit -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from oidc_provider_mock import User, run_server_in_thread from starlette.middleware.sessions import SessionMiddleware import httpx @@ -17,12 +17,18 @@ def dashboard_app(monkeypatch): monkeypatch.setattr( config, "settings", - replace(config.settings, allowed_emails=("chester@example.com",)), + replace( + config.settings, + allowed_emails=("chester@example.com",), + frontend_redirect_url="http://localhost:3000/auth/callback", + ), ) monkeypatch.setattr( config, "secrets", - config.Secrets("", "test-client-id", "test-client-secret", ""), + config.Secrets( + "", "test-client-id", "test-client-secret", "test-session-secret" + ), ) monkeypatch.setattr(router.queries, "list_events", lambda: []) @@ -45,6 +51,51 @@ def test_is_email_allowed_matches_allowlist_case_insensitively(monkeypatch): assert auth.is_email_allowed("") is False +def test_issue_api_token_round_trips_email(monkeypatch): + """A freshly minted token verifies back to the same email.""" + monkeypatch.setattr( + config, "secrets", replace(config.secrets, session_secret="s3cr3t") + ) + + token = auth.issue_api_token("chester@example.com") + + assert auth.verify_api_token(token) == "chester@example.com" + + +def test_verify_api_token_rejects_tampered_token(monkeypatch): + """A modified token fails signature verification.""" + monkeypatch.setattr( + config, "secrets", replace(config.secrets, session_secret="s3cr3t") + ) + token = auth.issue_api_token("chester@example.com") + + assert auth.verify_api_token(token + "tampered") is None + + +def test_verify_api_token_rejects_expired_token(monkeypatch): + """A token older than AUTH_TOKEN_TTL_SECONDS is rejected.""" + monkeypatch.setattr( + config, "secrets", replace(config.secrets, session_secret="s3cr3t") + ) + token = auth.issue_api_token("chester@example.com") + # A negative TTL makes every token's age exceed max_age immediately — + # no need to sleep in the test. + monkeypatch.setattr( + config, "settings", replace(config.settings, auth_token_ttl_seconds=-1) + ) + + assert auth.verify_api_token(token) is None + + +def test_verify_api_token_rejects_garbage_input(monkeypatch): + """A string that isn't a signed token at all is rejected, not raised.""" + monkeypatch.setattr( + config, "secrets", replace(config.secrets, session_secret="s3cr3t") + ) + + assert auth.verify_api_token("not-a-real-token") is None + + @pytest.mark.asyncio @pytest.mark.parametrize( ("email", "expected_status"), @@ -91,3 +142,178 @@ async def test_google_oauth_accepts_only_allowlisted_user( ).status_code == 200 finally: auth.reset_oauth() + + +@pytest.mark.asyncio +async def test_require_login_accepts_valid_bearer_token(dashboard_app): + """A valid Authorization: Bearer token authorizes API routes without a session.""" + token = auth.issue_api_token("chester@example.com") + + transport = httpx.ASGITransport(app=dashboard_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/dashboard/api/events", headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_require_login_rejects_bearer_token_for_disallowed_email(dashboard_app): + """A well-signed token for a non-allowlisted email is still rejected.""" + token = auth.issue_api_token("steve@example.com") + + transport = httpx.ASGITransport(app=dashboard_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/dashboard/api/events", headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_require_login_rejects_garbage_bearer_token(dashboard_app): + """A malformed token is rejected the same as a missing session.""" + transport = httpx.ASGITransport(app=dashboard_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/dashboard/api/events", + headers={"Authorization": "Bearer not-a-real-token"}, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_api_me_returns_authenticated_email(dashboard_app): + """The SPA frontend can look up who is currently logged in via a token.""" + token = auth.issue_api_token("chester@example.com") + + transport = httpx.ASGITransport(app=dashboard_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/dashboard/api/me", headers={"Authorization": f"Bearer {token}"} + ) + + assert response.status_code == 200 + assert response.json() == {"email": "chester@example.com"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("email", "should_allow"), + [("chester@example.com", True), ("steve@example.com", False)], +) +async def test_spa_oauth_callback_redirects_with_token_or_error( + dashboard_app, monkeypatch, email, should_allow +): + """SPA login redirects to the frontend with a token, or an error, in the URL.""" + with run_server_in_thread( + user_claims=[User(sub=email, claims={"email": email})] + ) as server: + provider_url = f"http://localhost:{server.server_port}" + monkeypatch.setattr( + auth, + "_GOOGLE_SERVER_METADATA_URL", + f"{provider_url}/.well-known/openid-configuration", + ) + auth.reset_oauth() + + try: + transport = httpx.ASGITransport(app=dashboard_app) + async with httpx.AsyncClient( + transport=transport, base_url="http://test" + ) as client: + login = await client.get("/dashboard/login/spa", follow_redirects=False) + + async with httpx.AsyncClient() as provider_client: + authorized = await provider_client.post( + login.headers["location"], data={"sub": email} + ) + + callback = urlsplit(authorized.headers["location"]) + response = await client.get( + f"{callback.path}?{callback.query}", follow_redirects=False + ) + + assert response.status_code == 302 + location = response.headers["location"] + assert location.startswith("http://localhost:3000/auth/callback") + if should_allow: + assert "#token=" in location + token = location.split("#token=", 1)[1] + assert auth.verify_api_token(token) == email + else: + assert location.endswith("?error=access_denied") + finally: + auth.reset_oauth() + + +@pytest.mark.asyncio +async def test_login_spa_returns_503_when_frontend_redirect_url_unset( + dashboard_app, monkeypatch +): + """A misconfigured deployment fails closed with a clear 503, not a redirect loop.""" + monkeypatch.setattr( + config, "settings", replace(config.settings, frontend_redirect_url="") + ) + + transport = httpx.ASGITransport(app=dashboard_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/dashboard/login/spa") + + assert response.status_code == 503 + + +@pytest.mark.asyncio +async def test_spa_oauth_callback_returns_503_when_frontend_redirect_url_unset( + dashboard_app, monkeypatch +): + """The callback also fails closed if hit directly with no configured target.""" + monkeypatch.setattr( + config, "settings", replace(config.settings, frontend_redirect_url="") + ) + + transport = httpx.ASGITransport(app=dashboard_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/dashboard/oauth/callback/spa") + + assert response.status_code == 503 + + +@pytest.mark.asyncio +async def test_spa_oauth_callback_redirects_with_oauth_exchange_error( + dashboard_app, monkeypatch +): + """A Google token-exchange failure redirects to the frontend with an error, not a 500.""" + + async def fake_exchange(request): + raise HTTPException(status_code=400, detail="oauth_exchange_failed") + + monkeypatch.setattr(router, "_exchange_google_email", fake_exchange) + + transport = httpx.ASGITransport(app=dashboard_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/dashboard/oauth/callback/spa", follow_redirects=False + ) + + assert response.status_code == 302 + assert response.headers["location"] == ( + "http://localhost:3000/auth/callback?error=oauth_exchange_failed" + ) + + +@pytest.mark.asyncio +async def test_require_login_accepts_lowercase_bearer_scheme(dashboard_app): + """RFC 7235 auth-schemes are case-insensitive; accept `bearer` as well as `Bearer`.""" + token = auth.issue_api_token("chester@example.com") + + transport = httpx.ASGITransport(app=dashboard_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/dashboard/api/events", headers={"Authorization": f"bearer {token}"} + ) + + assert response.status_code == 200 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7e021a9 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,42 @@ +from argus import config + + +def test_settings_from_env_parses_frontend_origins_as_tuple(monkeypatch): + """Split a comma-separated FRONTEND_ORIGINS into a tuple, trimming blanks.""" + monkeypatch.setenv( + "FRONTEND_ORIGINS", "http://localhost:3000, https://dash.example.com" + ) + + settings = config.Settings.from_env() + + assert settings.frontend_origins == ( + "http://localhost:3000", + "https://dash.example.com", + ) + + +def test_settings_from_env_defaults_frontend_origins_to_empty(monkeypatch): + """No FRONTEND_ORIGINS configured means no cross-origin frontend is allowed.""" + monkeypatch.delenv("FRONTEND_ORIGINS", raising=False) + + settings = config.Settings.from_env() + + assert settings.frontend_origins == () + + +def test_settings_from_env_reads_frontend_redirect_url(monkeypatch): + """FRONTEND_REDIRECT_URL configures where the SPA OAuth callback lands.""" + monkeypatch.setenv("FRONTEND_REDIRECT_URL", "http://localhost:3000/auth/callback") + + settings = config.Settings.from_env() + + assert settings.frontend_redirect_url == "http://localhost:3000/auth/callback" + + +def test_settings_from_env_defaults_auth_token_ttl_to_one_day(monkeypatch): + """AUTH_TOKEN_TTL_SECONDS defaults to 86400 seconds (24h) when unset.""" + monkeypatch.delenv("AUTH_TOKEN_TTL_SECONDS", raising=False) + + settings = config.Settings.from_env() + + assert settings.auth_token_ttl_seconds == 86400 diff --git a/tests/test_docker_integration.py b/tests/test_docker_integration.py index 0708ecd..6103504 100644 --- a/tests/test_docker_integration.py +++ b/tests/test_docker_integration.py @@ -6,7 +6,7 @@ import subprocess import time -from itsdangerous import TimestampSigner +from itsdangerous import TimestampSigner, URLSafeTimedSerializer import httpx import pytest @@ -20,6 +20,8 @@ "DISCORD_WEBHOOK_SMOKE": "https://example.com/discord-webhook", "ALLOWED_EMAILS": _EMAIL, "KKTIX_ORGANIZATION": "", + "FRONTEND_ORIGINS": "http://localhost:3000", + "FRONTEND_REDIRECT_URL": "http://localhost:3000/auth/callback", } _WEBHOOK_BODY = { "notifications": [ @@ -111,6 +113,35 @@ def test_docker_image_api_flow(api_url: str) -> None: assert client.get("/dashboard/api/events").json() == [] +def test_docker_image_cors_and_bearer_token_auth(api_url: str) -> None: + """Verify CORS headers and SPA Bearer-token auth through the built image.""" + token = _api_token(_EMAIL) + with httpx.Client(base_url=api_url, timeout=5) as client: + allowed_origin = client.get( + "/dashboard/api/me", + headers={ + "Authorization": f"Bearer {token}", + "Origin": "http://localhost:3000", + }, + ) + assert allowed_origin.status_code == 200 + assert allowed_origin.json() == {"email": _EMAIL} + assert ( + allowed_origin.headers["access-control-allow-origin"] + == "http://localhost:3000" + ) + + disallowed_origin = client.get( + "/dashboard/api/me", + headers={ + "Authorization": f"Bearer {token}", + "Origin": "https://not-allowed.example.com", + }, + ) + assert disallowed_origin.status_code == 200 + assert "access-control-allow-origin" not in disallowed_origin.headers + + def _start_postgresql(container: str, network: str) -> None: _run( [ @@ -175,6 +206,12 @@ def _session_cookie(email: str) -> str: return TimestampSigner(_SESSION_SECRET).sign(data).decode("utf-8") +def _api_token(email: str) -> str: + return URLSafeTimedSerializer(_SESSION_SECRET, salt="argus-spa-api-token").dumps( + {"email": email} + ) + + def _get_free_port() -> int: with socket.socket() as sock: sock.bind(("127.0.0.1", 0))