from __future__ import annotations import hashlib import hmac import json import secrets import sqlite3 import time from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import Iterator SCHEMA = """ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, oidc_sub TEXT NOT NULL UNIQUE, email TEXT, upstream_access TEXT, upstream_refresh TEXT, upstream_expires_at REAL, created_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS oauth_codes ( code TEXT PRIMARY KEY, client_id TEXT NOT NULL, redirect_uri TEXT NOT NULL, code_challenge TEXT NOT NULL, code_challenge_method TEXT NOT NULL, user_id INTEGER NOT NULL, expires_at REAL NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS access_tokens ( token TEXT PRIMARY KEY, client_id TEXT NOT NULL, user_id INTEGER NOT NULL, expires_at REAL NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS oauth_clients ( client_id TEXT PRIMARY KEY, client_name TEXT, redirect_uris TEXT NOT NULL, registration_token_hash TEXT NOT NULL, created_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS oidc_states ( state TEXT PRIMARY KEY, next_url TEXT NOT NULL, expires_at REAL NOT NULL ); """ @dataclass class User: id: int oidc_sub: str email: str | None class Database: def __init__(self, data_dir: str) -> None: path = Path(data_dir) path.mkdir(parents=True, exist_ok=True) self._path = path / "bridge.db" self._init() def _connect(self) -> sqlite3.Connection: conn = sqlite3.connect(self._path, check_same_thread=False) conn.row_factory = sqlite3.Row return conn def _init(self) -> None: with self._connect() as conn: conn.executescript(SCHEMA) @contextmanager def connection(self) -> Iterator[sqlite3.Connection]: conn = self._connect() try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() def upsert_user(self, oidc_sub: str, email: str | None) -> User: now = time.time() with self.connection() as conn: conn.execute( """ INSERT INTO users (oidc_sub, email, created_at) VALUES (?, ?, ?) ON CONFLICT(oidc_sub) DO UPDATE SET email = COALESCE(excluded.email, users.email) """, (oidc_sub, email, now), ) row = conn.execute( "SELECT id, oidc_sub, email FROM users WHERE oidc_sub = ?", (oidc_sub,), ).fetchone() assert row is not None return User(row["id"], row["oidc_sub"], row["email"]) def get_user_by_id(self, user_id: int) -> User | None: with self.connection() as conn: row = conn.execute( "SELECT id, oidc_sub, email FROM users WHERE id = ?", (user_id,), ).fetchone() if row is None: return None return User(row["id"], row["oidc_sub"], row["email"]) def create_oauth_code( self, client_id: str, redirect_uri: str, code_challenge: str, code_challenge_method: str, user_id: int, ttl_seconds: int = 600, ) -> str: code = secrets.token_urlsafe(32) expires_at = time.time() + ttl_seconds with self.connection() as conn: conn.execute( """ INSERT INTO oauth_codes (code, client_id, redirect_uri, code_challenge, code_challenge_method, user_id, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?) """, (code, client_id, redirect_uri, code_challenge, code_challenge_method, user_id, expires_at), ) return code def consume_oauth_code(self, code: str) -> sqlite3.Row | None: now = time.time() with self.connection() as conn: row = conn.execute( "SELECT * FROM oauth_codes WHERE code = ? AND expires_at > ?", (code, now), ).fetchone() if row is None: return None conn.execute("DELETE FROM oauth_codes WHERE code = ?", (code,)) return row def create_access_token(self, client_id: str, user_id: int, ttl_seconds: int = 86400 * 7) -> str: token = secrets.token_urlsafe(48) expires_at = time.time() + ttl_seconds with self.connection() as conn: conn.execute( "INSERT INTO access_tokens (token, client_id, user_id, expires_at) VALUES (?, ?, ?, ?)", (token, client_id, user_id, expires_at), ) return token def get_access_token(self, token: str) -> sqlite3.Row | None: now = time.time() with self.connection() as conn: return conn.execute( "SELECT * FROM access_tokens WHERE token = ? AND expires_at > ?", (token, now), ).fetchone() def save_oidc_state(self, state: str, next_url: str, ttl_seconds: int = 600) -> None: expires_at = time.time() + ttl_seconds with self.connection() as conn: conn.execute( "INSERT INTO oidc_states (state, next_url, expires_at) VALUES (?, ?, ?)", (state, next_url, expires_at), ) def pop_oidc_state(self, state: str) -> str | None: now = time.time() with self.connection() as conn: row = conn.execute( "SELECT next_url FROM oidc_states WHERE state = ? AND expires_at > ?", (state, now), ).fetchone() if row is None: return None conn.execute("DELETE FROM oidc_states WHERE state = ?", (state,)) return row["next_url"] # ---- dynamic client registration (RFC 7591 / 7592) ---- @staticmethod def _hash(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() def register_client( self, client_name: str, redirect_uris: list[str], max_clients: int ) -> tuple[str, str, int] | None: client_id = "dcr-" + secrets.token_urlsafe(24) reg_token = secrets.token_urlsafe(32) now = time.time() with self.connection() as conn: (count,) = conn.execute("SELECT COUNT(*) FROM oauth_clients").fetchone() if count >= max_clients: return None conn.execute( "INSERT INTO oauth_clients (client_id, client_name, redirect_uris, registration_token_hash, created_at)" " VALUES (?, ?, ?, ?, ?)", (client_id, client_name, json.dumps(redirect_uris), self._hash(reg_token), now), ) return client_id, reg_token, int(now) def get_client(self, client_id: str) -> sqlite3.Row | None: with self.connection() as conn: return conn.execute( "SELECT * FROM oauth_clients WHERE client_id = ?", (client_id,) ).fetchone() def get_client_redirect_uris(self, client_id: str) -> list[str] | None: row = self.get_client(client_id) if row is None: return None return list(json.loads(row["redirect_uris"])) def check_registration_token(self, client_id: str, token: str) -> bool: row = self.get_client(client_id) if row is None or not token: return False return hmac.compare_digest(row["registration_token_hash"], self._hash(token)) def delete_client(self, client_id: str) -> None: with self.connection() as conn: conn.execute("DELETE FROM oauth_clients WHERE client_id = ?", (client_id,)) conn.execute("DELETE FROM access_tokens WHERE client_id = ?", (client_id,)) conn.execute("DELETE FROM oauth_codes WHERE client_id = ?", (client_id,)) # ---- upstream (Gitea) OAuth tokens ---- def set_upstream_tokens( self, user_id: int, enc_access: str, enc_refresh: str | None, expires_at: float ) -> None: with self.connection() as conn: conn.execute( "UPDATE users SET upstream_access = ?, upstream_refresh = COALESCE(?, upstream_refresh)," " upstream_expires_at = ? WHERE id = ?", (enc_access, enc_refresh, expires_at, user_id), ) def get_upstream_tokens(self, user_id: int) -> sqlite3.Row | None: with self.connection() as conn: return conn.execute( "SELECT upstream_access, upstream_refresh, upstream_expires_at FROM users WHERE id = ?", (user_id,), ).fetchone()