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.
This commit is contained in:
2026-06-28 23:46:30 +02:00
parent 13e8849047
commit 9d807a6cf1
11 changed files with 359 additions and 74 deletions

View File

@@ -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 ---

View File

@@ -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

View File

@@ -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),

View File

@@ -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 = (
<div style={{
width: 34, height: 34, borderRadius: 9,
background: 'linear-gradient(145deg,#7c3aed 0%,#4f1d96 100%)',
boxShadow: '0 0 0 1px rgba(124,58,237,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
stroke="rgba(255,255,255,0.9)" strokeWidth="1.6" strokeLinecap="round">
<circle cx="8" cy="8" r="2.5"/>
<line x1="1.5" y1="8" x2="5" y2="8"/>
<line x1="11" y1="8" x2="14.5" y2="8"/>
<line x1="8" y1="1.5" x2="8" y2="5"/>
<line x1="8" y1="11" x2="8" y2="14.5"/>
</svg>
</div>
)
export default function Login({ onLogin }: { onLogin: () => void }) {
const [cfg, setCfg] = useState<AuthConfig | null>(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 (
<div style={{
minHeight: '100vh', background: '#09090b',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: 360, background: '#111115', border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 12, padding: '32px 28px',
}}>
const card = (children: React.ReactNode) => (
<div style={{ minHeight: '100vh', background: '#09090b', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 360, background: '#111115', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 12, padding: '32px 28px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 28 }}>
<div style={{
width: 34, height: 34, borderRadius: 9,
background: 'linear-gradient(145deg,#7c3aed 0%,#4f1d96 100%)',
boxShadow: '0 0 0 1px rgba(124,58,237,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
stroke="rgba(255,255,255,0.9)" strokeWidth="1.6" strokeLinecap="round">
<circle cx="8" cy="8" r="2.5"/>
<line x1="1.5" y1="8" x2="5" y2="8"/>
<line x1="11" y1="8" x2="14.5" y2="8"/>
<line x1="8" y1="1.5" x2="8" y2="5"/>
<line x1="8" y1="11" x2="8" y2="14.5"/>
</svg>
</div>
{LOGO}
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: '#f0eeff' }}>Local Model Routex</div>
<div style={{ fontSize: 11, color: 'rgba(237,237,240,0.35)' }}>Admin Dashboard</div>
</div>
</div>
{children}
</div>
</div>
)
<form onSubmit={submit}>
if (!cfg) return card(
<div style={{ textAlign: 'center', fontSize: 13, color: 'rgba(237,237,240,0.35)' }}>Loading…</div>
)
return card(
<>
{err && (
<div style={{ background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: 7, padding: '9px 12px', fontSize: 12, color: '#f87171', marginBottom: 16 }}>
{err}
</div>
)}
{cfg.oidc_enabled && (
<a href="/auth/login" style={{ textDecoration: 'none', display: 'block', marginBottom: cfg.static_token ? 16 : 0 }}>
<button style={{
width: '100%', background: '#7c3aed', border: 'none',
borderRadius: 7, padding: '10px 0', fontSize: 13.5,
fontWeight: 500, color: 'white', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
<path d="M12 8v4l3 3"/>
</svg>
Continue with Authentik
</button>
</a>
)}
{cfg.oidc_enabled && cfg.static_token && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)' }}/>
<span style={{ fontSize: 11, color: 'rgba(237,237,240,0.28)' }}>or</span>
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)' }}/>
</div>
)}
{cfg.static_token && (
<form onSubmit={submitToken}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'rgba(237,237,240,0.55)', marginBottom: 6 }}>
Admin token
</label>
@@ -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 && <div style={{ fontSize: 12, color: '#f87171', marginBottom: 12 }}>{err}</div>}
<button
type="submit"
disabled={!token || loading}
style={{
width: '100%', background: '#7c3aed', border: 'none',
borderRadius: 7, padding: '9px 0', fontSize: 13.5,
fontWeight: 500, color: 'white', cursor: 'pointer',
opacity: !token || loading ? 0.5 : 1,
}}
>
<button type="submit" disabled={!token || loading} style={{
width: '100%',
background: cfg.oidc_enabled ? 'rgba(124,58,237,0.15)' : '#7c3aed',
border: cfg.oidc_enabled ? '1px solid rgba(124,58,237,0.3)' : 'none',
borderRadius: 7, padding: '9px 0', fontSize: 13.5,
fontWeight: 500,
color: cfg.oidc_enabled ? '#c4b5fd' : 'white',
cursor: 'pointer', opacity: !token || loading ? 0.5 : 1,
}}>
{loading ? 'Verifying…' : 'Sign in'}
</button>
</form>
<p style={{ fontSize: 11, color: 'rgba(237,237,240,0.28)', marginTop: 20, textAlign: 'center' }}>
Set <span style={{ fontFamily: 'monospace' }}>GATEWAY_SECURITY_ADMIN_TOKEN</span> in .env
</p>
</div>
</div>
)}
</>
)
}

View File

@@ -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 },
},
},
});

View File

@@ -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

View File

@@ -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)

View File

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

View File

@@ -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},
}
}

View File

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

View File

@@ -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 {