diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go new file mode 100644 index 0000000..6aede43 --- /dev/null +++ b/internal/config/auth_config_test.go @@ -0,0 +1,104 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "forgejo.riotpiao.com/rock/homelab-frontend/internal/config" +) + +func TestLoadAuthConfig_TokenURLAndClientID(t *testing.T) { + yaml := ` +routes: [] +models: [] +auth: + enabled: true + issuer: "https://authentik.example.com/application/o/api-gw/" + audience: "api-gw" + jwksUrl: "https://authentik.example.com/application/o/api-gw/jwks/" + requiredCapability: "llm:inference" + tokenUrl: "https://authentik.example.com/application/o/token/" + clientId: "api-gw" +` + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + // Set env for client secret + t.Setenv("AUTH_CLIENT_SECRET", "test-secret-value") + + _, _, _, auth, err := config.LoadRoutesAndModelsFromFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !auth.Enabled { + t.Error("auth should be enabled") + } + if auth.TokenURL != "https://authentik.example.com/application/o/token/" { + t.Errorf("tokenUrl = %q, want authentik token endpoint", auth.TokenURL) + } + if auth.ClientID != "api-gw" { + t.Errorf("clientId = %q, want api-gw", auth.ClientID) + } + if auth.ClientSecret != "test-secret-value" { + t.Errorf("clientSecret = %q, want test-secret-value", auth.ClientSecret) + } +} + +func TestLoadAuthConfig_ClientSecretFromEnvOnly(t *testing.T) { + yaml := ` +routes: [] +models: [] +auth: + enabled: true + tokenUrl: "https://example.com/token/" + clientId: "test" +` + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + os.WriteFile(path, []byte(yaml), 0644) + + // No AUTH_CLIENT_SECRET env set + t.Setenv("AUTH_CLIENT_SECRET", "") + + _, _, _, auth, err := config.LoadRoutesAndModelsFromFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if auth.ClientSecret != "" { + t.Errorf("clientSecret should be empty when env not set, got %q", auth.ClientSecret) + } +} + +func TestLoadAuthConfig_BackwardCompatible(t *testing.T) { + // Config without tokenUrl/clientId should still load (zero values) + yaml := ` +routes: [] +models: [] +auth: + enabled: true + issuer: "https://example.com/" + jwksUrl: "https://example.com/jwks/" + requiredCapability: "llm:inference" +` + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + os.WriteFile(path, []byte(yaml), 0644) + + _, _, _, auth, err := config.LoadRoutesAndModelsFromFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if auth.TokenURL != "" { + t.Errorf("tokenUrl should be empty, got %q", auth.TokenURL) + } + if auth.ClientID != "" { + t.Errorf("clientId should be empty, got %q", auth.ClientID) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index fcb31b4..dd78f02 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,12 @@ type AuthConfig struct { JWKSURL string // RequiredCapability is the permission required for LLM inference (e.g., "llm:inference"). RequiredCapability string + // TokenURL is the Authentik token endpoint for password/refresh grants. + TokenURL string + // ClientID is the OAuth2 client ID for token exchange. + ClientID string + // ClientSecret is the OAuth2 client secret (loaded from env, never from config file). + ClientSecret string } // Route represents a single route and its upstream configuration. diff --git a/internal/config/loader.go b/internal/config/loader.go index e8cc51b..344dfc6 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -25,6 +25,8 @@ type rawAuth struct { Audience string `yaml:"audience"` JWKSURL string `yaml:"jwksUrl"` RequiredCapability string `yaml:"requiredCapability"` + TokenURL string `yaml:"tokenUrl"` + ClientID string `yaml:"clientId"` } // rawRoute represents a single route in the YAML configuration. @@ -176,6 +178,9 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo Audience: raw.Auth.Audience, JWKSURL: raw.Auth.JWKSURL, RequiredCapability: raw.Auth.RequiredCapability, + TokenURL: raw.Auth.TokenURL, + ClientID: raw.Auth.ClientID, + ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"), } return routes, models, adapters, authConfig, nil diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index 14b9617..d04904f 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -17,6 +17,8 @@ data: audience: "api-gw" jwksUrl: "http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/" requiredCapability: "llm:inference" + tokenUrl: "http://authentik-server.iam.svc.cluster.local/application/o/token/" + clientId: "api-gw" # Routes: standard HTTP proxy routes (not LLM-specific) # These are for non-LLM services (agent-pod/console, etc.) diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 54daa80..332d74e 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -57,6 +57,12 @@ spec: value: "0.0.0.0:8080" - name: CONFIG_PATH value: "/etc/gateway/config.yaml" + - name: AUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: api-gw-client-secret + key: client-secret + optional: true - name: SHUTDOWN_TIMEOUT value: "5m" - name: LOG_LEVEL diff --git a/k8s/gateway-config-secret.enc.yaml b/k8s/gateway-config-secret.enc.yaml index fa55e3a..a02cf3e 100644 --- a/k8s/gateway-config-secret.enc.yaml +++ b/k8s/gateway-config-secret.enc.yaml @@ -14,6 +14,8 @@ stringData: audience: "api-gw" jwksUrl: "http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/" requiredCapability: "llm:inference" + tokenUrl: "http://authentik-server.iam.svc.cluster.local/application/o/token/" + clientId: "api-gw" routes: [] models: - name: "reasoning"