feat: OIDC Identity Provider — Phase 1 endpoints

Add OIDC/OAuth2 endpoints to bincio-auth so it acts as a full IdP:
  GET  /.well-known/openid-configuration
  GET  /.well-known/jwks.json
  GET  /oauth2/authorize  (auth-code flow, redirects to /login/ if no session)
  POST /oauth2/token      (exchanges code for RS256 id_token; PKCE supported)
  GET  /oauth2/userinfo   (Bearer token → profile claims)

Infrastructure:
  - oauth2_clients + oauth2_codes tables in db.py with CRUD helpers
  - RS256 sign/verify helpers in tokens.py (create_id_token, get_jwks)
  - oidc_private_key_pem / oidc_issuer state + _issue_id_token in deps.py
  - serve_cmd reads BINCIO_OIDC_PRIVATE_KEY_FILE / BINCIO_OIDC_ISSUER env vars
  - `bincio-auth client add/list` commands for managing OAuth2 clients
This commit is contained in:
Davide Scaini
2026-06-03 15:11:43 +02:00
parent c341c27ad4
commit 42bc476882
7 changed files with 525 additions and 15 deletions
+104
View File
@@ -74,9 +74,33 @@ CREATE TABLE IF NOT EXISTS user_prefs (
PRIMARY KEY (handle, key)
);
CREATE TABLE IF NOT EXISTS oauth2_clients (
client_id TEXT PRIMARY KEY,
client_secret TEXT,
name TEXT NOT NULL,
redirect_uris TEXT NOT NULL,
scopes TEXT NOT NULL DEFAULT 'openid profile',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS oauth2_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
handle TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
scope TEXT NOT NULL,
nonce TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER
);
CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle);
CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by);
CREATE INDEX IF NOT EXISTS reset_codes_handle ON reset_codes(handle);
CREATE INDEX IF NOT EXISTS oauth2_codes_client ON oauth2_codes(client_id);
CREATE INDEX IF NOT EXISTS user_prefs_handle ON user_prefs(handle);
"""
@@ -488,3 +512,83 @@ def use_reset_code(db: sqlite3.Connection, code: str, handle: str) -> bool:
)
db.commit()
return True
# ── OAuth2 clients ────────────────────────────────────────────────────────────
import json as _json
def get_oauth2_client(db: sqlite3.Connection, client_id: str) -> dict | None:
row = db.execute(
"SELECT client_id, client_secret, name, redirect_uris, scopes FROM oauth2_clients WHERE client_id = ?",
(client_id,),
).fetchone()
if not row:
return None
return {
"client_id": row["client_id"],
"client_secret": row["client_secret"],
"name": row["name"],
"redirect_uris": _json.loads(row["redirect_uris"]),
"scopes": row["scopes"],
}
def upsert_oauth2_client(
db: sqlite3.Connection,
client_id: str,
client_secret: str | None,
name: str,
redirect_uris: list[str],
scopes: str = "openid profile",
) -> None:
db.execute(
"INSERT INTO oauth2_clients (client_id, client_secret, name, redirect_uris, scopes, created_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(client_id) DO UPDATE SET "
"client_secret=excluded.client_secret, name=excluded.name, "
"redirect_uris=excluded.redirect_uris, scopes=excluded.scopes",
(client_id, client_secret, name, _json.dumps(redirect_uris), scopes, int(time.time())),
)
db.commit()
# ── OAuth2 authorization codes ────────────────────────────────────────────────
_CODE_TTL = 300 # 5 minutes
def create_oauth2_code(
db: sqlite3.Connection,
client_id: str,
handle: str,
redirect_uri: str,
scope: str,
nonce: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
) -> str:
code = secrets.token_urlsafe(32)
now = int(time.time())
db.execute(
"INSERT INTO oauth2_codes "
"(code, client_id, handle, redirect_uri, scope, nonce, code_challenge, code_challenge_method, created_at, expires_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(code, client_id, handle, redirect_uri, scope, nonce,
code_challenge, code_challenge_method, now, now + _CODE_TTL),
)
db.commit()
return code
def get_oauth2_code(db: sqlite3.Connection, code: str) -> dict | None:
row = db.execute("SELECT * FROM oauth2_codes WHERE code = ?", (code,)).fetchone()
if not row:
return None
return dict(row)
def use_oauth2_code(db: sqlite3.Connection, code: str) -> None:
db.execute("UPDATE oauth2_codes SET used_at = ? WHERE code = ?", (int(time.time()), code))
db.commit()