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}")
|
||||
|
||||
Reference in New Issue
Block a user