From 9d807a6cf1ddac95d11362d2ce4063acf406b03d Mon Sep 17 00:00:00 2001 From: LovroG05 Date: Sun, 28 Jun 2026 23:46:30 +0200 Subject: [PATCH] feat: OIDC server-side login flow with confidential client Add /auth/login and /auth/callback handlers. The backend holds the client secret and exchanges the authorization code server-side; the browser never sees the secret. Redirect URI and post-login fragment destination are derived solely from GATEWAY_SERVER_PUBLIC_URL (required when OIDC is set) to prevent host-header injection. --- backend/.env.example | 6 + backend/Makefile | 58 +++++++++- backend/cmd/server/main.go | 12 +- backend/dashboard/src/pages/Login.tsx | 160 +++++++++++++++++--------- backend/dashboard/vite.config.ts | 6 +- backend/go.mod | 2 +- backend/internal/api/api.go | 16 ++- backend/internal/api/auth.go | 30 +++++ backend/internal/api/oidclogin.go | 100 ++++++++++++++++ backend/internal/config/config.go | 32 ++++-- backend/internal/oidcauth/oidcauth.go | 11 ++ 11 files changed, 359 insertions(+), 74 deletions(-) create mode 100644 backend/internal/api/auth.go create mode 100644 backend/internal/api/oidclogin.go diff --git a/backend/.env.example b/backend/.env.example index 59edd39..cdda432 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,10 @@ GATEWAY_SERVER_PORT=8080 # development | production (controls log format/level) GATEWAY_SERVER_ENV=development +# Canonical external URL — required when OIDC is enabled. +# Must match the redirect URI registered in your OIDC provider. +# Dev example: http://localhost:8080 Prod: https://lmr.example.com +GATEWAY_SERVER_PUBLIC_URL=http://localhost:8080 # --- Database --- GATEWAY_DB_URL=postgres://gateway:gateway@localhost:5432/gateway @@ -17,8 +21,10 @@ GATEWAY_DB_MIN_CONNS=2 GATEWAY_DB_MAX_CONN_LIFETIME=30 # --- OIDC (Authentik or any OIDC provider) --- +# Leave GATEWAY_OIDC_ISSUER empty to disable OIDC and use only the static token. GATEWAY_OIDC_ISSUER=https://auth.example.com/application/o/llmgateway/ GATEWAY_OIDC_CLIENT_ID=your-client-id +GATEWAY_OIDC_CLIENT_SECRET=your-client-secret GATEWAY_OIDC_ADMIN_GROUP=llm-admins # --- Security --- diff --git a/backend/Makefile b/backend/Makefile index dd96c59..27d3dc1 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -3,15 +3,69 @@ -include .env export -BINARY := bin/gateway +BINARY := bin/gateway +DB_NAME := lmr-db +DB_PORT := 5432 +DB_USER := gateway +DB_PASS := gateway .DEFAULT_GOAL := help -.PHONY: help run build ui migrate-up migrate-down test lint tidy clean +.PHONY: help dev dev-stop run build ui migrate-up migrate-down test lint tidy clean help: ## List available targets @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) \ | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' +# --------------------------------------------------------------------------- +# Development +# --------------------------------------------------------------------------- + +dev: .env ## Start Postgres, run the Go server, and the Vite dev server in parallel + @# Ensure the .env has non-empty GATEWAY_SECURITY_ENCRYPTION_KEY + @grep -q 'GATEWAY_SECURITY_ENCRYPTION_KEY=.\+' .env || \ + { echo ""; \ + echo " ERROR: GATEWAY_SECURITY_ENCRYPTION_KEY is empty in .env"; \ + echo " Run: echo GATEWAY_SECURITY_ENCRYPTION_KEY=\$$(openssl rand -hex 32) >> .env"; \ + echo ""; exit 1; } + @# Start Postgres container if not already running + @if ! docker ps --format '{{.Names}}' | grep -q '^$(DB_NAME)$$'; then \ + if docker ps -a --format '{{.Names}}' | grep -q '^$(DB_NAME)$$'; then \ + echo "→ starting existing postgres container $(DB_NAME)"; \ + docker start $(DB_NAME); \ + else \ + echo "→ creating postgres container $(DB_NAME)"; \ + docker run -d --name $(DB_NAME) \ + -e POSTGRES_USER=$(DB_USER) \ + -e POSTGRES_PASSWORD=$(DB_PASS) \ + -e POSTGRES_DB=$(DB_USER) \ + -p $(DB_PORT):5432 \ + postgres:16-alpine; \ + fi; \ + else \ + echo "→ postgres $(DB_NAME) already running"; \ + fi + @# Wait until Postgres accepts connections + @echo "→ waiting for postgres…"; \ + until docker exec $(DB_NAME) pg_isready -U $(DB_USER) -q 2>/dev/null; do sleep 1; done; \ + echo "→ postgres ready" + @# Run Go server and Vite dev server side-by-side, kill both on Ctrl-C + @trap 'kill 0' INT; \ + go run ./cmd/server & \ + (cd dashboard && npm run dev) & \ + wait + +dev-stop: ## Stop and remove the dev Postgres container + docker rm -f $(DB_NAME) 2>/dev/null && echo "→ removed $(DB_NAME)" || echo "→ $(DB_NAME) not found" + +.env: + @echo ""; \ + echo " ERROR: .env not found. Copy the example and fill in the required fields:"; \ + echo ""; \ + echo " cp .env.example .env"; \ + echo " echo GATEWAY_SECURITY_ENCRYPTION_KEY=\$$(openssl rand -hex 32) >> .env"; \ + echo " echo GATEWAY_SECURITY_ADMIN_TOKEN=\$$(openssl rand -hex 16) >> .env"; \ + echo ""; exit 1 + run: ## Run the gateway (migrations applied automatically on startup) go run ./cmd/server diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 17b5cc3..646bb03 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -109,7 +109,7 @@ func run() error { go syncer.Run(ctx, cfg.Catalog.SyncInterval, refresh) - a := api.New(api.Deps{ + deps := api.Deps{ Store: st, Cache: cache, Manager: mgr, @@ -118,7 +118,15 @@ func run() error { OIDCVerifier: oidcV, Limiter: limiter, SyncNow: syncNow, - }) + } + if oidcV != nil { + deps.OIDCClientID = cfg.OIDC.ClientID + deps.OIDCClientSecret = cfg.OIDC.ClientSecret + deps.OIDCAuthURL = oidcV.AuthURL() + deps.OIDCTokenURL = oidcV.TokenURL() + deps.PublicBaseURL = cfg.Server.PublicURL + } + a := api.New(deps) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Server.Port), diff --git a/backend/dashboard/src/pages/Login.tsx b/backend/dashboard/src/pages/Login.tsx index 53b6119..e8e687d 100644 --- a/backend/dashboard/src/pages/Login.tsx +++ b/backend/dashboard/src/pages/Login.tsx @@ -1,12 +1,53 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { setToken } from '../lib/api' +interface AuthConfig { + oidc_enabled: boolean + static_token: boolean +} + +const LOGO = ( +
+ + + + + + + +
+) + export default function Login({ onLogin }: { onLogin: () => void }) { + const [cfg, setCfg] = useState(null) const [token, setTok] = useState('') const [err, setErr] = useState('') const [loading, setLoading] = useState(false) - async function submit(e: React.FormEvent) { + useEffect(() => { + // Check if returning from OIDC callback — server puts the token in the fragment. + const hash = new URLSearchParams(window.location.hash.slice(1)) + const fragToken = hash.get('token') + if (fragToken) { + window.history.replaceState({}, '', '/') + setToken(decodeURIComponent(fragToken)) + onLogin() + return + } + + fetch('/api/auth/config') + .then(r => r.json()) + .then(setCfg) + .catch(() => setCfg({ oidc_enabled: false, static_token: true })) + }, []) + + async function submitToken(e: React.FormEvent) { e.preventDefault() setErr('') setLoading(true) @@ -14,14 +55,8 @@ export default function Login({ onLogin }: { onLogin: () => void }) { const res = await fetch('/api/admin/providers', { headers: { Authorization: `Bearer ${token}` }, }) - if (res.status === 401 || res.status === 403) { - setErr('Invalid token') - return - } - if (!res.ok && res.status !== 404) { - setErr('Server error — is the backend running?') - return - } + if (res.status === 401 || res.status === 403) { setErr('Invalid token'); return } + if (!res.ok && res.status !== 404) { setErr('Server error — is the backend running?'); return } setToken(token) onLogin() } catch { @@ -31,38 +66,60 @@ export default function Login({ onLogin }: { onLogin: () => void }) { } } - return ( -
-
+ const card = (children: React.ReactNode) => ( +
+
-
- - - - - - - -
+ {LOGO}
Local Model Routex
Admin Dashboard
+ {children} +
+
+ ) -
+ if (!cfg) return card( +
Loading…
+ ) + + return card( + <> + {err && ( +
+ {err} +
+ )} + + {cfg.oidc_enabled && ( + + + + )} + + {cfg.oidc_enabled && cfg.static_token && ( +
+
+ or +
+
+ )} + + {cfg.static_token && ( + @@ -71,34 +128,27 @@ export default function Login({ onLogin }: { onLogin: () => void }) { value={token} onChange={e => setTok(e.target.value)} placeholder="sk-…" - autoFocus + autoFocus={!cfg.oidc_enabled} style={{ width: '100%', background: 'rgba(0,0,0,0.28)', border: '1px solid rgba(255,255,255,0.09)', borderRadius: 7, padding: '9px 12px', fontSize: 13, color: '#ededf0', - outline: 'none', fontFamily: "'DM Mono',monospace", - marginBottom: err ? 8 : 16, + outline: 'none', fontFamily: "'DM Mono',monospace", marginBottom: 12, }} /> - {err &&
{err}
} - - -

- Set GATEWAY_SECURITY_ADMIN_TOKEN in .env -

-
-
+ )} + ) } diff --git a/backend/dashboard/vite.config.ts b/backend/dashboard/vite.config.ts index ac20c7e..dc29d4c 100644 --- a/backend/dashboard/vite.config.ts +++ b/backend/dashboard/vite.config.ts @@ -14,9 +14,9 @@ export default defineConfig({ server: { // Forward gateway routes to the Go server during `npm run dev`. proxy: { - '/v1': 'http://localhost:8080', - '/api': 'http://localhost:8080', - '/auth': 'http://localhost:8080', + '/v1': 'http://localhost:8080', + '/api': 'http://localhost:8080', + '/auth': { target: 'http://localhost:8080', changeOrigin: false }, }, }, }); diff --git a/backend/go.mod b/backend/go.mod index 4a88db1..b505cf5 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/spf13/viper v1.21.0 + golang.org/x/oauth2 v0.36.0 ) require ( @@ -27,7 +28,6 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 7c737b6..e1a777b 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -27,7 +27,16 @@ type Deps struct { Manager *providermgr.Manager Cipher *crypto.Cipher AdminToken string - OIDCVerifier oidcVerifier // nil when OIDC is not configured + OIDCVerifier oidcVerifier // nil when OIDC is not configured + OIDCClientID string // empty when OIDC is not configured + OIDCClientSecret string + OIDCAuthURL string // authorization_endpoint from discovery + OIDCTokenURL string // token_endpoint from discovery + // PublicBaseURL is the canonical external URL of this server (e.g. + // "https://lmr.example.com"). Required when OIDC is enabled — used to + // build the redirect_uri and the post-login fragment redirect so neither + // can be overridden by a forged Host/X-Forwarded-Host header. + PublicBaseURL string Limiter *ratelimit.Limiter // SyncNow triggers an immediate provider reload + catalog sync (used after // admin changes). May be nil. @@ -51,6 +60,11 @@ func (a *API) Mount(r chi.Router) { r.Get("/models", a.listModels) }) + // Public auth endpoints — no auth middleware. + r.Get("/api/auth/config", a.authConfig) + r.Get("/auth/login", a.oidcLogin) + r.Get("/auth/callback", a.oidcCallback) + r.Route("/api/admin", func(r chi.Router) { r.Use(a.adminAuth) r.Get("/providers", a.adminListProviders) diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..059fd1e --- /dev/null +++ b/backend/internal/api/auth.go @@ -0,0 +1,30 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +type authConfigResponse struct { + OIDCEnabled bool `json:"oidc_enabled"` + ClientID string `json:"client_id,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + TokenURL string `json:"token_url,omitempty"` + StaticToken bool `json:"static_token"` +} + +// authConfig is a public endpoint (no auth) that tells the dashboard which +// login methods are available and the OIDC endpoints to use for PKCE flow. +func (a *API) authConfig(w http.ResponseWriter, r *http.Request) { + resp := authConfigResponse{ + OIDCEnabled: a.OIDCVerifier != nil, + StaticToken: a.AdminToken != "", + } + if a.OIDCVerifier != nil { + resp.ClientID = a.OIDCClientID + resp.AuthURL = a.OIDCAuthURL + resp.TokenURL = a.OIDCTokenURL + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/backend/internal/api/oidclogin.go b/backend/internal/api/oidclogin.go new file mode 100644 index 0000000..5d2680a --- /dev/null +++ b/backend/internal/api/oidclogin.go @@ -0,0 +1,100 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + "net/url" + "time" + + "golang.org/x/oauth2" +) + +const ( + oidcStateCookie = "oidc_state" + oidcStateTTL = 5 * time.Minute +) + +// oidcLogin starts the Authorization Code flow: sets a state cookie and +// redirects the browser to the provider's authorization endpoint. +func (a *API) oidcLogin(w http.ResponseWriter, r *http.Request) { + if a.OIDCVerifier == nil { + http.Error(w, "OIDC not configured", http.StatusNotFound) + return + } + + b := make([]byte, 16) + _, _ = rand.Read(b) + state := base64.RawURLEncoding.EncodeToString(b) + + http.SetCookie(w, &http.Cookie{ + Name: oidcStateCookie, + Value: state, + Path: "/auth", + MaxAge: int(oidcStateTTL.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, a.oauth2Config().AuthCodeURL(state), http.StatusTemporaryRedirect) +} + +// oidcCallback receives the authorization code from the provider, exchanges it +// server-side (using the client secret), verifies the id_token, then redirects +// the browser to the SPA with the token in the URL fragment. +func (a *API) oidcCallback(w http.ResponseWriter, r *http.Request) { + if a.OIDCVerifier == nil { + http.Error(w, "OIDC not configured", http.StatusNotFound) + return + } + + // Verify state matches the cookie we set in oidcLogin. + cookie, err := r.Cookie(oidcStateCookie) + if err != nil || cookie.Value != r.URL.Query().Get("state") { + http.Error(w, "invalid state", http.StatusBadRequest) + return + } + // Consume the state cookie. + http.SetCookie(w, &http.Cookie{ + Name: oidcStateCookie, + Value: "", + Path: "/auth", + MaxAge: -1, + }) + + tok, err := a.oauth2Config().Exchange(r.Context(), r.URL.Query().Get("code")) + if err != nil { + http.Error(w, "token exchange failed: "+err.Error(), http.StatusBadRequest) + return + } + + idToken, _ := tok.Extra("id_token").(string) + if idToken == "" { + http.Error(w, "no id_token in provider response", http.StatusBadRequest) + return + } + + // Verify signature + group membership before handing the token to the browser. + if err := a.OIDCVerifier.VerifyAdmin(r.Context(), idToken); err != nil { + http.Error(w, "unauthorized: "+err.Error(), http.StatusForbidden) + return + } + + // Redirect to the SPA root with the token in the URL fragment. + // PublicBaseURL is set from config — never from request headers — so a + // forged Host / X-Forwarded-Host cannot redirect the token elsewhere. + dest := a.PublicBaseURL + "/#token=" + url.QueryEscape(idToken) + http.Redirect(w, r, dest, http.StatusTemporaryRedirect) +} + +// oauth2Config returns an oauth2.Config using the pinned PublicBaseURL for the +// redirect_uri so it always matches what is registered in the OIDC provider. +func (a *API) oauth2Config() *oauth2.Config { + return &oauth2.Config{ + ClientID: a.OIDCClientID, + ClientSecret: a.OIDCClientSecret, + RedirectURL: a.PublicBaseURL + "/auth/callback", + Scopes: []string{"openid", "profile", "email", "groups"}, + Endpoint: oauth2.Endpoint{AuthURL: a.OIDCAuthURL, TokenURL: a.OIDCTokenURL}, + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 56d3994..440ed3b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -21,8 +21,9 @@ type CatalogConfig struct { } type ServerConfig struct { - Port int - Env string // "development" | "production" + Port int + Env string // "development" | "production" + PublicURL string // canonical external URL, required when OIDC is enabled } type DBConfig struct { @@ -33,9 +34,10 @@ type DBConfig struct { } type OIDCConfig struct { - Issuer string - ClientID string - AdminGroup string + Issuer string + ClientID string + ClientSecret string + AdminGroup string } type SecurityConfig struct { @@ -63,8 +65,9 @@ func Load() (*Config, error) { cfg := &Config{ Server: ServerConfig{ - Port: v.GetInt("server.port"), - Env: v.GetString("server.env"), + Port: v.GetInt("server.port"), + Env: v.GetString("server.env"), + PublicURL: v.GetString("server.public_url"), }, DB: DBConfig{ URL: v.GetString("db.url"), @@ -73,9 +76,10 @@ func Load() (*Config, error) { MaxConnLifetime: v.GetInt("db.max_conn_lifetime"), }, OIDC: OIDCConfig{ - Issuer: v.GetString("oidc.issuer"), - ClientID: v.GetString("oidc.client_id"), - AdminGroup: v.GetString("oidc.admin_group"), + Issuer: v.GetString("oidc.issuer"), + ClientID: v.GetString("oidc.client_id"), + ClientSecret: v.GetString("oidc.client_secret"), + AdminGroup: v.GetString("oidc.admin_group"), }, Security: SecurityConfig{ EncryptionKey: v.GetString("security.encryption_key"), @@ -105,9 +109,17 @@ func (c *Config) validate() error { if c.OIDC.ClientID == "" { missing = append(missing, "oidc.client_id") } + if c.OIDC.ClientSecret == "" { + missing = append(missing, "oidc.client_secret") + } if c.OIDC.AdminGroup == "" { missing = append(missing, "oidc.admin_group") } + // PublicURL pins the OAuth redirect_uri and the post-login redirect so + // neither can be overridden by a forged Host / X-Forwarded-Host header. + if c.Server.PublicURL == "" { + missing = append(missing, "server.public_url") + } if len(missing) > 0 { return fmt.Errorf("oidc.issuer is set but missing: %s", strings.Join(missing, ", ")) } diff --git a/backend/internal/oidcauth/oidcauth.go b/backend/internal/oidcauth/oidcauth.go index 09bcfa8..49da8f4 100644 --- a/backend/internal/oidcauth/oidcauth.go +++ b/backend/internal/oidcauth/oidcauth.go @@ -13,6 +13,8 @@ import ( type Verifier struct { v *oidc.IDTokenVerifier adminGroup string + authURL string + tokenURL string } // New creates a Verifier by fetching the issuer's OIDC discovery document. @@ -22,12 +24,21 @@ func New(ctx context.Context, issuer, clientID, adminGroup string) (*Verifier, e if err != nil { return nil, fmt.Errorf("oidc discovery %q: %w", issuer, err) } + ep := provider.Endpoint() return &Verifier{ v: provider.Verifier(&oidc.Config{ClientID: clientID}), adminGroup: adminGroup, + authURL: ep.AuthURL, + tokenURL: ep.TokenURL, }, nil } +// AuthURL returns the provider's authorization endpoint. +func (v *Verifier) AuthURL() string { return v.authURL } + +// TokenURL returns the provider's token endpoint. +func (v *Verifier) TokenURL() string { return v.tokenURL } + // VerifyAdmin checks that rawToken is a valid JWT from this issuer and that its // "groups" claim includes the configured admin group. func (v *Verifier) VerifyAdmin(ctx context.Context, rawToken string) error {