feat(oidc): backend-mediated OIDC login + session cookies + cli config

Adds the OpenID Connect login flow Dialectic.Frontend will drive. Pattern
mirrors Fabric.Backend.Center: SPA → /api/auth/oidc/start → IdP →
/api/auth/oidc/callback → 302 to SPA with one-time ticket in URL fragment
→ SPA POST /api/auth/oidc/exchange → HttpOnly session cookie set.

What's added:

  - internal/oidc/service.go — runtime OIDC service:
    * BuildAuthorizeURL (PKCE S256 + random state, 10min ttl)
    * HandleCallback (token exchange + ID token verify + ticket mint, 60s ttl)
    * ExchangeTicket (ticket → session JWT, HS256 24h)
    * VerifySession (cookie validation)
    * GetConfig/SetConfig with sync.Map-backed state/ticket stores
    * SweepExpired (call from background goroutine; clears stale entries)
  - internal/db/migrations/004_oidc_config.sql — single-row oidc_config
    table (issuer/client_id/client_secret/redirect_uri/post_login_redirect/
    scopes/enabled). Runtime-mutable via dialectic-cli.
  - internal/httpapi/handlers/auth.go — 5 endpoints:
    GET  /api/auth/oidc/status   — { enabled }
    GET  /api/auth/oidc/start    — 302 to IdP
    GET  /api/auth/oidc/callback — IdP returns; we 302 to SPA with ticket
    POST /api/auth/oidc/exchange — ticket → cookie + user
    GET  /api/auth/me            — current session user (401 if anon)
    POST /api/auth/logout        — clears cookie
  - internal/auth: replaces the OIDCBrowser Phase-2C stub with one that
    reads the session cookie via SessionVerifier; keeps dev-bypass
    behind cfg.OIDCOnly gate (set OIDC_ONLY=true in prod to disable
    dev-bypass entirely)
  - cmd/dialectic-cli/main.go — new binary; subcommand
    'config oidc [--issuer ... --client-id ... --client-secret ...
                  --callback-url ... --enabled true|false]'
    Runs against same DB the backend uses; reachable via
    'docker exec dialectic-backend dialectic-cli config oidc ...'
  - Dockerfile: build both binaries; put on PATH for docker exec

Config:

  - SESSION_SIGNING_KEY env: required in prod, ephemeral random in dev.
    HS256 secret for session JWTs. Stable across restarts (rotation
    invalidates every session — kill switch).
  - OIDC_ONLY env: 'true' disables the dev-bypass path entirely; use
    in prod once OIDC is configured.
  - OIDC_ISSUER + OIDC_CLIENT_ID env are no longer required at boot —
    they're advisory bootstrap values for the oidc_config DB row.

Deps:
  - github.com/coreos/go-oidc/v3 (discovery + JWKS verify)
  - golang.org/x/oauth2 (token exchange + PKCE)
  - github.com/golang-jwt/jwt/v5 (session JWT)
  - Bumped go.mod toolchain to 1.25.

Pairs with Dialectic.Frontend (next commit) which removes the
/agents/:id admin page and adds the login button + /oidc/callback
SPA route + AuthProvider that talks to these new endpoints.
This commit is contained in:
h z
2026-05-24 01:40:36 +01:00
parent 0b16b52ee7
commit 2463129dbd
11 changed files with 949 additions and 33 deletions

145
cmd/dialectic-cli/main.go Normal file
View File

@@ -0,0 +1,145 @@
// dialectic-cli — operator/admin commands for the running Dialectic
// backend. Reads the same DB the backend reads + writes; takes no
// HTTP path so it can run inside the same container as the backend
// (docker exec dialectic-backend dialectic-cli ...) or against the
// same DB from anywhere with credentials.
//
// Subcommands today:
//
// dialectic-cli config oidc [--issuer URL] [--client-id ID]
// [--client-secret S] [--callback-url URL]
// [--post-login-redirect URL]
// [--scopes "openid email profile"]
// [--enabled true|false]
// [--show-secret]
//
// Pattern mirrors Fabric.Backend.Center's `node dist/cli.js config oidc`.
// Only the flags you pass mutate; others stay unchanged. Default print
// masks client_secret (use --show-secret to reveal — local audit only,
// never paste output into chat).
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/config"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/db"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/oidc"
)
func main() {
if len(os.Args) < 3 {
usage()
}
subject := os.Args[1]
action := os.Args[2]
args := os.Args[3:]
if subject != "config" || action != "oidc" {
usage()
}
patch := oidc.ConfigPatch{}
showSecret := false
for i := 0; i < len(args); i++ {
switch args[i] {
case "--issuer":
patch.Issuer = strArg(args, &i)
case "--client-id":
patch.ClientID = strArg(args, &i)
case "--client-secret":
patch.ClientSecret = strArg(args, &i)
case "--callback-url":
patch.RedirectURI = strArg(args, &i)
case "--post-login-redirect":
patch.PostLoginRedirect = strArg(args, &i)
case "--scopes":
patch.Scopes = strArg(args, &i)
case "--enabled":
v := strArg(args, &i)
b := v != nil && *v == "true"
patch.Enabled = &b
case "--show-secret":
showSecret = true
default:
fmt.Fprintf(os.Stderr, "unknown flag: %s\n", args[i])
usage()
}
}
cfg, err := config.LoadFromEnv()
check("config", err)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
conn, err := db.Open(ctx, cfg.DSN())
check("db", err)
defer conn.Close()
// Don't auto-run migrations from the CLI — the backend container's
// startup is the canonical migration driver. The CLI assumes the
// table is already in place.
signingKey := []byte(cfg.SessionSigningKey)
if len(signingKey) == 0 {
signingKey = []byte("cli-only-irrelevant")
}
svc := oidc.NewService(conn, signingKey, 24*time.Hour)
updated, err := svc.SetConfig(ctx, patch)
check("set config", err)
out := map[string]any{
"issuer": updated.Issuer,
"client_id": updated.ClientID,
"redirect_uri": updated.RedirectURI,
"post_login_redirect": updated.PostLoginRedirect,
"scopes": updated.Scopes,
"enabled": updated.Enabled,
}
if showSecret {
out["client_secret"] = updated.ClientSecret
} else if updated.ClientSecret != "" {
out["client_secret"] = "***set***"
} else {
out["client_secret"] = ""
}
b, _ := json.MarshalIndent(map[string]any{"ok": true, "config": out}, "", " ")
fmt.Println(string(b))
}
func strArg(args []string, i *int) *string {
if *i+1 >= len(args) {
fmt.Fprintf(os.Stderr, "%s requires a value\n", args[*i])
os.Exit(1)
}
*i++
v := args[*i]
return &v
}
func check(label string, err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", label, err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, `Usage:
dialectic-cli config oidc [--issuer <url>] [--client-id <id>]
[--client-secret <s>] [--callback-url <url>]
[--post-login-redirect <url>]
[--scopes "openid email profile"]
[--enabled true|false] [--show-secret]
Sets only the flags you pass; leaves the rest unchanged. Prints the
post-update config with client_secret masked unless --show-secret.
Reads the same DB env as the backend (DB_HOST/PORT/USER/PASSWORD/NAME).
Run inside the backend container via:
docker exec dialectic-backend dialectic-cli config oidc ...
`)
os.Exit(1)
}