Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b3d623b
feat: implement authentication and configuration management for routa…
samueltuyizere Jun 21, 2026
5881e51
refactor: simplify role and provider checks using slices package
samueltuyizere Jun 21, 2026
fdd7828
feat(config): implement CachedConfigProvider with TTL caching and LRU…
samueltuyizere Jun 21, 2026
cd1b8f3
Add file-based configuration provider tests and enhance provider func…
samueltuyizere Jun 21, 2026
136fbc8
feat: enhance authentication handling and add health check registrati…
samueltuyizere Jun 21, 2026
66424ef
feat(auth): add authentication tests for missing and invalid credentials
samueltuyizere Jun 21, 2026
b845962
style: format code for consistency across multiple files
samueltuyizere Jun 21, 2026
d61d0bc
refactor: simplify conversion functions and clean up unused code
samueltuyizere Jun 21, 2026
a489d80
style: update lint directive for unused function placeholder
samueltuyizere Jun 21, 2026
375f63a
refactor: remove unused setConfig method from mockConfigProvider
samueltuyizere Jun 21, 2026
ce357c3
refactor: rename unused function parameters in validation methods
samueltuyizere Jun 21, 2026
2db6180
style: add lint directive for intentionally unused function in access…
samueltuyizere Jun 21, 2026
ee40b1d
refactor: simplify conversion of ProviderFileConfig to ProviderConfig
samueltuyizere Jun 21, 2026
bb4a663
refactor: handle errors for deferred function calls in auth and confi…
samueltuyizere Jun 21, 2026
1892930
refactor: add isCloudManagedMode function to streamline config valida…
samueltuyizere Jun 21, 2026
d6f9224
refactor: enhance config loading for serverless deployments with envi…
samueltuyizere Jun 21, 2026
d13f5bd
refactor: improve caching mechanism and add cloud endpoint support fo…
samueltuyizere Jun 21, 2026
9615425
refactor: handle context errors in GetEffectiveConfig method
samueltuyizere Jun 21, 2026
9046a5e
refactor: optimize health check request method and improve localhost …
samueltuyizere Jun 21, 2026
99de499
refactor: add static bootstrap config support and enhance runtime con…
samueltuyizere Jun 21, 2026
e173624
refactor: make StaticConfigProvider thread-safe with sync.RWMutex
samueltuyizere Jun 21, 2026
8d4ba61
refactor: optimize HealthCheck method to use HEAD request for improve…
samueltuyizere Jun 21, 2026
c76b473
refactor: enhance config provider initialization with autodetection a…
samueltuyizere Jun 21, 2026
7da1e07
refactor: make StaticConfigProvider methods safe for concurrent use w…
samueltuyizere Jun 21, 2026
a7ebcd1
feat: implement hosted server with cloud-based authentication and con…
samueltuyizere Jun 21, 2026
ce7cfa3
refactor: update GetEffectiveConfig to use auth context for workspace ID
samueltuyizere Jun 21, 2026
1da02e2
refactor: standardize formatting and alignment in main.go
samueltuyizere Jun 21, 2026
15b2bd4
refactor: improve context handling and error logging in hosted server
samueltuyizere Jun 21, 2026
35cc679
refactor: standardize spacing in constant declarations
samueltuyizere Jun 21, 2026
a2bce66
feat: implement GetConfigByRef to fetch configuration by workspace re…
samueltuyizere Jun 21, 2026
1a7a495
refactor: remove health check implementation for hosted mode
samueltuyizere Jun 21, 2026
9fb31ef
feat: add IntrospectionRequest type and update API key validation to …
samueltuyizere Jun 21, 2026
c7655b6
feat: enhance handleProxy to support POST requests and improve error …
samueltuyizere Jun 21, 2026
7cd14c4
feat: enhance API key validation and update HealthCheck documentation
samueltuyizere Jun 21, 2026
980e509
Merge branch 'main' into authprovider-and-configprovider
samueltuyizere Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
373 changes: 280 additions & 93 deletions cmd/routatic-proxy/main.go

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ go 1.25.0

require (
github.com/fsnotify/fsnotify v1.10.1
github.com/lib/pq v1.12.3
github.com/mattn/go-sqlite3 v1.14.46
github.com/pkoukk/tiktoken-go v0.1.8
github.com/spf13/cobra v1.8.1
golang.org/x/sys v0.46.0
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.46 h1:ZfaNcYO/CGNMRxkN1vvG9qf+Y+uvXfgT9a6MlEw+HmU=
github.com/mattn/go-sqlite3 v1.14.46/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand All @@ -22,6 +26,7 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
363 changes: 363 additions & 0 deletions internal/auth/cloud.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,363 @@
// Package auth provides authentication interfaces and types for the routatic-proxy.
package auth

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
)

// CloudAuthProvider implements AuthProvider for cloud-based token authentication.
// It validates tokens by calling a cloud introspection endpoint and caches results
// with TTL for performance. All operations are thread-safe.
type CloudAuthProvider struct {
introspectionURL string
cacheTTL time.Duration
mu sync.RWMutex
cache map[string]cachedAuth // key: token hash
httpClient *http.Client

// serviceToken is used to authenticate with the introspection endpoint
serviceToken string

// refreshThreshold is the duration before TTL expiry when background refresh triggers
refreshThreshold time.Duration
}

// cachedAuth holds cached authentication results with timestamp
type cachedAuth struct {
authContext *AuthContext
cachedAt time.Time
}

// IntrospectionRequest is the JSON payload sent to the introspection endpoint
type IntrospectionRequest struct {
Token string `json:"token"`
}

// IntrospectionResponse is the JSON response from the introspection endpoint
type IntrospectionResponse struct {
Active bool `json:"active"`
KeyID string `json:"key_id"`
WorkspaceID string `json:"workspace_id"`
SubjectID string `json:"subject_id"`
SubjectType string `json:"subject_type"`
Roles []string `json:"roles"`
AllowedModels []string `json:"allowed_models"`
AllowedProviders []string `json:"allowed_providers"`
RateLimits RateLimitPolicy `json:"rate_limits"`
Billing BillingPolicy `json:"billing"`
}

// NewCloudAuthProvider creates a new CloudAuthProvider with the given configuration.
// The introspectionURL is the endpoint to call for token validation.
// The cacheTTL determines how long to cache successful authentication results.
// The serviceToken is used to authenticate with the introspection endpoint.
func NewCloudAuthProvider(introspectionURL string, cacheTTL time.Duration, serviceToken string) *CloudAuthProvider {
if cacheTTL <= 0 {
cacheTTL = 5 * time.Minute
}

return &CloudAuthProvider{
introspectionURL: introspectionURL,
cacheTTL: cacheTTL,
cache: make(map[string]cachedAuth),
httpClient: &http.Client{Timeout: 10 * time.Second},
serviceToken: serviceToken,
refreshThreshold: cacheTTL / 4, // Refresh when 25% of TTL remains
}
}

// NewCloudAuthProviderWithClient creates a new CloudAuthProvider with a custom HTTP client.
// This is useful for testing and for advanced configuration scenarios.
func NewCloudAuthProviderWithClient(introspectionURL string, cacheTTL time.Duration, serviceToken string, httpClient *http.Client) *CloudAuthProvider {
provider := NewCloudAuthProvider(introspectionURL, cacheTTL, serviceToken)
if httpClient != nil {
provider.httpClient = httpClient
}
return provider
}

// Authenticate validates the request credentials by checking the cache first,
// then calling the cloud introspection endpoint if needed.
// Thread-safe.
func (p *CloudAuthProvider) Authenticate(ctx context.Context, req *http.Request) (*AuthContext, error) {
// Extract the Bearer token from the Authorization header.
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
slog.Debug("missing Authorization header")
return nil, ErrAuthenticationFailed
}

if !strings.HasPrefix(authHeader, "Bearer ") {
slog.Debug("invalid Authorization header format")
return nil, ErrAuthenticationFailed
}

// Extract the token.
token := strings.TrimPrefix(authHeader, "Bearer ")
token = strings.TrimSpace(token)

if token == "" {
slog.Debug("empty bearer token")
return nil, ErrAuthenticationFailed
}

// Hash the token for cache key.
tokenHash := hashToken(token)

// Check cache first (read lock).
p.mu.RLock()
cached, exists := p.cache[tokenHash]
p.mu.RUnlock()

if exists {
// Check if cache entry is still valid.
if time.Since(cached.cachedAt) < p.cacheTTL {
slog.Debug("cache hit for token", "key_id", cached.authContext.KeyID)

// Trigger background refresh if entry is nearing expiry.
if time.Since(cached.cachedAt) > p.cacheTTL-p.refreshThreshold {
go p.refreshCacheEntry(token, tokenHash)
}

return cached.authContext, nil
}
// Cache entry expired, will refresh below.
slog.Debug("cache entry expired, refreshing", "key_id", cached.authContext.KeyID)
}

// Cache miss or expired - call introspection endpoint.
return p.introspectAndCache(ctx, token, tokenHash)
}

// introspectAndCache calls the cloud introspection endpoint and caches the result.
// This is called when cache miss or expired.
func (p *CloudAuthProvider) introspectAndCache(ctx context.Context, token, tokenHash string) (*AuthContext, error) {
// Call introspection endpoint with fail-closed behavior.
authCtx, err := p.callIntrospectionEndpoint(ctx, token)
if err != nil {
// On introspection failure: fail closed (return error).
slog.Error("introspection failed, failing closed", "error", err)
return nil, ErrAuthenticationFailed
}

// Check if token is active.
if authCtx.KeyStatus != KeyStatusActive {
slog.Debug("token is not active", "key_id", authCtx.KeyID, "status", authCtx.KeyStatus)
return nil, ErrAuthenticationFailed
}

// Cache the result.
p.mu.Lock()
p.cache[tokenHash] = cachedAuth{
authContext: authCtx,
cachedAt: time.Now(),
}
p.mu.Unlock()

slog.Debug("token introspection successful, cached", "key_id", authCtx.KeyID)
return authCtx, nil
}

// callIntrospectionEndpoint makes the HTTP request to the cloud introspection endpoint.
// Returns the AuthContext on success, error on failure.
func (p *CloudAuthProvider) callIntrospectionEndpoint(ctx context.Context, token string) (*AuthContext, error) {
// Prepare request body.
reqBody := IntrospectionRequest{Token: token}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshaling introspection request: %w", err)
}

// Create HTTP request.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.introspectionURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("creating introspection request: %w", err)
}

// Set headers.
req.Header.Set("Content-Type", "application/json")
if p.serviceToken != "" {
req.Header.Set("Authorization", "Bearer "+p.serviceToken)
}

// Make the request.
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("introspection request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()

// Handle non-2xx responses as failures (fail closed).
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Read and log response body for debugging.
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
slog.Debug("introspection endpoint returned non-2xx status",
"status", resp.StatusCode,
"body", string(body))
return nil, fmt.Errorf("introspection endpoint returned status %d", resp.StatusCode)
}

// Parse response.
var introspectResp IntrospectionResponse
if err := json.NewDecoder(resp.Body).Decode(&introspectResp); err != nil {
return nil, fmt.Errorf("decoding introspection response: %w", err)
}

// Convert response to AuthContext.
return p.responseToAuthContext(&introspectResp), nil
}

// responseToAuthContext converts an IntrospectionResponse to an AuthContext.
func (p *CloudAuthProvider) responseToAuthContext(resp *IntrospectionResponse) *AuthContext {
// Determine key status based on active field.
keyStatus := KeyStatusActive
if !resp.Active {
keyStatus = KeyStatusRevoked // Default to revoked if not active
}

// Map subject type.
subjectType := SubjectTypeService
switch resp.SubjectType {
case "user":
subjectType = SubjectTypeUser
case "workspace":
subjectType = SubjectTypeWorkspace
case "service":
subjectType = SubjectTypeService
}

return &AuthContext{
Identity: SubjectIdentity{
Type: subjectType,
ID: resp.SubjectID,
Name: resp.KeyID, // Using key_id as name for now
},
WorkspaceID: resp.WorkspaceID,
KeyID: resp.KeyID,
KeyStatus: keyStatus,
AllowedModels: resp.AllowedModels,
AllowedProviders: resp.AllowedProviders,
Roles: resp.Roles,
RateLimits: resp.RateLimits,
Billing: resp.Billing,
ConfigRef: ConfigRef{
WorkspaceID: resp.WorkspaceID,
Version: "cloud",
LastModified: time.Now().Unix(),
},
Metadata: make(map[string]string),
}
}

// refreshCacheEntry performs background refresh of a cache entry.
// This is called in a goroutine and should not block the caller.
func (p *CloudAuthProvider) refreshCacheEntry(token, tokenHash string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

slog.Debug("background refresh started", "token_hash_prefix", tokenHash[:8])

authCtx, err := p.callIntrospectionEndpoint(ctx, token)
if err != nil {
slog.Error("background refresh failed", "error", err)
return
}

// Only update cache if token is still active.
if authCtx.KeyStatus != KeyStatusActive {
slog.Debug("background refresh: token no longer active, removing from cache",
"key_id", authCtx.KeyID)
p.mu.Lock()
delete(p.cache, tokenHash)
p.mu.Unlock()
return
}

p.mu.Lock()
p.cache[tokenHash] = cachedAuth{
authContext: authCtx,
cachedAt: time.Now(),
}
p.mu.Unlock()

slog.Debug("background refresh completed", "key_id", authCtx.KeyID)
}

// RevokeCache invalidates any cached authentication state for the given key ID.
// Thread-safe.
func (p *CloudAuthProvider) RevokeCache(ctx context.Context, keyID string) error {
p.mu.Lock()
defer p.mu.Unlock()

// Find and remove cache entries matching the key ID.
// Since cache is keyed by token hash, we need to iterate.
for tokenHash, cached := range p.cache {
if cached.authContext.KeyID == keyID {
delete(p.cache, tokenHash)
slog.Info("revoked cache entry", "key_id", keyID)
}
}

return nil
}

// HealthCheck verifies the authentication provider is healthy by making
// a lightweight request to the introspection endpoint.
// Thread-safe.
func (p *CloudAuthProvider) HealthCheck(ctx context.Context) error {
// Perform a lightweight check by making an HTTP HEAD request.
// HEAD is more efficient than GET as it doesn't return a response body.
req, err := http.NewRequestWithContext(ctx, http.MethodHead, p.introspectionURL, nil)
if err != nil {
return fmt.Errorf("creating health check request: %w", err)
}

// Try to connect - we expect this might fail authentication,
// but we just want to check the endpoint is reachable.
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("introspection endpoint unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()

// Any HTTP response (even 401/403) means the endpoint is reachable.
// Only network errors or timeouts should cause health check to fail.
return nil
}

// ClearCache removes all entries from the cache.
// Thread-safe.
func (p *CloudAuthProvider) ClearCache() {
p.mu.Lock()
defer p.mu.Unlock()

p.cache = make(map[string]cachedAuth)
slog.Info("cloud auth cache cleared")
}

// CacheStats returns statistics about the current cache state.
// Thread-safe.
func (p *CloudAuthProvider) CacheStats() (total int, expired int) {
p.mu.RLock()
defer p.mu.RUnlock()

now := time.Now()
total = len(p.cache)

for _, cached := range p.cache {
if now.Sub(cached.cachedAt) >= p.cacheTTL {
expired++
}
}

return total, expired
}
Loading
Loading