Files
kyleandClaude Opus 5.5 2fa9eea274 Replace basic auth with Microsoft 365 sign-in
nginx now checks every dashboard and API request with oauth2-proxy
(auth_request), which signs users in with Microsoft Entra ID. Who may
sign in is controlled in Entra: a single-tenant app registration with
"Assignment required", so only assigned users/groups get through.

- deploy/oauth2-proxy.cfg (entra-id provider, auth-only mode, secure
  cookie, trusted proxy = localhost) and deploy/oauth2-proxy.service
- nginx: pages redirect to Microsoft sign-in and back to the same URL;
  API calls get a plain 401 (the page already asks to reload);
  /api/v1/ingest stays key-only for collectors; /oauth2/auth is
  internal; the signed-in user is passed to the API in headers nginx
  sets (so browsers can't spoof them); dashboard files are served with
  Cache-Control: no-cache so updates show up at once
- GET /api/v1/me; the top bar shows the signed-in user and a Sign out
  that also signs out of Microsoft (otherwise SSO signs straight back in)
- README: Entra and server setup steps

Tested locally with oauth2-proxy 7.15.5 and nginx 1.31 running the
deploy configs (TLS, self-signed): redirects to the tenant's sign-in
with the return URL kept, 401s, ingest bypass, sign-out allow-list,
cookie flags, security headers. The signed-in path was tested with a
stand-in for oauth2-proxy's auth answer; a real Entra sign-in wasn't.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 11:31:35 -06:00

43 lines
1.3 KiB
Python

"""Collector (machine) authentication.
Dashboard *users* are authenticated by nginx in front of the app (Microsoft 365 sign-in via
oauth2-proxy), so nothing here deals with people.
"""
import hashlib
import secrets
import sqlite3
from fastapi import Depends, Header, HTTPException
from .db import get_db
from .timeutil import to_db, utcnow
KEY_PREFIX = "bdk_"
def generate_key() -> str:
return KEY_PREFIX + secrets.token_urlsafe(32)
def hash_key(key: str) -> str:
# Keys are 256-bit random, so a fast unsalted hash is fine (no dictionary to attack).
return hashlib.sha256(key.encode()).hexdigest()
def require_ingest_key(
authorization: str | None = Header(None),
conn: sqlite3.Connection = Depends(get_db),
) -> str:
scheme, _, token = (authorization or "").partition(" ")
token = token.strip()
if scheme.lower() != "bearer" or not token:
raise HTTPException(401, "Missing bearer token", headers={"WWW-Authenticate": "Bearer"})
row = conn.execute(
"SELECT id, name FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL", (hash_key(token),)
).fetchone()
if row is None:
raise HTTPException(401, "Invalid or revoked API key", headers={"WWW-Authenticate": "Bearer"})
conn.execute("UPDATE api_keys SET last_used_at = ? WHERE id = ?", (to_db(utcnow()), row["id"]))
return row["name"]