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
+89 -1
View File
@@ -94,7 +94,18 @@ def show_secret_cmd(data_dir: str) -> None:
@click.option("--port", default=4040, type=int)
@click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET",
help="HS256 signing secret. Defaults to the value stored in the DB during `init`.")
def serve_cmd(data_dir: str, host: str, port: int, jwt_secret: str | None) -> None:
@click.option("--oidc-private-key-file", default=None, envvar="BINCIO_OIDC_PRIVATE_KEY_FILE",
type=click.Path(), help="Path to RS256 PEM private key. Enables OIDC endpoints.")
@click.option("--oidc-issuer", default=None, envvar="BINCIO_OIDC_ISSUER",
help="OIDC issuer URL (e.g. https://bincio.org). Required when --oidc-private-key-file is set.")
def serve_cmd(
data_dir: str,
host: str,
port: int,
jwt_secret: str | None,
oidc_private_key_file: str | None,
oidc_issuer: str | None,
) -> None:
"""Start the bincio-auth API server."""
import uvicorn
@@ -120,9 +131,86 @@ def serve_cmd(data_dir: str, host: str, port: int, jwt_secret: str | None) -> No
deps.data_dir = dd
deps.jwt_secret = secret
if oidc_private_key_file:
key_path = Path(oidc_private_key_file).expanduser().resolve()
if not key_path.exists():
raise click.UsageError(f"OIDC private key file not found: {key_path}")
deps.oidc_private_key_pem = key_path.read_text()
deps.oidc_issuer = oidc_issuer or ""
if not deps.oidc_issuer:
raise click.UsageError("--oidc-issuer is required when --oidc-private-key-file is set")
console.print("[bold]bincio-auth[/bold]")
console.print(f" Data: [cyan]{dd}[/cyan]")
console.print(f" URL: [cyan]http://{host}:{port}[/cyan]")
if deps.oidc_issuer:
console.print(f" OIDC: [cyan]{deps.oidc_issuer}[/cyan]")
console.print()
uvicorn.run(srv.app, host=host, port=port, log_level="info")
# ── OAuth2 client management ──────────────────────────────────────────────────
@main.group("client")
def client_group() -> None:
"""Manage OAuth2 clients (OIDC consumers: Gitea, mobile app, etc.)."""
@client_group.command("add")
@click.option("--data-dir", required=True, type=click.Path())
@click.option("--client-id", required=True)
@click.option("--name", required=True, help="Human-readable name")
@click.option("--redirect-uris", required=True, multiple=True, help="Allowed redirect URIs (repeat for multiple)")
@click.option("--client-secret", default=None,
help="Client secret (omit for public/PKCE-only clients)")
@click.option("--scopes", default="openid profile", show_default=True)
def client_add_cmd(
data_dir: str,
client_id: str,
name: str,
redirect_uris: tuple[str, ...],
client_secret: str | None,
scopes: str,
) -> None:
"""Register or update an OAuth2 client."""
from bincio.auth.db import open_db, upsert_oauth2_client
dd = Path(data_dir).expanduser().resolve()
db = open_db(dd)
upsert_oauth2_client(
db,
client_id=client_id,
client_secret=client_secret,
name=name,
redirect_uris=list(redirect_uris),
scopes=scopes,
)
db.close()
console.print(f" [green]✓[/green] client [cyan]{client_id}[/cyan] saved")
if not client_secret:
console.print(" [yellow]![/yellow] No secret — this client must use PKCE (public client)")
@client_group.command("list")
@click.option("--data-dir", required=True, type=click.Path())
def client_list_cmd(data_dir: str) -> None:
"""List registered OAuth2 clients."""
from bincio.auth.db import open_db
dd = Path(data_dir).expanduser().resolve()
db = open_db(dd)
rows = db.execute(
"SELECT client_id, name, redirect_uris, scopes, client_secret IS NOT NULL AS has_secret FROM oauth2_clients ORDER BY client_id"
).fetchall()
db.close()
if not rows:
console.print("[yellow]No clients registered.[/yellow]")
return
import json
for r in rows:
secret_label = "[green]confidential[/green]" if r["has_secret"] else "[yellow]public/PKCE[/yellow]"
uris = ", ".join(json.loads(r["redirect_uris"]))
console.print(f" [bold]{r['client_id']}[/bold] ({r['name']}) — {secret_label}")
console.print(f" scopes: {r['scopes']}")
console.print(f" redirects: {uris}")
+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()
+24 -1
View File
@@ -11,12 +11,14 @@ import jwt as _jwt
from fastapi import Cookie, HTTPException, Request, Response
from bincio.auth.db import User, get_user, open_db
from bincio.auth.tokens import create_token, decode_token
from bincio.auth.tokens import create_id_token, create_token, decode_token
# ── Module-level state (set by CLI before uvicorn starts) ─────────────────────
data_dir: Path | None = None
jwt_secret: str = ""
oidc_private_key_pem: str = "" # RS256 private key PEM (loaded from file at startup)
oidc_issuer: str = "" # e.g. "https://bincio.org"
_db = None
# ── Constants ─────────────────────────────────────────────────────────────────
@@ -63,6 +65,27 @@ def _issue_jwt(user: User) -> str:
)
_ID_TOKEN_TTL = 3600 # 1 hour — short-lived; clients use refresh or re-auth
def _issue_id_token(user: User, client_id: str, nonce: str | None = None) -> str:
"""Issue an RS256 OIDC id_token for the given user and client."""
claims: dict = {
"iss": oidc_issuer,
"sub": user.handle,
"aud": client_id,
"name": user.display_name,
"preferred_username": user.handle,
# bincio-specific claims (used by bincio-activity for authz)
"is_admin": user.is_admin,
"wiki_access": user.wiki_access,
"activity_access": user.activity_access,
}
if nonce:
claims["nonce"] = nonce
return create_id_token(claims, oidc_private_key_pem, _ID_TOKEN_TTL)
# ── Rate limiting ─────────────────────────────────────────────────────────────
def _check_rate_limit(
+257
View File
@@ -0,0 +1,257 @@
"""OIDC / OAuth2 endpoints for bincio-auth acting as an Identity Provider.
Implements the authorization code flow (+ PKCE for public clients).
Endpoints are at standard paths:
GET /.well-known/openid-configuration
GET /.well-known/jwks.json
GET /oauth2/authorize
POST /oauth2/token
GET /oauth2/userinfo
"""
from __future__ import annotations
import base64
import hashlib
import time
from urllib.parse import urlencode
from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from bincio.auth import deps
from bincio.auth.db import (
create_oauth2_code,
get_oauth2_client,
get_oauth2_code,
get_user,
use_oauth2_code,
)
from bincio.auth.tokens import decode_id_token, get_jwks
router = APIRouter()
# ── Discovery ─────────────────────────────────────────────────────────────────
@router.get("/.well-known/openid-configuration")
async def oidc_discovery() -> JSONResponse:
if not deps.oidc_issuer:
raise HTTPException(503, "OIDC not configured")
iss = deps.oidc_issuer.rstrip("/")
return JSONResponse({
"issuer": iss,
"authorization_endpoint": f"{iss}/oauth2/authorize",
"token_endpoint": f"{iss}/oauth2/token",
"userinfo_endpoint": f"{iss}/oauth2/userinfo",
"jwks_uri": f"{iss}/.well-known/jwks.json",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
"code_challenge_methods_supported": ["S256"],
"claims_supported": [
"iss", "sub", "aud", "exp", "iat", "nonce",
"name", "preferred_username",
],
})
@router.get("/.well-known/jwks.json")
async def jwks() -> JSONResponse:
if not deps.oidc_private_key_pem:
raise HTTPException(503, "OIDC not configured")
return JSONResponse(get_jwks(deps.oidc_private_key_pem))
# ── Authorization endpoint ────────────────────────────────────────────────────
@router.get("/oauth2/authorize")
async def authorize(
request: Request,
bincio_session: str | None = Cookie(default=None),
) -> RedirectResponse:
if not deps.oidc_issuer:
raise HTTPException(503, "OIDC not configured")
p = request.query_params
client_id = p.get("client_id", "")
redirect_uri = p.get("redirect_uri", "")
response_type = p.get("response_type", "")
scope = p.get("scope", "openid")
state = p.get("state", "")
nonce = p.get("nonce") or None
code_challenge = p.get("code_challenge") or None
code_challenge_method = p.get("code_challenge_method") or None
def _err(error: str, description: str) -> RedirectResponse:
qs = urlencode({"error": error, "error_description": description, "state": state})
return RedirectResponse(f"{redirect_uri}?{qs}", status_code=302)
# Validate client and redirect_uri before anything else
if not client_id or not redirect_uri:
raise HTTPException(400, "client_id and redirect_uri are required")
client = get_oauth2_client(deps._get_db(), client_id)
if not client:
raise HTTPException(400, "Unknown client_id")
if redirect_uri not in client["redirect_uris"]:
raise HTTPException(400, "redirect_uri not registered for this client")
if response_type != "code":
return _err("unsupported_response_type", "Only response_type=code is supported")
# Check session — redirect to login if not authenticated
user = deps._current_user(bincio_session)
if not user:
login_url = f"{deps.oidc_issuer.rstrip('/')}/login/"
next_url = str(request.url)
return RedirectResponse(
f"{login_url}?next={_pct_encode(next_url)}",
status_code=302,
)
# Issue authorization code
code = create_oauth2_code(
deps._get_db(),
client_id=client_id,
handle=user.handle,
redirect_uri=redirect_uri,
scope=scope,
nonce=nonce,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
)
qs_parts: dict[str, str] = {"code": code}
if state:
qs_parts["state"] = state
return RedirectResponse(f"{redirect_uri}?{urlencode(qs_parts)}", status_code=302)
# ── Token endpoint ────────────────────────────────────────────────────────────
@router.post("/oauth2/token")
async def token(request: Request) -> JSONResponse:
if not deps.oidc_issuer:
raise HTTPException(503, "OIDC not configured")
form = await request.form()
grant_type = str(form.get("grant_type", ""))
code = str(form.get("code", ""))
redirect_uri = str(form.get("redirect_uri", ""))
client_id = str(form.get("client_id", ""))
client_secret = str(form.get("client_secret", ""))
code_verifier = str(form.get("code_verifier", ""))
if grant_type != "authorization_code":
raise HTTPException(400, "unsupported_grant_type")
if not code or not redirect_uri or not client_id:
raise HTTPException(400, "code, redirect_uri, client_id are required")
client = get_oauth2_client(deps._get_db(), client_id)
if not client:
raise HTTPException(401, "invalid_client")
# Authenticate client: secret (confidential) or PKCE verifier (public)
if client["client_secret"] is not None:
if client_secret != client["client_secret"]:
raise HTTPException(401, "invalid_client")
# Public clients (no secret) rely on PKCE — verified below against the code record
rec = get_oauth2_code(deps._get_db(), code)
if not rec:
raise HTTPException(400, "invalid_grant")
if rec["used_at"] is not None:
raise HTTPException(400, "invalid_grant")
if rec["expires_at"] < int(time.time()):
raise HTTPException(400, "invalid_grant")
if rec["client_id"] != client_id:
raise HTTPException(400, "invalid_grant")
if rec["redirect_uri"] != redirect_uri:
raise HTTPException(400, "invalid_grant")
# PKCE verification for public clients
if rec["code_challenge"]:
if not code_verifier:
raise HTTPException(400, "code_verifier required")
if not _verify_pkce(code_verifier, rec["code_challenge"], rec["code_challenge_method"] or "S256"):
raise HTTPException(400, "invalid_grant")
use_oauth2_code(deps._get_db(), code)
user = get_user(deps._get_db(), rec["handle"])
if not user or user.suspended:
raise HTTPException(400, "invalid_grant")
id_token = deps._issue_id_token(user, client_id, nonce=rec["nonce"])
return JSONResponse({
"access_token": id_token, # access_token = id_token (validated by userinfo)
"token_type": "Bearer",
"expires_in": deps._ID_TOKEN_TTL,
"id_token": id_token,
})
# ── Userinfo endpoint ─────────────────────────────────────────────────────────
@router.get("/oauth2/userinfo")
async def userinfo(request: Request) -> JSONResponse:
if not deps.oidc_issuer:
raise HTTPException(503, "OIDC not configured")
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(401, "Bearer token required")
token_str = auth[7:]
# We need the client_id (audience) to validate — try all registered clients
# by decoding without aud check first, then validate aud matches a real client
try:
import jwt as _jwt
from cryptography.hazmat.primitives.serialization import load_pem_private_key
priv = load_pem_private_key(deps.oidc_private_key_pem.encode(), password=None)
payload = _jwt.decode(
token_str,
priv.public_key(),
algorithms=["RS256"],
options={"verify_aud": False},
)
except Exception:
raise HTTPException(401, "Invalid token")
handle = payload.get("sub")
if not handle:
raise HTTPException(401, "Invalid token")
user = get_user(deps._get_db(), handle)
if not user or user.suspended:
raise HTTPException(401, "User not found or suspended")
return JSONResponse({
"sub": user.handle,
"name": user.display_name,
"preferred_username": user.handle,
"is_admin": user.is_admin,
"wiki_access": user.wiki_access,
"activity_access": user.activity_access,
})
# ── Helpers ───────────────────────────────────────────────────────────────────
def _pct_encode(url: str) -> str:
from urllib.parse import quote
return quote(url, safe="")
def _verify_pkce(verifier: str, challenge: str, method: str) -> bool:
if method == "S256":
digest = hashlib.sha256(verifier.encode()).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return computed == challenge
if method == "plain":
return verifier == challenge
return False
+2 -2
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from bincio.auth.routers import admin, auth, invites
from bincio.auth.routers import admin, auth, invites, oidc
app = FastAPI(title="bincio-auth")
@@ -19,5 +19,5 @@ app.add_middleware(
allow_headers=["Content-Type", "Authorization"],
)
for _router in [auth.router, invites.router, admin.router]:
for _router in [auth.router, invites.router, admin.router, oidc.router]:
app.include_router(_router)
+47 -10
View File
@@ -1,28 +1,65 @@
"""JWT helpers for bincio-auth.
Tokens are HS256-signed JWTs. Consumers validate locally using the shared
secret no round-trip to the auth service per request.
HS256 tokens: used for session cookies (existing behaviour, shared secret).
RS256 tokens: used for OIDC id_tokens (asymmetric, public key via JWKS).
"""
from __future__ import annotations
import base64
import time
import jwt
from cryptography.hazmat.primitives.serialization import load_pem_private_key
_KID = "bincio-oidc-1"
# ── HS256 (session cookies) ───────────────────────────────────────────────────
def create_token(payload: dict, secret: str, expires_in: int) -> str:
"""Return a signed JWT.
Args:
payload: Claims to embed (will be shallow-copied; 'exp' is added).
secret: HS256 signing key.
expires_in: Validity window in seconds from now.
"""
claims = {**payload, "exp": int(time.time()) + expires_in}
return jwt.encode(claims, secret, algorithm="HS256")
def decode_token(token: str, secret: str) -> dict:
"""Decode and verify a JWT. Raises jwt.PyJWTError on any failure."""
"""Decode and verify an HS256 JWT. Raises jwt.PyJWTError on any failure."""
return jwt.decode(token, secret, algorithms=["HS256"])
# ── RS256 (OIDC id_tokens) ────────────────────────────────────────────────────
def create_id_token(payload: dict, private_key_pem: str, expires_in: int) -> str:
"""Sign an OIDC id_token with RS256. payload should include iss, sub, aud."""
now = int(time.time())
claims = {**payload, "iat": now, "exp": now + expires_in}
private_key = load_pem_private_key(private_key_pem.encode(), password=None)
return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": _KID})
def get_jwks(private_key_pem: str) -> dict:
"""Return the JWKS document for the given RSA private key."""
private_key = load_pem_private_key(private_key_pem.encode(), password=None)
pub = private_key.public_key().public_numbers()
def b64url(n: int) -> str:
length = (n.bit_length() + 7) // 8
return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
return {
"keys": [{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": _KID,
"n": b64url(pub.n),
"e": b64url(pub.e),
}]
}
def decode_id_token(token: str, private_key_pem: str, audience: str) -> dict:
"""Decode and verify an RS256 id_token (used by userinfo endpoint)."""
private_key = load_pem_private_key(private_key_pem.encode(), password=None)
public_key = private_key.public_key()
return jwt.decode(token, public_key, algorithms=["RS256"], audience=audience)