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:
+89
-1
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.29",
|
||||
"python-multipart>=0.0.9",
|
||||
"bcrypt>=4.1",
|
||||
"PyJWT>=2.8",
|
||||
"PyJWT[crypto]>=2.8",
|
||||
"cryptography>=42.0",
|
||||
"click>=8.1",
|
||||
"rich>=13.0",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user