From 3345c93df1467099ec9e710ffe64d661836f5cf7 Mon Sep 17 00:00:00 2001 From: jzhao234 Date: Mon, 14 Sep 2026 16:03:12 +0000 Subject: [PATCH] feat(auth): revoke Explorer sessions from Auth's signed back-channel logout TL;DR: Explorer now accepts signed logout events from the central auth service and immediately ends every Explorer session belonging to the affected account. Until now a central disable or password replacement left an already-issued Explorer session alive for up to 12 hours. Motivation: The central handoff (#17) created app-scoped Explorer sessions with no way for Auth to end them. The README said as much and called back-channel revocation a required follow-up. Fix: - POST /auth/backchannel-logout (public, unauthenticated by design: the token is the credential). Content-Length is required and capped at 20 KB before parsing. - verify_logout_token checks typ logout+jwt, alg EdDSA, kid against AUTH_SIGNING_PUBKEY and AUTH_SIGNING_PREVIOUS_PUBKEYS, the Ed25519 signature, exact issuer and audience, sub, jti, iat not in the future, a live exp (60-second skew), the back-channel event, and rejects a nonce. - consume_logout_event records the jti (INSERT OR IGNORE) and revokes every session for the subject only on first sight, so Auth's retries are idempotent. Replay rows are pruned after seven days. Schema version 2 migrates forward. - Central mode now requires a valid AUTH_SIGNING_PUBKEY at startup. Bootstrap prompts for it in both modes, update.sh warns in both modes, and the docs no longer call it Elcano-only. Without this the receiver would have answered 400 to every event forever while sessions stayed alive. Tests: - Store: event revokes only the matching subject and is idempotent; replay table pruned after retention. - Verifier: signature, issuer, audience, malformed issuer; expired, string, boolean, and missing exp all rejected. - Endpoints: a signed event revokes the live cookie and a replay is still 204; oversized body is 413; central mode refuses to start without the key or with a malformed one. - Ran: pytest -q (108 passed), ruff check, ruff format --check, bash -n and shellcheck on bootstrap.sh and update.sh. --- README.md | 13 +- app/auth.py | 2 + app/central_auth.py | 192 ++++++++++++++++++++++++++- app/main.py | 35 +++++ docs/DEPLOYMENT.md | 25 ++-- scripts/bootstrap.sh | 17 ++- scripts/update.sh | 12 +- tests/test_central_auth.py | 150 +++++++++++++++++++++ tests/test_central_auth_endpoints.py | 95 +++++++++++++ 9 files changed, 509 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 5cff62f..31015e8 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Neither dotenv file is committed. `.env.example` is the annotated template: | `AWS_ACCESS_KEY_ID` | no | *(empty)* | Read-only key. Leave blank to use the ambient AWS credential chain (instance role, `~/.aws`, `AWS_PROFILE`). | | `AWS_SECRET_ACCESS_KEY` | no | *(empty)* | Secret for the above. | | `EXPLORER_AUTH_MODE` | no | `elcano` | Authentication provider: `elcano` or `central`. The default preserves existing deployments. | -| `AUTH_SIGNING_PUBKEY` | Elcano mode | *(empty)* | Base64 Ed25519 **public** key of the auth service. Unset ⇒ every request redirects to sign-in. | +| `AUTH_SIGNING_PUBKEY` | yes | *(empty)* | Base64 Ed25519 **public** key of the auth service. Elcano mode: unset ⇒ every request redirects to sign-in. Central mode: verifies back-channel logout tokens; startup refuses without it. | | `AUTH_LOGIN_URL` | no | `https://auth.elcanotek.com` | Where unauthenticated browsers are sent. Set this. | | `AUTH_COOKIE_NAME` | no | `elcano_auth` | Name of the session cookie to verify. | | `AUTH_ISSUER_URL` | central mode | *(empty)* | HTTPS origin of the client's central auth service. | @@ -203,12 +203,13 @@ hash is stored in `/var/lib/explorer/access.db`; the host-only scoped to `/`. Sessions expire after 60 minutes idle or 12 hours total. Revoking an email immediately invalidates all of that email's Explorer sessions. Logout is CSRF-protected and revokes only the current Explorer -session. Central login/logout or account disablement does not yet revoke an -already-issued Explorer session; it lasts until its idle/absolute expiry or a -local `explorer access revoke`. Back-channel revocation is a required follow-up -if clients need immediate cross-service sign-out or disablement. +session. Auth's signed back-channel endpoint also revokes every local session +for the affected central subject after account disablement, password +replacement, or explicit sign-out-everywhere. Events have durable retry and +replay protection, so temporary Explorer outages do not lose revocations. -The auth service must register the exact client ID, secret, and callback URL. +The auth service must register the exact client ID, secret, callback URL, and +`https:///auth/backchannel-logout` endpoint. The expected `/authorize` and `/token` contract is documented in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#central-auth-service-contract). diff --git a/app/auth.py b/app/auth.py index 47af2d1..e5503c7 100644 --- a/app/auth.py +++ b/app/auth.py @@ -45,6 +45,7 @@ CentralAuthClient, CentralAuthStore, CentralIdentity, + require_auth_signing_public_keys, ) AUTH_COOKIE_NAME = os.getenv("AUTH_COOKIE_NAME", "elcano_auth") @@ -209,6 +210,7 @@ def from_env( raise RuntimeError( "Central auth requires Secure app and UI cookies; insecure HTTP is only allowed for development" ) + require_auth_signing_public_keys() client = client_factory.from_env() store = CentralAuthStore.from_env() return cls( diff --git a/app/central_auth.py b/app/central_auth.py index c3e99e9..599e032 100644 --- a/app/central_auth.py +++ b/app/central_auth.py @@ -12,6 +12,7 @@ from __future__ import annotations import base64 +import binascii import hashlib import hmac import json @@ -26,12 +27,17 @@ from dataclasses import dataclass from pathlib import Path +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + CENTRAL_AUTH_COOKIE_NAME = "__Host-explorer_session" DEFAULT_IDLE_SECONDS = 60 * 60 DEFAULT_ABSOLUTE_SECONDS = 12 * 60 * 60 LOGIN_TRANSACTION_SECONDS = 10 * 60 -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 MAX_TOKEN_RESPONSE_BYTES = 64 * 1024 +BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout" +REVOCATION_EVENT_RETENTION_SECONDS = 7 * 24 * 60 * 60 class _NoRedirect(urllib.request.HTTPRedirectHandler): @@ -104,6 +110,143 @@ class IssuedSession: token_hash: str +@dataclass(frozen=True) +class LogoutEvent: + event_id: str + subject: str + issuer: str + issued_at: int + + +def _decode_b64url(segment: str) -> bytes: + if not segment or any( + char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + for char in segment + ): + raise CentralAuthError("The logout token was invalid") + try: + return base64.b64decode( + segment + "=" * (-len(segment) % 4), altchars=b"-_", validate=True + ) + except (ValueError, binascii.Error) as exc: + raise CentralAuthError("The logout token was invalid") from exc + + +def verify_logout_token( + raw: str, + *, + issuer: str, + audience: str, + public_keys: list[str], + now: int | None = None, +) -> LogoutEvent: + """Verify one OIDC back-channel logout token and return its replay key.""" + if len(raw) > 16_384: + raise CentralAuthError("The logout token was invalid") + parts = raw.split(".") + if len(parts) != 3: + raise CentralAuthError("The logout token was invalid") + try: + header = json.loads(_decode_b64url(parts[0])) + claims = json.loads(_decode_b64url(parts[1])) + signature = _decode_b64url(parts[2]) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CentralAuthError("The logout token was invalid") from exc + if not isinstance(header, dict) or not isinstance(claims, dict): + raise CentralAuthError("The logout token was invalid") + if header.get("typ") != "logout+jwt" or header.get("alg") != "EdDSA": + raise CentralAuthError("The logout token was invalid") + + verified = False + for encoded_key in public_keys: + try: + raw_key = base64.b64decode(encoded_key.strip(), validate=True) + if len(raw_key) != 32: + continue + kid = ( + base64.urlsafe_b64encode(hashlib.sha256(raw_key).digest()[:16]) + .rstrip(b"=") + .decode() + ) + if not hmac.compare_digest(str(header.get("kid", "")), kid): + continue + Ed25519PublicKey.from_public_bytes(raw_key).verify( + signature, f"{parts[0]}.{parts[1]}".encode("ascii") + ) + verified = True + break + except (ValueError, InvalidSignature, UnicodeEncodeError): + continue + if not verified: + raise CentralAuthError("The logout token was invalid") + + timestamp = int(time.time() if now is None else now) + issued_at = claims.get("iat") + expires_at = claims.get("exp") + subject = claims.get("sub") + event_id = claims.get("jti") + token_issuer = claims.get("iss") + events = claims.get("events") + if ( + isinstance(expires_at, bool) + or not isinstance(expires_at, int) + or expires_at + CLOCK_SKEW_SECONDS <= timestamp + or not isinstance(token_issuer, str) + or token_issuer.rstrip("/") != issuer.rstrip("/") + or claims.get("aud") != audience + or not isinstance(subject, str) + or not subject + or len(subject) > 255 + or not isinstance(event_id, str) + or not event_id + or len(event_id) > 255 + or isinstance(issued_at, bool) + or not isinstance(issued_at, int) + or issued_at <= 0 + or issued_at > timestamp + CLOCK_SKEW_SECONDS + or not isinstance(events, dict) + or not isinstance(events.get(BACKCHANNEL_LOGOUT_EVENT), dict) + or "nonce" in claims + ): + raise CentralAuthError("The logout token was invalid") + return LogoutEvent( + event_id=event_id, + subject=subject, + issuer=issuer.rstrip("/"), + issued_at=issued_at, + ) + + +def auth_signing_public_keys() -> list[str]: + keys = [os.getenv("AUTH_SIGNING_PUBKEY", "")] + keys.extend(os.getenv("AUTH_SIGNING_PREVIOUS_PUBKEYS", "").split(",")) + return [key.strip() for key in keys if key.strip()] + + +def require_auth_signing_public_keys() -> list[str]: + """Fail at startup, not at the first logout event, when no usable key is set. + + Central mode needs the auth service's Ed25519 public key to verify + back-channel logout tokens. Without it every revocation would be answered + 400 and retried forever while sessions stayed alive. + """ + keys = auth_signing_public_keys() + if not keys: + raise RuntimeError( + "AUTH_SIGNING_PUBKEY is required in central mode: run `auth pubkey` on the auth host" + ) + for encoded in keys: + try: + raw = base64.b64decode(encoded, validate=True) + except (ValueError, binascii.Error) as exc: + raise RuntimeError("AUTH_SIGNING_PUBKEY is not valid base64") from exc + if len(raw) != 32: + raise RuntimeError( + "AUTH_SIGNING_PUBKEY must decode to a 32-byte Ed25519 key" + ) + return keys + + def _env_bool(name: str, default: bool) -> bool: raw = os.getenv(name) if raw is None: @@ -382,6 +525,14 @@ def _initialize(self) -> None: CREATE INDEX IF NOT EXISTS sessions_email_idx ON sessions(email); CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(absolute_expires_at, idle_expires_at); + + CREATE TABLE IF NOT EXISTS revocation_events ( + event_id TEXT PRIMARY KEY, + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + issued_at INTEGER NOT NULL, + received_at INTEGER NOT NULL + ); """ ) connection.execute( @@ -391,10 +542,14 @@ def _initialize(self) -> None: row = connection.execute( "SELECT value FROM schema_meta WHERE key = 'schema_version'" ).fetchone() - if row is None or int(row["value"]) != SCHEMA_VERSION: + if row is None or int(row["value"]) > SCHEMA_VERSION: raise RuntimeError( "Unsupported Explorer access database schema version" ) + connection.execute( + "UPDATE schema_meta SET value = ? WHERE key = 'schema_version'", + (str(SCHEMA_VERSION),), + ) os.chmod(self.path, 0o600) def grant_access(self, email: str, *, now: int | None = None) -> AccessEntry: @@ -573,6 +728,39 @@ def revoke_session(self, token: str | None, *, now: int | None = None) -> bool: ) return cursor.rowcount > 0 + def consume_logout_event( + self, + event_id: str, + issuer: str, + subject: str, + issued_at: int, + *, + now: int | None = None, + ) -> bool: + timestamp = int(time.time() if now is None else now) + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + # Replay protection only needs to outlive a token's acceptance + # window (exp plus skew, minutes). Keep a week for forensics. + connection.execute( + "DELETE FROM revocation_events WHERE received_at < ?", + (timestamp - REVOCATION_EVENT_RETENTION_SECONDS,), + ) + cursor = connection.execute( + """ + INSERT OR IGNORE INTO revocation_events( + event_id, issuer, subject, issued_at, received_at + ) VALUES (?, ?, ?, ?, ?) + """, + (event_id, issuer, subject, issued_at, timestamp), + ) + if cursor.rowcount: + connection.execute( + "UPDATE sessions SET revoked_at = ? WHERE subject = ? AND revoked_at IS NULL", + (timestamp, subject), + ) + return cursor.rowcount > 0 + @staticmethod def csrf_token(token: str) -> str: return csrf_token_for_session(token) diff --git a/app/main.py b/app/main.py index 0461dd5..6405061 100644 --- a/app/main.py +++ b/app/main.py @@ -34,7 +34,9 @@ AuthTransactionError, CentralAuthError, CodeExchangeRejectedError, + auth_signing_public_keys, verify_csrf_token, + verify_logout_token, ) from app.config import settings from app.s3_email import S3EmailInbox, SearchCancelledError @@ -103,8 +105,10 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: "/login", "/auth/login", "/auth/callback", + "/auth/backchannel-logout", "/signed-out", } +MAX_BACKCHANNEL_BODY_BYTES = 20_000 def request_auth_provider(request: Request) -> AuthProvider: @@ -115,6 +119,14 @@ def request_auth_provider(request: Request) -> AuthProvider: async def require_authentication(request: Request, call_next) -> Response: provider = request_auth_provider(request) path = request.url.path + if path == "/auth/backchannel-logout" and request.method == "POST": + content_length = request.headers.get("content-length") + if content_length is None: + return Response(status_code=411) + if not content_length.isdecimal(): + return Response(status_code=400) + if int(content_length) > MAX_BACKCHANNEL_BODY_BYTES: + return Response(status_code=413) if path == "/health" or path.startswith("/static/"): request.state.identity = None return await call_next(request) @@ -649,6 +661,29 @@ def signed_out(request: Request): ) +@app.post("/auth/backchannel-logout", status_code=204) +def auth_backchannel_logout(request: Request, logout_token: str = Form(...)): + provider = request_auth_provider(request) + if not isinstance(provider, CentralAuthProvider): + raise HTTPException(status_code=404) + try: + event = verify_logout_token( + logout_token, + issuer=provider.client.issuer_url, + audience=provider.client.client_id, + public_keys=auth_signing_public_keys(), + ) + except CentralAuthError as exc: + raise HTTPException(status_code=400, detail="Invalid logout token") from exc + provider.store.consume_logout_event( + event.event_id, + event.issuer, + event.subject, + event.issued_at, + ) + return Response(status_code=204) + + @app.post("/logout") def logout(request: Request, csrf_token: str | None = Form(default=None)): provider = request_auth_provider(request) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 322ea8d..44e15aa 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -169,7 +169,7 @@ but lose to `.env`. | Variable | Required | Default | Purpose | |---|---|---|---| | `EXPLORER_AUTH_MODE` | no | `elcano` | Selects exactly one provider. `elcano` preserves Elcano's external magic-link cookie; `central` delegates login to the new auth service. Any other value fails startup. | -| `AUTH_SIGNING_PUBKEY` | Elcano mode | *(empty)* | Base64-encoded 32-byte Ed25519 **public** key of the auth service. Explorer verifies the session cookie's signature with it. Any parse failure is treated as "no key", which means "everyone is logged out". Safe to store in plaintext config — a public key cannot mint sessions. | +| `AUTH_SIGNING_PUBKEY` | yes | *(empty)* | Base64-encoded 32-byte Ed25519 **public** key of the auth service. Elcano mode verifies the session cookie's signature with it (a parse failure means "everyone is logged out"). Central mode verifies signed back-channel logout tokens with it and refuses to start without a valid key. Safe to store in plaintext config — a public key cannot mint sessions. | | `AUTH_LOGIN_URL` | no | `https://auth.elcanotek.com` | Where unauthenticated browsers are redirected, as `/?return_to=`. Set it to your own auth service. Trailing slashes are stripped. | | `AUTH_COOKIE_NAME` | no | `elcano_auth` | Cookie the auth service mints. Must match. | | `AUTH_ISSUER_URL` | central mode | *(empty)* | HTTPS origin of the client's central auth service, without a path or query. | @@ -177,6 +177,7 @@ but lose to `.env`. | `AUTH_CLIENT_ID` | central mode | `explorer` | Client identifier registered at the auth service. | | `AUTH_CLIENT_SECRET` | central mode | *(empty)* | Unique per-deployment client secret, at least 32 bytes. | | `AUTH_HTTP_TIMEOUT_SECONDS` | no | `10` | Backchannel code-exchange timeout; must be greater than 0 and no more than 60 seconds. | +| `AUTH_SIGNING_PREVIOUS_PUBKEYS` | no | *(empty)* | Comma-separated prior Ed25519 public keys accepted temporarily during Auth signing-key rotation. | | `EXPLORER_ACCESS_DB` | central mode | `/var/lib/explorer/access.db` | SQLite email access list and Explorer session hashes. Keep it outside the application tree and mode `0600`. | | `EXPLORER_AUTH_COOKIE_SECURE` | central mode | `1` | Controls `Secure` on `__Host-explorer_session`. Central mode refuses an insecure setting in production. | | `EXPLORER_SESSION_IDLE_SECONDS` | no | `3600` | Explorer app-session idle lifetime. Activity refreshes this deadline but never extends the absolute deadline. | @@ -214,16 +215,20 @@ redirect-URI matching, state, nonce, and S256 PKCE: `nonce`. Explorer requires the nonce to match before applying its local allowlist and issuing an app session. Errors must not return identity data. -The central auth repository's authorization-code work must implement this -contract before `central` mode can be deployed end to end. +Auth can also deliver signed, durable back-channel logout events. Register this +deployment's exact endpoint after creating the application: -This first compatibility phase does not include back-channel logout or a -central-session introspection call. Disabling an account or ending its central -session prevents new authorization codes, but an Explorer session already -issued to that user remains active until its 60-minute idle timeout, 12-hour -absolute timeout, or `explorer access revoke`. Add back-channel revocation (or -short-interval introspection) before promising immediate cross-service -disablement. +```bash +auth app set-backchannel explorer \ + https://explorer.example.com/auth/backchannel-logout +``` + +Explorer validates the token's Ed25519 signature, issuer, audience, event type, +subject, and replay ID before revoking every local session for that central +subject. Delivery is idempotent, so Auth can retry safely after outages. +Account disablement, password replacement, and an explicit central +sign-out-everywhere take effect without waiting for Explorer's idle timeout. +Normal Explorer logout remains scoped to the current Explorer session. #### Replacing the removed local-password mode diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index ff24cf4..ce52cbe 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -158,11 +158,10 @@ case "$EXPLORER_AUTH_MODE" in esac AUTH_SIGNING_PUBKEY="${AUTH_SIGNING_PUBKEY:-}" -if [[ "$EXPLORER_AUTH_MODE" == "elcano" ]]; then - # Elcano mode verifies a shared magic-link cookie with the auth service's - # public key. It can verify but cannot mint sessions. - AUTH_SIGNING_PUBKEY="$(prompt AUTH_SIGNING_PUBKEY "auth service AUTH_SIGNING_PUBKEY, base64 (blank to set later)" "$AUTH_SIGNING_PUBKEY")" -fi +# Both modes need the auth service's public key: Elcano mode verifies the +# shared session cookie with it; central mode verifies signed back-channel +# logout tokens with it. Get it from `auth pubkey` on the auth host. +AUTH_SIGNING_PUBKEY="$(prompt AUTH_SIGNING_PUBKEY "auth service AUTH_SIGNING_PUBKEY, base64 (run 'auth pubkey' on the auth host; blank to set later)" "$AUTH_SIGNING_PUBKEY")" EXPLORER_ACCESS_DB="${EXPLORER_ACCESS_DB:-/var/lib/explorer/access.db}" EXPLORER_SESSION_IDLE_SECONDS="${EXPLORER_SESSION_IDLE_SECONDS:-3600}" @@ -431,8 +430,12 @@ if [[ "$EXPLORER_AUTH_MODE" == "central" ]]; then else say " Sign-in ${c_dim}via the external magic-link auth service — no local password${c_reset}" fi -if [[ "$EXPLORER_AUTH_MODE" == "elcano" && -z "$AUTH_SIGNING_PUBKEY" ]]; then - printf ' %s! AUTH_SIGNING_PUBKEY is unset — every request will redirect to sign-in.%s\n' "$c_yellow" "$c_reset" +if [[ -z "$AUTH_SIGNING_PUBKEY" ]]; then + if [[ "$EXPLORER_AUTH_MODE" == "elcano" ]]; then + printf ' %s! AUTH_SIGNING_PUBKEY is unset — every request will redirect to sign-in.%s\n' "$c_yellow" "$c_reset" + else + printf ' %s! AUTH_SIGNING_PUBKEY is unset — central mode refuses to start until it is set (back-channel logout needs it).%s\n' "$c_yellow" "$c_reset" + fi printf ' %s Set it with: explorer env edit (paste the auth service public key), then: explorer restart%s\n' "$c_yellow" "$c_reset" fi say diff --git a/scripts/update.sh b/scripts/update.sh index f62a3a8..67209b6 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -122,20 +122,18 @@ else fi # ── 1b. external-auth public key ──────────────────────────────────────── -# Elcano mode verifies its session cookie with the auth service's public key. -# Central mode does not need that key. +# Elcano mode verifies its session cookie with the auth service's public key; +# central mode verifies signed back-channel logout tokens with the same key. ensure_auth_pubkey() { - local found="" mode="" f v + local found="" f v for f in "$APP_DIR/.env.shared" "$APP_DIR/.env"; do [[ -f "$f" ]] || continue - v="$(sed -n 's/^[[:space:]]*EXPLORER_AUTH_MODE[[:space:]]*=[[:space:]]*//p' "$f" | tail -n1)" - v="${v%[\"\']}"; v="${v#[\"\']}" - [[ -n "$v" ]] && mode="${v,,}" v="$(sed -n 's/^[[:space:]]*AUTH_SIGNING_PUBKEY[[:space:]]*=[[:space:]]*//p' "$f" | tail -n1)" v="${v%[\"\']}"; v="${v#[\"\']}" [[ -n "$v" ]] && found="$v" done - [[ "${mode:-elcano}" != "elcano" ]] && return 0 + # Every mode needs the key: Elcano verifies the cookie with it, central + # verifies back-channel logout tokens with it (and refuses to start without). [[ -n "$found" ]] && return 0 warn "AUTH_SIGNING_PUBKEY is not set — Explorer can't verify the session" diff --git a/tests/test_central_auth.py b/tests/test_central_auth.py index 562d1ca..67214ff 100644 --- a/tests/test_central_auth.py +++ b/tests/test_central_auth.py @@ -5,6 +5,7 @@ from __future__ import annotations +import base64 import hashlib import json import sqlite3 @@ -14,6 +15,7 @@ import urllib.parse import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from app.central_auth import ( AccessDeniedError, @@ -23,6 +25,7 @@ CodeExchangeRejectedError, csrf_token_for_session, verify_csrf_token, + verify_logout_token, ) @@ -95,6 +98,92 @@ def test_logout_revokes_only_the_presented_session(store: CentralAuthStore) -> N assert store.get_identity(second.token, now=1_002) is not None +def test_backchannel_logout_is_idempotent_and_scoped_to_subject( + store: CentralAuthStore, +) -> None: + store.grant_access("alice@example.com", now=1_000) + first = store.create_session("account-123", "alice@example.com", now=1_000) + second = store.create_session("account-456", "alice@example.com", now=1_000) + + assert store.consume_logout_event( + "event-123", "https://auth.example.com", "account-123", 1_001, now=1_002 + ) + assert not store.consume_logout_event( + "event-123", "https://auth.example.com", "account-123", 1_001, now=1_003 + ) + assert store.get_identity(first.token, now=1_004) is None + assert store.get_identity(second.token, now=1_004) is not None + + +def _mint_logout( + private_key, *, audience="explorer", issuer="https://auth.example.com", **overrides +): + public = private_key.public_key().public_bytes_raw() + kid = ( + base64.urlsafe_b64encode(hashlib.sha256(public).digest()[:16]) + .rstrip(b"=") + .decode() + ) + header = {"typ": "logout+jwt", "alg": "EdDSA", "kid": kid} + payload = { + "iss": issuer, + "sub": "account-123", + "aud": audience, + "email": "alice@example.com", + "iat": 1_000, + "exp": 1_300, + "jti": "event-123", + "events": {"http://schemas.openid.net/event/backchannel-logout": {}}, + } + payload.update(overrides) + + def encode(value): + return ( + base64.urlsafe_b64encode(json.dumps(value, separators=(",", ":")).encode()) + .rstrip(b"=") + .decode() + ) + + body = f"{encode(header)}.{encode(payload)}" + signature = ( + base64.urlsafe_b64encode(private_key.sign(body.encode())).rstrip(b"=").decode() + ) + return f"{body}.{signature}", base64.b64encode(public).decode() + + +def test_logout_token_verification_checks_signature_issuer_and_audience() -> None: + private_key = Ed25519PrivateKey.generate() + raw, public_key = _mint_logout(private_key) + event = verify_logout_token( + raw, + issuer="https://auth.example.com", + audience="explorer", + public_keys=[public_key], + now=1_010, + ) + assert event.event_id == "event-123" + assert event.subject == "account-123" + + with pytest.raises(CentralAuthError): + verify_logout_token( + raw, + issuer="https://auth.example.com", + audience="lens", + public_keys=[public_key], + now=1_010, + ) + + malformed_issuer, _ = _mint_logout(private_key, issuer=123) + with pytest.raises(CentralAuthError): + verify_logout_token( + malformed_issuer, + issuer="https://auth.example.com", + audience="explorer", + public_keys=[public_key], + now=1_010, + ) + + def test_csrf_token_is_bound_to_the_app_session_secret() -> None: token = "browser-only-app-session-secret" csrf = csrf_token_for_session(token) @@ -330,3 +419,64 @@ def refuse(*_args): with pytest.raises(CentralAuthError) as excinfo: _client().exchange(code="code", code_verifier="v" * 43, expected_nonce="n") assert not isinstance(excinfo.value, CodeExchangeRejectedError) + + +@pytest.mark.parametrize("overrides", [{"exp": 1_000}, {"exp": "soon"}, {"exp": True}]) +def test_logout_token_requires_a_live_expiry(overrides) -> None: + private_key = Ed25519PrivateKey.generate() + raw, public_key = _mint_logout(private_key, **overrides) + with pytest.raises(CentralAuthError): + verify_logout_token( + raw, + issuer="https://auth.example.com", + audience="explorer", + public_keys=[public_key], + now=1_200, + ) + + +def test_logout_token_missing_expiry_is_rejected() -> None: + private_key = Ed25519PrivateKey.generate() + raw, public_key = _mint_logout(private_key) + # Strip exp by re-minting without it: overrides cannot delete, so build by hand. + header_b64, payload_b64, _sig = raw.split(".") + payload = json.loads(base64.urlsafe_b64decode(payload_b64 + "==")) + del payload["exp"] + body = ( + header_b64 + + "." + + base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()) + .rstrip(b"=") + .decode() + ) + signature = ( + base64.urlsafe_b64encode(private_key.sign(body.encode())).rstrip(b"=").decode() + ) + with pytest.raises(CentralAuthError): + verify_logout_token( + f"{body}.{signature}", + issuer="https://auth.example.com", + audience="explorer", + public_keys=[public_key], + now=1_010, + ) + + +def test_replay_table_is_pruned_after_retention(store: CentralAuthStore) -> None: + assert store.consume_logout_event( + "old-event", "https://auth.example.com", "account-1", 1_000, now=1_000 + ) + week = 7 * 24 * 60 * 60 + assert store.consume_logout_event( + "new-event", + "https://auth.example.com", + "account-2", + 1_000 + week, + now=1_000 + week + 1, + ) + with sqlite3.connect(store.path) as connection: + ids = { + row[0] + for row in connection.execute("SELECT event_id FROM revocation_events") + } + assert ids == {"new-event"} diff --git a/tests/test_central_auth_endpoints.py b/tests/test_central_auth_endpoints.py index 40d619a..7be2e7c 100644 --- a/tests/test_central_auth_endpoints.py +++ b/tests/test_central_auth_endpoints.py @@ -5,12 +5,17 @@ from __future__ import annotations +import base64 +import hashlib +import json from dataclasses import replace from urllib.parse import parse_qs, urlsplit import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from app import main +from app.auth import CentralAuthProvider from app.central_auth import ( CENTRAL_AUTH_COOKIE_NAME, AuthenticatedPrincipal, @@ -25,6 +30,7 @@ class FakeAuthClient: exchange_error: Exception | None = None exchanged_codes: list[str] = [] email = "alice@example.com" + signing_key: Ed25519PrivateKey | None = None def __init__(self, *args, **kwargs) -> None: pass @@ -34,6 +40,7 @@ def from_env(cls): return cls() issuer_url = "https://auth.example.com" + client_id = "explorer" def authorization_url(self, *, state: str, code_challenge: str, nonce: str) -> str: query = ( @@ -60,6 +67,7 @@ def reset_fake_client() -> None: FakeAuthClient.exchange_error = None FakeAuthClient.exchanged_codes = [] FakeAuthClient.email = "alice@example.com" + FakeAuthClient.signing_key = None @pytest.fixture() @@ -76,6 +84,12 @@ def central_client(monkeypatch, tmp_path, asgi_client): monkeypatch.setenv( "AUTH_CLIENT_SECRET", "a-test-client-secret-with-at-least-32-bytes" ) + private_key = Ed25519PrivateKey.generate() + FakeAuthClient.signing_key = private_key + monkeypatch.setenv( + "AUTH_SIGNING_PUBKEY", + base64.b64encode(private_key.public_key().public_bytes_raw()).decode(), + ) monkeypatch.setattr(main, "CentralAuthClient", FakeAuthClient) monkeypatch.setattr( main, @@ -264,9 +278,90 @@ def test_logout_requires_csrf_and_revokes_only_explorer_session( assert store.get_identity(raw_token) is None +def test_signed_backchannel_logout_revokes_all_sessions_for_subject( + central_client, +) -> None: + client, store = central_client + complete_login(client) + raw_token = client.cookies.get(CENTRAL_AUTH_COOKIE_NAME) + assert raw_token and FakeAuthClient.signing_key + + public = FakeAuthClient.signing_key.public_key().public_bytes_raw() + kid = ( + base64.urlsafe_b64encode(hashlib.sha256(public).digest()[:16]) + .rstrip(b"=") + .decode() + ) + + def encode(value): + return ( + base64.urlsafe_b64encode(json.dumps(value, separators=(",", ":")).encode()) + .rstrip(b"=") + .decode() + ) + + header = {"typ": "logout+jwt", "alg": "EdDSA", "kid": kid} + claims = { + "iss": "https://auth.example.com", + "sub": "account-123", + "aud": "explorer", + "iat": int(main.time.time()) if hasattr(main, "time") else 1_000, + "exp": 2_000_000_000, + "jti": "event-123", + "events": {"http://schemas.openid.net/event/backchannel-logout": {}}, + } + body = f"{encode(header)}.{encode(claims)}" + signature = ( + base64.urlsafe_b64encode(FakeAuthClient.signing_key.sign(body.encode())) + .rstrip(b"=") + .decode() + ) + raw_logout = f"{body}.{signature}" + + response = client.post( + "/auth/backchannel-logout", data={"logout_token": raw_logout} + ) + + assert response.status_code == 204 + assert store.get_identity(raw_token) is None + assert ( + client.post( + "/auth/backchannel-logout", data={"logout_token": raw_logout} + ).status_code + == 204 + ) + + def test_auth_client_requires_https_and_a_strong_client_secret(monkeypatch) -> None: monkeypatch.setenv("AUTH_ISSUER_URL", "http://auth.example.com") monkeypatch.setenv("AUTH_CLIENT_SECRET", "short") with pytest.raises(RuntimeError, match="HTTPS"): CentralAuthClient.from_env() + + +def test_backchannel_logout_rejects_oversized_request(central_client) -> None: + client, _store = central_client + response = client.post( + "/auth/backchannel-logout", data={"logout_token": "x" * 20_001} + ) + assert response.status_code == 413 + + +def test_central_mode_refuses_to_start_without_the_auth_public_key( + monkeypatch, tmp_path +) -> None: + monkeypatch.setenv("EXPLORER_AUTH_MODE", "central") + monkeypatch.setenv("EXPLORER_ACCESS_DB", str(tmp_path / "access.db")) + monkeypatch.setenv("AUTH_ISSUER_URL", "https://auth.example.com") + monkeypatch.setenv("EXPLORER_PUBLIC_URL", "https://explorer.example.com") + monkeypatch.setenv( + "AUTH_CLIENT_SECRET", "a-test-client-secret-with-at-least-32-bytes" + ) + monkeypatch.setenv("AUTH_SIGNING_PUBKEY", "") + monkeypatch.delenv("AUTH_SIGNING_PREVIOUS_PUBKEYS", raising=False) + with pytest.raises(RuntimeError, match="AUTH_SIGNING_PUBKEY"): + CentralAuthProvider.from_env(FakeAuthClient) + monkeypatch.setenv("AUTH_SIGNING_PUBKEY", "not-32-bytes") + with pytest.raises(RuntimeError, match="AUTH_SIGNING_PUBKEY"): + CentralAuthProvider.from_env(FakeAuthClient)