Files
bincio-auth/bincio/auth/cli.py
T
Davide Scaini 42bc476882 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
2026-06-03 15:11:43 +02:00

217 lines
8.1 KiB
Python

"""bincio-auth CLI entry point."""
from __future__ import annotations
import secrets
from pathlib import Path
import click
from rich.console import Console
from rich.panel import Panel
console = Console()
@click.group()
def main() -> None:
"""bincio-auth — central authentication service."""
@main.command("init")
@click.option("--data-dir", required=True, type=click.Path(), help="Data directory to initialise")
@click.option("--handle", required=True, help="Admin user handle")
@click.option("--password", required=True, hide_input=True, confirmation_prompt=True)
@click.option("--display-name", default="", help="Admin display name (defaults to handle)")
@click.option("--max-users", default=0, type=int, help="Max users (0 = unlimited)")
def init_cmd(data_dir: str, handle: str, password: str, display_name: str, max_users: int) -> None:
"""Bootstrap a fresh bincio-auth instance."""
from bincio.auth.db import create_invite, create_user, get_user, open_db, set_setting
dd = Path(data_dir).expanduser().resolve()
dd.mkdir(parents=True, exist_ok=True)
console.print(f"[bold]Initialising bincio-auth[/bold] at [cyan]{dd}[/cyan]")
db = open_db(dd)
console.print(" [green]✓[/green] instance.db ready")
existing = get_user(db, handle)
if existing:
console.print(f" [yellow]·[/yellow] user '{handle}' already exists — skipping")
else:
create_user(db, handle, display_name or handle, password, is_admin=True)
console.print(f" [green]✓[/green] admin '{handle}' created")
if max_users > 0:
set_setting(db, "max_users", str(max_users))
console.print(f" [green]✓[/green] user limit set to {max_users}")
# Generate JWT secret if not already stored
from bincio.auth.db import get_setting
if not get_setting(db, "jwt_secret"):
jwt_secret = secrets.token_hex(32)
set_setting(db, "jwt_secret", jwt_secret)
console.print(" [green]✓[/green] JWT secret generated and stored")
else:
console.print(" [yellow]·[/yellow] JWT secret already set — skipping")
code = create_invite(db, handle)
console.print()
console.print(Panel(
f"[bold green]Instance ready![/bold green]\n\n"
f"Admin: [cyan]{handle}[/cyan]\n"
f"Data dir: [cyan]{dd}[/cyan]\n\n"
f"First invite code:\n\n"
f" [bold yellow]{code}[/bold yellow]\n\n"
f"Share this with your first user:\n"
f" /register/?code={code}",
title="bincio-auth init",
border_style="green",
))
@main.command("show-secret")
@click.option("--data-dir", required=True, type=click.Path(), help="Data directory (contains instance.db)")
def show_secret_cmd(data_dir: str) -> None:
"""Print the JWT secret stored in the DB — use this to configure consumer services."""
from bincio.auth.db import get_setting, open_db
dd = Path(data_dir).expanduser().resolve()
if not (dd / "instance.db").exists():
raise click.UsageError(f"No instance.db in {dd}.")
db = open_db(dd)
secret = get_setting(db, "jwt_secret") or ""
db.close()
if not secret:
raise click.ClickException("No JWT secret found. Run `bincio-auth init` first.")
click.echo(secret)
@main.command("serve")
@click.option("--data-dir", required=True, type=click.Path(), help="Data directory (contains instance.db)")
@click.option("--host", default="127.0.0.1")
@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`.")
@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
import bincio.auth.server as srv
from bincio.auth import deps
from bincio.auth.db import get_setting, open_db
dd = Path(data_dir).expanduser().resolve()
if not (dd / "instance.db").exists():
raise click.UsageError(
f"No instance.db in {dd}. Run `bincio-auth init --data-dir {dd}` first."
)
db = open_db(dd)
secret = jwt_secret or get_setting(db, "jwt_secret") or ""
db.close()
if not secret:
raise click.UsageError(
"No JWT secret configured. Pass --jwt-secret or run `bincio-auth init` first."
)
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}")