Skip to content
Merged
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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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://<explorer-host>/auth/backchannel-logout` endpoint.
The expected `/authorize` and `/token` contract is documented in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#central-auth-service-contract).

Expand Down
2 changes: 2 additions & 0 deletions app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
CentralAuthClient,
CentralAuthStore,
CentralIdentity,
require_auth_signing_public_keys,
)

AUTH_COOKIE_NAME = os.getenv("AUTH_COOKIE_NAME", "elcano_auth")
Expand Down Expand Up @@ -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(
Expand Down
192 changes: 190 additions & 2 deletions app/central_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from __future__ import annotations

import base64
import binascii
import hashlib
import hmac
import json
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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)
35 changes: 35 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 15 additions & 10 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,15 @@ 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 `<url>/?return_to=<escaped current url>`. 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. |
| `EXPLORER_PUBLIC_URL` | central mode | *(empty)* | Explorer's public HTTPS origin. The callback is exactly `<origin>/auth/callback`. |
| `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. |
Expand Down Expand Up @@ -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

Expand Down
Loading