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:
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
30
backend/internal/api/auth.go
Normal file
30
backend/internal/api/auth.go
Normal 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)
|
||||
}
|
||||
100
backend/internal/api/oidclogin.go
Normal file
100
backend/internal/api/oidclogin.go
Normal 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},
|
||||
}
|
||||
}
|
||||
@@ -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, ", "))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user