feat: OIDC-only auth, user-scoped keys/logs, admin group, 1yr log retention

This commit is contained in:
2026-06-29 08:32:09 +02:00
parent d7bd1cced0
commit 21d74ebfe0
16 changed files with 369 additions and 247 deletions

View File

@@ -78,16 +78,11 @@ func run() error {
syncer := catalog.NewSyncer(st, cache)
// OIDC is optional; only initialised when the issuer is configured.
var oidcV *oidcauth.Verifier
if cfg.OIDC.Issuer != "" {
v, err := oidcauth.New(ctx, cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.AdminGroup)
if err != nil {
return fmt.Errorf("init oidc: %w", err)
}
oidcV = v
slog.Info("OIDC admin auth enabled", "issuer", cfg.OIDC.Issuer)
oidcV, err := oidcauth.New(ctx, cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.AdminGroup)
if err != nil {
return fmt.Errorf("init oidc: %w", err)
}
slog.Info("OIDC auth enabled", "issuer", cfg.OIDC.Issuer)
limiter := ratelimit.New()
@@ -109,22 +104,35 @@ func run() error {
go syncer.Run(ctx, cfg.Catalog.SyncInterval, refresh)
// Purge request logs older than 1 year on a daily tick.
go func() {
tick := time.NewTicker(24 * time.Hour)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
purgeCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
_, _ = pool.Exec(purgeCtx, `DELETE FROM request_logs WHERE created_at < NOW() - INTERVAL '1 year'`)
cancel()
}
}
}()
deps := api.Deps{
Store: st,
Cache: cache,
Manager: mgr,
Cipher: cipher,
AdminToken: cfg.Security.AdminToken,
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
Store: st,
Cache: cache,
Manager: mgr,
Cipher: cipher,
OIDCVerifier: oidcV,
OIDCClientID: cfg.OIDC.ClientID,
OIDCClientSecret: cfg.OIDC.ClientSecret,
OIDCAuthURL: oidcV.AuthURL(),
OIDCTokenURL: oidcV.TokenURL(),
PublicBaseURL: cfg.Server.PublicURL,
Limiter: limiter,
SyncNow: syncNow,
}
a := api.New(deps)

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { getToken } from './lib/api'
import { getToken, clearToken, api } from './lib/api'
import type { CurrentUser } from './lib/types'
import Login from './pages/Login'
import Layout from './components/Layout'
import Overview from './pages/Overview'
@@ -14,6 +15,15 @@ import Settings from './pages/Settings'
export default function App() {
const [authed, setAuthed] = useState(() => !!getToken())
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
useEffect(() => {
if (!authed) return
api.me().then(setCurrentUser).catch(() => {
clearToken()
setAuthed(false)
})
}, [authed])
if (!authed) {
return <Login onLogin={() => setAuthed(true)} />
@@ -22,17 +32,19 @@ export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/" element={<Layout currentUser={currentUser} />}>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<Overview />} />
<Route path="logs" element={<Logs />} />
<Route path="keys" element={<ApiKeys />} />
<Route path="providers" element={<Providers />} />
<Route path="routing" element={<Routing />} />
<Route path="analytics" element={<Analytics />} />
<Route path="models" element={<Models />} />
<Route path="settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/overview" replace />} />
{currentUser?.is_admin && <>
<Route path="providers" element={<Providers />} />
<Route path="routing" element={<Routing />} />
<Route path="models" element={<Models />} />
<Route path="settings" element={<Settings />} />
</>}
<Route path="*" element={<Navigate to="/overview" replace />} />
</Route>
</Routes>
</BrowserRouter>

View File

@@ -1,13 +1,17 @@
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { clearToken } from '../lib/api'
import type { CurrentUser } from '../lib/types'
const NAV = [
const USER_NAV = [
{ to: '/overview', label: 'Overview', icon: <IconGrid /> },
{ to: '/logs', label: 'Request Logs', icon: <IconLogs /> },
{ to: '/keys', label: 'API Keys', icon: <IconKey /> },
{ to: '/analytics',label: 'Analytics', icon: <IconChart /> },
]
const ADMIN_NAV = [
{ to: '/providers',label: 'Providers', icon: <IconServer /> },
{ to: '/routing', label: 'Routing Rules', icon: <IconRoute /> },
{ to: '/analytics',label: 'Analytics', icon: <IconChart /> },
{ to: '/models', label: 'Model Catalog', icon: <IconBox /> },
]
@@ -22,9 +26,10 @@ function navStyle(active: boolean) {
}
}
export default function Layout() {
export default function Layout({ currentUser }: { currentUser: CurrentUser | null }) {
const loc = useLocation()
const section = NAV.find(n => loc.pathname.startsWith(n.to))?.label ?? 'Settings'
const allNav = [...USER_NAV, ...(currentUser?.is_admin ? ADMIN_NAV : [])]
const section = allNav.find(n => loc.pathname.startsWith(n.to))?.label ?? 'Settings'
return (
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden', background: '#09090b' }}>
@@ -73,12 +78,23 @@ export default function Layout() {
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.09em', color: 'rgba(237,237,240,0.22)', padding: '0 8px', margin: '2px 0 4px', textTransform: 'uppercase' }}>
Main
</div>
{NAV.map(({ to, label, icon }) => (
{USER_NAV.map(({ to, label, icon }) => (
<NavLink key={to} to={to} style={({ isActive }) => navStyle(isActive)}>
{icon}
{label}
</NavLink>
))}
{currentUser?.is_admin && <>
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.09em', color: 'rgba(237,237,240,0.22)', padding: '0 8px', margin: '8px 0 4px', textTransform: 'uppercase' }}>
Admin
</div>
{ADMIN_NAV.map(({ to, label, icon }) => (
<NavLink key={to} to={to} style={({ isActive }) => navStyle(isActive)}>
{icon}
{label}
</NavLink>
))}
</>}
</nav>
{/* Bottom */}
@@ -93,9 +109,11 @@ export default function Layout() {
background: 'linear-gradient(135deg,#7c3aed,#2563eb)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11.5, fontWeight: 600, color: 'white',
}}>A</div>
}}>{(currentUser?.name || currentUser?.email || 'U')[0].toUpperCase()}</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#ededf0' }}>admin</div>
<div style={{ fontSize: 12.5, fontWeight: 500, color: '#ededf0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{currentUser?.name || currentUser?.email || '…'}
</div>
<button onClick={() => { clearToken(); window.location.reload() }}
style={{ fontSize: 10, color: 'rgba(237,237,240,0.3)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
Sign out

View File

@@ -1,16 +1,17 @@
import type {
Provider, VirtualKey, AdminModel, RequestLog, KeyStat,
Provider, VirtualKey, AdminModel, RequestLog, KeyStat, CurrentUser,
CreateProviderParams, CreateKeyParams, ModelOverride,
} from './types'
const BASE = '/api/admin'
const BASE_API = '/api'
export function getToken() { return localStorage.getItem('admin_token') ?? '' }
export function setToken(t: string) { localStorage.setItem('admin_token', t) }
export function clearToken() { localStorage.removeItem('admin_token') }
async function req<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
async function req<T>(path: string, opts?: RequestInit, base = BASE): Promise<T> {
const res = await fetch(base + path, {
...opts,
headers: {
'Content-Type': 'application/json',
@@ -71,4 +72,5 @@ export const api = {
return req<KeyStat[]>('/stats/keys' + qs)
},
},
me: () => req<CurrentUser>('/me', undefined, BASE_API),
}

View File

@@ -65,6 +65,13 @@ export interface KeyStat {
errors: number
}
export interface CurrentUser {
id: string
email: string
name: string
is_admin: boolean
}
export interface CatalogEntry {
id: string
object: string

View File

@@ -1,11 +1,6 @@
import { useEffect, useState } from 'react'
import { useEffect } 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,
@@ -25,76 +20,28 @@ const LOGO = (
)
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)
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)
try {
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 }
setToken(token)
onLogin()
} catch {
setErr('Cannot reach server')
} finally {
setLoading(false)
}
}
const card = (children: React.ReactNode) => (
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' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 28 }}>
{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 style={{ fontSize: 11, color: 'rgba(237,237,240,0.35)' }}>Dashboard</div>
</div>
</div>
{children}
</div>
</div>
)
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 }}>
<a href="/auth/login" style={{ textDecoration: 'none', display: 'block' }}>
<button style={{
width: '100%', background: '#7c3aed', border: 'none',
borderRadius: 7, padding: '10px 0', fontSize: 13.5,
@@ -108,47 +55,7 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
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>
<input
type="password"
value={token}
onChange={e => setTok(e.target.value)}
placeholder="sk-…"
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: 12,
}}
/>
<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>
)}
</>
</div>
</div>
)
}

View File

@@ -123,8 +123,15 @@ func (a *API) adminCreateKey(w http.ResponseWriter, r *http.Request) {
expires = &t
}
// Bind the key to the authenticated user.
au := userFrom(r.Context())
var userID *uuid.UUID
if au != nil {
userID = &au.User.ID
}
id, err := a.Store.CreateVirtualKey(r.Context(), store.CreateVirtualKeyParams{
Name: in.Name, KeyHash: sum[:], KeyPrefix: prefix,
Name: in.Name, KeyHash: sum[:], KeyPrefix: prefix, UserID: userID,
AllowAllModels: in.AllowAllModels, ExpiresAt: expires, Rules: in.Rules,
})
if err != nil {
@@ -136,7 +143,13 @@ func (a *API) adminCreateKey(w http.ResponseWriter, r *http.Request) {
}
func (a *API) adminListKeys(w http.ResponseWriter, r *http.Request) {
keys, err := a.Store.ListVirtualKeys(r.Context())
// Admins see all keys; regular users see only their own.
au := userFrom(r.Context())
var userID *uuid.UUID
if au != nil && !au.IsAdmin {
userID = &au.User.ID
}
keys, err := a.Store.ListVirtualKeys(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -239,9 +252,16 @@ func (a *API) adminPutSetting(w http.ResponseWriter, r *http.Request) {
func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// Admins see all logs; regular users see only their own keys' logs.
au := userFrom(r.Context())
var userID *uuid.UUID
if au != nil && !au.IsAdmin {
userID = &au.User.ID
}
p := store.ListLogsParams{
Provider: q.Get("provider"),
KeyPrefix: q.Get("key"),
UserID: userID,
Limit: 100,
}
if s := q.Get("status"); s == "error" {
@@ -272,7 +292,12 @@ func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
since = t
}
}
stats, err := a.Store.KeyStats(r.Context(), since)
au := userFrom(r.Context())
var userID *uuid.UUID
if au != nil && !au.IsAdmin {
userID = &au.User.ID
}
stats, err := a.Store.KeyStats(r.Context(), since, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return

View File

@@ -4,20 +4,42 @@ package api
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"git.govekar.net/LovroG05/localmodelroutex/internal/catalog"
"git.govekar.net/LovroG05/localmodelroutex/internal/crypto"
"git.govekar.net/LovroG05/localmodelroutex/internal/oidcauth"
"git.govekar.net/LovroG05/localmodelroutex/internal/providermgr"
"git.govekar.net/LovroG05/localmodelroutex/internal/ratelimit"
"git.govekar.net/LovroG05/localmodelroutex/internal/store"
)
// oidcVerifier validates OIDC tokens for admin access.
// Satisfied by *oidcauth.Verifier; nil means OIDC is not configured.
// oidcVerifier validates OIDC tokens.
// Satisfied by *oidcauth.Verifier.
type oidcVerifier interface {
Verify(ctx context.Context, rawToken string) (*oidcauth.Claims, error)
IsAdmin(c *oidcauth.Claims) bool
VerifyAdmin(ctx context.Context, rawToken string) error
AuthURL() string
TokenURL() string
}
// ctxKeyUser is the context key for the authenticated user.
type ctxKeyUser struct{}
// authedUser holds the resolved user and their admin status for the current request.
type authedUser struct {
User store.User
IsAdmin bool
}
// userFrom retrieves the authenticated user from the request context.
// Returns nil when called outside of requireAuth.
func userFrom(ctx context.Context) *authedUser {
u, _ := ctx.Value(ctxKeyUser{}).(*authedUser)
return u
}
// Deps are the dependencies the API handlers need.
@@ -26,9 +48,8 @@ type Deps struct {
Cache *catalog.Cache
Manager *providermgr.Manager
Cipher *crypto.Cipher
AdminToken string
OIDCVerifier oidcVerifier // nil when OIDC is not configured
OIDCClientID string // empty when OIDC is not configured
OIDCVerifier oidcVerifier
OIDCClientID string
OIDCClientSecret string
OIDCAuthURL string // authorization_endpoint from discovery
OIDCTokenURL string // token_endpoint from discovery
@@ -61,24 +82,48 @@ func (a *API) Mount(r chi.Router) {
})
// 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)
r.Post("/providers", a.adminCreateProvider)
r.Delete("/providers/{id}", a.adminDeleteProvider)
r.Get("/keys", a.adminListKeys)
r.Post("/keys", a.adminCreateKey)
r.Delete("/keys/{id}", a.adminRevokeKey)
r.Get("/logs", a.adminListLogs)
// Authenticated user endpoints.
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Get("/api/me", a.apiMe)
r.Route("/api/admin", func(r chi.Router) {
// Keys and logs are user-scoped (admins see all via nil filter).
r.Get("/keys", a.adminListKeys)
r.Post("/keys", a.adminCreateKey)
r.Delete("/keys/{id}", a.adminRevokeKey)
r.Get("/logs", a.adminListLogs)
r.Get("/stats/keys", a.adminKeyStats)
r.Get("/models", a.adminListModels)
r.Patch("/models/{id}", a.adminUpdateModel)
r.Get("/settings", a.adminGetSettings)
r.Put("/settings/{key}", a.adminPutSetting)
r.Post("/catalog/refresh", a.adminRefreshCatalog)
// The following routes require admin group membership.
r.Group(func(r chi.Router) {
r.Use(a.requireAdmin)
r.Get("/providers", a.adminListProviders)
r.Post("/providers", a.adminCreateProvider)
r.Delete("/providers/{id}", a.adminDeleteProvider)
r.Get("/models", a.adminListModels)
r.Patch("/models/{id}", a.adminUpdateModel)
r.Get("/settings", a.adminGetSettings)
r.Put("/settings/{key}", a.adminPutSetting)
r.Post("/catalog/refresh", a.adminRefreshCatalog)
})
})
})
// Legacy public endpoint — tells the dashboard OIDC is configured.
r.Get("/api/auth/config", a.authConfig)
}
// apiMe returns the current user's profile and admin status.
func (a *API) apiMe(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
writeJSON(w, http.StatusOK, map[string]any{
"id": u.User.ID,
"email": u.User.Email,
"name": u.User.Name,
"is_admin": u.IsAdmin,
})
}

View File

@@ -6,24 +6,19 @@ import (
)
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"`
OIDCEnabled bool `json:"oidc_enabled"`
ClientID string `json:"client_id,omitempty"`
AuthURL string `json:"auth_url,omitempty"`
TokenURL string `json:"token_url,omitempty"`
}
// 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.
// authConfig tells the dashboard that OIDC is the only login method.
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
OIDCEnabled: true,
ClientID: a.OIDCClientID,
AuthURL: a.OIDCAuthURL,
TokenURL: a.OIDCTokenURL,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)

View File

@@ -3,7 +3,6 @@ package api
import (
"context"
"crypto/sha256"
"crypto/subtle"
"net/http"
"strings"
"time"
@@ -50,29 +49,36 @@ func keyFrom(ctx context.Context) *store.VirtualKey {
return k
}
// adminAuth guards admin routes. OIDC is used when configured; otherwise a
// static bearer token is required. Returns 503 when neither is configured.
func (a *API) adminAuth(next http.Handler) http.Handler {
// requireAuth verifies the Bearer token as an OIDC ID token, upserts the user
// in the DB, and stores the authedUser in the request context.
func (a *API) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
if tok == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return
}
if a.OIDCVerifier != nil {
if err := a.OIDCVerifier.VerifyAdmin(r.Context(), tok); err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
return
}
next.ServeHTTP(w, r)
claims, err := a.OIDCVerifier.Verify(r.Context(), tok)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
return
}
if a.AdminToken == "" {
writeError(w, http.StatusServiceUnavailable, "admin API disabled (configure OIDC or set GATEWAY_SECURITY_ADMIN_TOKEN)")
u, err := a.Store.UpsertUser(r.Context(), claims.Subject, claims.Email, claims.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "user lookup failed")
return
}
if subtle.ConstantTimeCompare([]byte(tok), []byte(a.AdminToken)) != 1 {
writeError(w, http.StatusUnauthorized, "unauthorized")
au := &authedUser{User: u, IsAdmin: a.OIDCVerifier.IsAdmin(claims)}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyUser{}, au)))
})
}
// requireAdmin rejects requests from non-admin users. Must be composed after requireAuth.
func (a *API) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if u == nil || !u.IsAdmin {
writeError(w, http.StatusForbidden, "admin access required")
return
}
next.ServeHTTP(w, r)

View File

@@ -74,12 +74,19 @@ func (a *API) oidcCallback(w http.ResponseWriter, r *http.Request) {
return
}
// Verify signature + group membership before handing the token to the browser.
if err := a.OIDCVerifier.VerifyAdmin(r.Context(), idToken); err != nil {
// Verify signature and extract claims; any valid OIDC user may log in.
claims, err := a.OIDCVerifier.Verify(r.Context(), idToken)
if err != nil {
http.Error(w, "unauthorized: "+err.Error(), http.StatusForbidden)
return
}
// Provision or update the user record on every login.
if _, err := a.Store.UpsertUser(r.Context(), claims.Subject, claims.Email, claims.Name); err != nil {
http.Error(w, "user provisioning failed", http.StatusInternalServerError)
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.

View File

@@ -42,7 +42,6 @@ type OIDCConfig struct {
type SecurityConfig struct {
EncryptionKey string // hex-encoded 32 bytes for AES-256-GCM
AdminToken string // bearer token guarding /api/admin (interim, pre-OIDC)
}
func Load() (*Config, error) {
@@ -83,7 +82,6 @@ func Load() (*Config, error) {
},
Security: SecurityConfig{
EncryptionKey: v.GetString("security.encryption_key"),
AdminToken: v.GetString("security.admin_token"),
},
Catalog: CatalogConfig{
SyncInterval: v.GetDuration("catalog.sync_interval"),
@@ -103,26 +101,26 @@ func (c *Config) validate() error {
if len(c.Security.EncryptionKey) != 64 {
return fmt.Errorf("security.encryption_key must be 64 hex chars (32 bytes)")
}
// OIDC is optional; if the issuer is set the other two fields are required.
if c.OIDC.Issuer != "" {
var missing []string
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, ", "))
}
var missing []string
if c.OIDC.Issuer == "" {
missing = append(missing, "oidc.issuer")
}
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("missing required OIDC config: %s", strings.Join(missing, ", "))
}
return nil
}

View File

@@ -8,6 +8,14 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
)
// Claims holds the extracted payload from a verified ID token.
type Claims struct {
Subject string
Email string
Name string
Groups []string
}
// Verifier validates OIDC JWTs against a provider's public keys and checks
// that the caller belongs to the configured admin group.
type Verifier struct {
@@ -39,23 +47,47 @@ 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 {
// Verify checks that rawToken is a valid JWT from this issuer and returns the
// extracted claims. It does NOT check group membership.
func (v *Verifier) Verify(ctx context.Context, rawToken string) (*Claims, error) {
token, err := v.v.Verify(ctx, rawToken)
if err != nil {
return fmt.Errorf("invalid token: %w", err)
return nil, fmt.Errorf("invalid token: %w", err)
}
var claims struct {
var raw struct {
Email string `json:"email"`
Name string `json:"name"`
Groups []string `json:"groups"`
}
if err := token.Claims(&claims); err != nil {
return fmt.Errorf("claims extraction failed: %w", err)
if err := token.Claims(&raw); err != nil {
return nil, fmt.Errorf("claims extraction failed: %w", err)
}
for _, g := range claims.Groups {
return &Claims{
Subject: token.Subject,
Email: raw.Email,
Name: raw.Name,
Groups: raw.Groups,
}, nil
}
// IsAdmin reports whether the claims include the configured admin group.
func (v *Verifier) IsAdmin(c *Claims) bool {
for _, g := range c.Groups {
if g == v.adminGroup {
return nil
return true
}
}
return fmt.Errorf("not a member of %q", v.adminGroup)
return false
}
// VerifyAdmin is a convenience method that combines Verify and IsAdmin.
func (v *Verifier) VerifyAdmin(ctx context.Context, rawToken string) error {
c, err := v.Verify(ctx, rawToken)
if err != nil {
return err
}
if !v.IsAdmin(c) {
return fmt.Errorf("not a member of %q", v.adminGroup)
}
return nil
}

View File

@@ -90,10 +90,11 @@ type RequestLogListRow struct {
// ListLogsParams filters the request_logs query.
type ListLogsParams struct {
Provider string // empty = all
KeyPrefix string // empty = all
StatusGte int // 0 = all; e.g. 500 filters ≥500
StatusLt int // 0 = no upper bound
Provider string // empty = all
KeyPrefix string // empty = all
UserID *uuid.UUID // non-nil = only logs for keys owned by this user
StatusGte int // 0 = all; e.g. 500 filters ≥500
StatusLt int // 0 = no upper bound
Since time.Time
Limit int
Offset int
@@ -119,12 +120,13 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques
LEFT JOIN request_log_content rlc ON rlc.request_log_id = rl.id
WHERE ($1 = '' OR rl.provider_name = $1)
AND ($2 = '' OR rl.key_prefix = $2)
AND ($3 = 0 OR rl.status_code >= $3)
AND ($4 = 0 OR rl.status_code < $4)
AND ($5::timestamptz IS NULL OR rl.created_at >= $5)
AND ($3::uuid IS NULL OR rl.virtual_key_id IN (SELECT id FROM virtual_keys WHERE user_id = $3))
AND ($4 = 0 OR rl.status_code >= $4)
AND ($5 = 0 OR rl.status_code < $5)
AND ($6::timestamptz IS NULL OR rl.created_at >= $6)
ORDER BY rl.created_at DESC
LIMIT $6 OFFSET $7`,
p.Provider, p.KeyPrefix, p.StatusGte, p.StatusLt, since, p.Limit, p.Offset,
LIMIT $7 OFFSET $8`,
p.Provider, p.KeyPrefix, p.UserID, p.StatusGte, p.StatusLt, since, p.Limit, p.Offset,
)
if err != nil {
return nil, err
@@ -159,8 +161,9 @@ type KeyStat struct {
Errors int `json:"errors"`
}
// KeyStats returns per-key aggregate stats since the given time.
func (s *Store) KeyStats(ctx context.Context, since time.Time) ([]KeyStat, error) {
// KeyStats returns per-key aggregate stats since the given time. When userID is
// non-nil only that user's keys are included.
func (s *Store) KeyStats(ctx context.Context, since time.Time, userID *uuid.UUID) ([]KeyStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT COALESCE(key_prefix, '(no key)'),
COUNT(*)::int,
@@ -171,9 +174,10 @@ func (s *Store) KeyStats(ctx context.Context, since time.Time) ([]KeyStat, error
SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::int
FROM request_logs
WHERE created_at >= $1
AND ($2::uuid IS NULL OR virtual_key_id IN (SELECT id FROM virtual_keys WHERE user_id = $2))
GROUP BY key_prefix
ORDER BY SUM(total_tokens) DESC`,
since,
since, userID,
)
if err != nil {
return nil, err

View File

@@ -0,0 +1,50 @@
package store
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// User mirrors the users table row.
type User struct {
ID uuid.UUID
Subject string
Email string
Name string
CreatedAt time.Time
LastLoginAt *time.Time
}
// UpsertUser inserts or updates the user identified by subject, returning the
// current row. Call on every successful OIDC login.
func (s *Store) UpsertUser(ctx context.Context, subject, email, name string) (User, error) {
var u User
err := s.pool.QueryRow(ctx, `
INSERT INTO users (subject, email, name, last_login_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (subject) DO UPDATE
SET email = EXCLUDED.email,
name = EXCLUDED.name,
last_login_at = now()
RETURNING id, subject, COALESCE(email,''), COALESCE(name,''), created_at, last_login_at`,
subject, nullString(email), nullString(name),
).Scan(&u.ID, &u.Subject, &u.Email, &u.Name, &u.CreatedAt, &u.LastLoginAt)
return u, err
}
// GetUserBySubject fetches a user by OIDC subject. Returns (zero, nil) when not found.
func (s *Store) GetUserBySubject(ctx context.Context, subject string) (User, error) {
var u User
err := s.pool.QueryRow(ctx, `
SELECT id, subject, COALESCE(email,''), COALESCE(name,''), created_at, last_login_at
FROM users WHERE subject = $1`, subject,
).Scan(&u.ID, &u.Subject, &u.Email, &u.Name, &u.CreatedAt, &u.LastLoginAt)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, nil
}
return u, err
}

View File

@@ -15,6 +15,7 @@ type VirtualKey struct {
Name string
KeyPrefix string
Type string
UserID *uuid.UUID
AllowAllModels bool
GroupID *uuid.UUID
ExpiresAt *time.Time
@@ -28,9 +29,9 @@ type VirtualKey struct {
func (s *Store) GetVirtualKeyByHash(ctx context.Context, hash []byte) (*VirtualKey, error) {
var k VirtualKey
err := s.pool.QueryRow(ctx, `
SELECT id, COALESCE(name,''), key_prefix, type, allow_all_models, group_id, expires_at, revoked_at
SELECT id, COALESCE(name,''), key_prefix, type, user_id, allow_all_models, group_id, expires_at, revoked_at
FROM virtual_keys WHERE key_hash = $1`, hash,
).Scan(&k.ID, &k.Name, &k.KeyPrefix, &k.Type, &k.AllowAllModels, &k.GroupID, &k.ExpiresAt, &k.RevokedAt)
).Scan(&k.ID, &k.Name, &k.KeyPrefix, &k.Type, &k.UserID, &k.AllowAllModels, &k.GroupID, &k.ExpiresAt, &k.RevokedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
@@ -58,6 +59,7 @@ type CreateVirtualKeyParams struct {
Name string
KeyHash []byte
KeyPrefix string
UserID *uuid.UUID
AllowAllModels bool
ExpiresAt *time.Time
Rules []string
@@ -72,9 +74,9 @@ func (s *Store) CreateVirtualKey(ctx context.Context, p CreateVirtualKeyParams)
var id uuid.UUID
if err := tx.QueryRow(ctx, `
INSERT INTO virtual_keys (name, key_hash, key_prefix, type, allow_all_models, expires_at)
VALUES ($1, $2, $3, 'standard', $4, $5) RETURNING id`,
nullString(p.Name), p.KeyHash, p.KeyPrefix, p.AllowAllModels, p.ExpiresAt,
INSERT INTO virtual_keys (name, key_hash, key_prefix, type, user_id, allow_all_models, expires_at)
VALUES ($1, $2, $3, 'standard', $4, $5, $6) RETURNING id`,
nullString(p.Name), p.KeyHash, p.KeyPrefix, p.UserID, p.AllowAllModels, p.ExpiresAt,
).Scan(&id); err != nil {
return uuid.Nil, err
}
@@ -89,10 +91,14 @@ func (s *Store) CreateVirtualKey(ctx context.Context, p CreateVirtualKeyParams)
return id, tx.Commit(ctx)
}
func (s *Store) ListVirtualKeys(ctx context.Context) ([]VirtualKey, error) {
// ListVirtualKeys returns virtual keys. When userID is non-nil only that user's
// keys are returned; pass nil to list all (admin view).
func (s *Store) ListVirtualKeys(ctx context.Context, userID *uuid.UUID) ([]VirtualKey, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, COALESCE(name,''), key_prefix, type, allow_all_models, expires_at, revoked_at, last_used_at
FROM virtual_keys ORDER BY created_at DESC`)
SELECT id, COALESCE(name,''), key_prefix, type, user_id, allow_all_models, expires_at, revoked_at, last_used_at
FROM virtual_keys
WHERE ($1::uuid IS NULL OR user_id = $1)
ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
@@ -100,7 +106,7 @@ func (s *Store) ListVirtualKeys(ctx context.Context) ([]VirtualKey, error) {
var out []VirtualKey
for rows.Next() {
var k VirtualKey
if err := rows.Scan(&k.ID, &k.Name, &k.KeyPrefix, &k.Type, &k.AllowAllModels,
if err := rows.Scan(&k.ID, &k.Name, &k.KeyPrefix, &k.Type, &k.UserID, &k.AllowAllModels,
&k.ExpiresAt, &k.RevokedAt, &k.LastUsedAt); err != nil {
return nil, err
}