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:
145
cmd/dialectic-cli/main.go
Normal file
145
cmd/dialectic-cli/main.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user