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}
+
+
+ )
-
-
-
- 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 {