"""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}")