10 Commits
Author SHA1 Message Date
rock 359645f397 feat(proxy): add /auth/exchange token exchange endpoint
CI / CI (push) Successful in 3m22s
Closes homelab#10 (P3.5)

## Endpoint

`POST /auth/exchange` — RFC 8693-inspired token exchange.

## Flow

1. Validate `subject_token` (user JWT) via gateway's JWKS validator
2. Authenticate service via `client_credentials` against Authentik
3. Verify requested `scope` is subset of service's roles (deny escalation)
4. Return service token + subject identity metadata

## Request
```json
{"subject_token": "<user JWT>", "client_id": "portfolio-agent",
 "client_secret": "<secret>", "scope": "memory:read", "resource": "poimen-memory"}
```

## Response
```json
{"access_token": "<service JWT>", "subject": "user-hash",
 "acting_party": "portfolio-agent", "scope": "memory:read"}
```
2026-09-09 00:31:10 +00:00
rock a51c14426f feat(proxy): add /auth/token and /auth/refresh endpoints (#18)
CI / CI (push) Successful in 4m2s
Closes homelab#6 (P3.1) and homelab#8 (P3.3)

## Endpoints

| Path | Method | Body | What it does |
|------|--------|------|-------------|
| `/auth/token` | POST | `{username, password, scope?}` | Password grant → JWT |
| `/auth/refresh` | POST | `{refresh_token, scope?}` | Refresh grant → new JWT |

Both proxy to Authentik `tokenUrl` (from P3.7 config). Upstream response forwarded verbatim — client sees Authentik errors directly.
2026-09-09 00:00:07 +00:00
poimenandrock ddee0f2dc9 feat(config): add tokenUrl, clientId, clientSecret to auth config (#17)
CI / CI (push) Successful in 3m20s
Closes homelab#12 (P3.7)

## Changes

- `AuthConfig`: added `TokenURL`, `ClientID`, `ClientSecret` fields
- `loader.go`: reads `tokenUrl`/`clientId` from YAML, `ClientSecret` from `AUTH_CLIENT_SECRET` env
- `deployment.yaml`: `AUTH_CLIENT_SECRET` from `api-gw-client-secret` Secret (optional)
- `gateway-config-secret.enc.yaml` + `configmap.yaml`: added `tokenUrl` and `clientId`

## Secret never in YAML

`clientSecret` deliberately omitted from YAML struct. Loaded from env only.

## Tests

3 tests: full config load, env-only secret, backward compat (missing fields = zero).

Co-authored-by: poimen <[email protected]>
2026-09-08 23:43:29 +00:00
poimenandrock 09318778fa feat(serviceadapter): enforce JWT auth on X-Service dispatch (#16)
CI / CI (push) Successful in 3m43s
SQS dispatcher hardcoded a JWT validator pointing at authentik.riotpiao.com/application/o/sqs/jwks/ — provider doesn't exist. Every SQS request got 403 regardless of token.

Co-authored-by: poimen <[email protected]>
2026-09-08 23:20:31 +00:00
rockandpoimen 6f7b850193 feat(identity): inject X-Forwarded-User headers after JWT validation (#15)
CI / CI (push) Successful in 3m14s
Closes homelab#9 (P3.4)

## Changes

- New `internal/identity` package: header injection + anti-spoofing
- `proxy.go`: strip spoofed headers on all requests, inject identity after JWT validation

## Headers

| Header | Source | When |
|--------|--------|------|
| X-Forwarded-User | sub claim | Always after JWT |
| X-Forwarded-Roles | roles or permissions claim | Always after JWT |
| X-Acting-Service | azp claim | Only when azp != sub |
| X-Auth-Verified | literal "true" | Always after JWT |

## Tests

13 tests, 93.9% coverage. Covers: spoofing, service accounts, human users, empty claims, nil values, wildcard, mixed types, precedence.

---------

Co-authored-by: Poimen <[email protected]>
Reviewed-on: #15
2026-09-08 23:08:39 +00:00
rock 5dd76f3b49 Merge pull request 'fix(s3): correct MinIO service port and allow egress' (#14) from fix/s3-adapter-port into main
CI / CI (push) Successful in 3m16s
2026-09-08 17:03:52 +00:00
Admin Bot c87463c850 fix(s3): correct MinIO service port and allow egress
CI / CI (pull_request) Successful in 3m16s
MinIO ClusterIP service listens on port 80 (targetPort 9000).
Config had port 9000 which caused 30s timeout then 502 — gateway
connected to service port 9000 which doesn't exist on the ClusterIP.

Changes:
- configmap.yaml: S3 upstream :9000 → :80
- gateway-config-secret.enc.yaml: same
- network-policy.yaml: add port 80 egress to storage namespace

Verified: S3 adapter now reaches MinIO (403 AccessDenied = auth issue,
not connectivity).
2026-09-08 09:58:36 -07:00
rockandAdmin Bot 1cd8e711dd ci: unified workflow - single job, DOCKER_HOST, build+push on all events (#4)
CI / CI (push) Successful in 3m23s
- Single job (no split test/build-push)
- DOCKER_HOST=tcp://localhost:2375 for dind
- Build + push on PRs too (verify before merge)
- workflow_dispatch for manual trigger

---------

Co-authored-by: Admin Bot <[email protected]>
Reviewed-on: rock/homelab-frontend#4
2026-09-07 21:01:02 +00:00
Admin Bot 7e78519499 ci: unified workflow - single job, DOCKER_HOST, build+push on all events
CI / CI (pull_request) Successful in 4m0s
2026-09-07 13:47:14 -07:00
rockandAdmin Bot 736c0d7724 fix: use env vars for docker registry credentials (#2)
CI / Test (push) Successful in 1m51s
CI / Build & Push Image (push) Failing after 1m6s
Fix registry login by passing FORGEJO_REGISTRY_USER and FORGEJO_REGISTRY_TOKEN via environment variables instead of direct secret interpolation.

Uses the proven pattern from riotpiao.com reference commit.

This prevents credentials from being exposed in logs or shell history while keeping the standard docker login approach.

After merge + org-level secrets configured:
- All repos inherit FORGEJO_REGISTRY_USER and FORGEJO_REGISTRY_TOKEN
- CI validates credentials exist before docker login
- Image pushed to registry on main push

---------

Co-authored-by: Admin Bot <[email protected]>
Reviewed-on: rock/homelab-frontend#2
2026-09-07 06:50:48 +00:00
18 changed files with 1789 additions and 152 deletions
+25 -46
View File
@@ -1,5 +1,3 @@
# Single pipeline: verify → build → push.
# One workflow per push, one concurrency group per branch.
name: CI name: CI
on: on:
@@ -7,75 +5,56 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env: env:
REGISTRY: forgejo.riotpiao.com REGISTRY: forgejo.riotpiao.com
IMAGE: forgejo.riotpiao.com/rock/api-gateway IMAGE: forgejo.riotpiao.com/rock/api-gateway
DOCKER_HOST: tcp://localhost:2375
jobs: jobs:
verify: ci:
name: Vet, test, build name: CI
runs-on: golang runs-on: golang
steps: steps:
- name: Install Node.js for actions runtime - name: Install Node.js and Docker
run: apt-get update && apt-get install -y nodejs
- uses: actions/checkout@v4
- name: go vet
run: go vet ./...
- name: go test -race
run: go test ./... -race
- name: Static build (smoke)
run: CGO_ENABLED=0 go build -trimpath -o gateway ./cmd/gateway
push:
name: Build and push image
needs: verify
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: golang
steps:
- name: Install Docker CLI and Node.js
run: | run: |
apt-get update apt-get update
apt-get install -y --no-install-recommends docker.io nodejs git apt-get install -y nodejs docker.io
rm -rf /var/lib/apt/lists/*
- uses: actions/checkout@v4 - name: Checkout code
uses: actions/checkout@v4
- name: Go vet
run: go vet ./...
- name: Go test
run: go test ./...
- name: Get short SHA - name: Get short SHA
id: sha id: sha
run: | run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
SHORT_SHA=$(git rev-parse --short HEAD)
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Registry login - name: Registry login
run: | run: |
echo "${REGISTRY_PAT}" | docker login "${REGISTRY}" \ echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
--username rock --password-stdin --username "${REGISTRY_USER}" --password-stdin
env: env:
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }} REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
- name: Build image - name: Build Docker image
run: | run: |
docker build \ docker build --no-cache \
--build-arg "VERSION=${{ steps.sha.outputs.short_sha }}" \
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \ -t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
-t "${IMAGE}:latest" \ -t "${IMAGE}:latest" \
. -f Dockerfile .
- name: Push image - name: Push Docker image
run: | run: |
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}" docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
docker push "${IMAGE}:latest" docker push "${IMAGE}:latest"
echo "✓ Image pushed: ${IMAGE}:${{ steps.sha.outputs.short_sha }}" echo "✓ Pushed: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
- name: Prune unused images - name: Prune unused images
run: | run: docker image prune -a --force 2>&1 | tail -3 || true
docker image prune -a --force 2>&1 | tail -3 || true
+8 -1
View File
@@ -9,6 +9,7 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config" "forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy" "forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server" "forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
@@ -75,7 +76,13 @@ func main() {
_ = registry.Add(a) _ = registry.Add(a)
} }
log.Printf("%d service adapters loaded", registry.Count()) log.Printf("%d service adapters loaded", registry.Count())
dispatcher := serviceadapter.NewDispatcher(registry)
// Create shared JWT validator for X-Service auth enforcement
var jwtValidator *auth.Validator
if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" {
jwtValidator = auth.NewValidator(cfg.Auth.Issuer, cfg.Auth.Audience, cfg.Auth.JWKSURL)
}
dispatcher := serviceadapter.NewDispatcher(registry, jwtValidator)
// Create router that handles health endpoints, X-Service (ServiceAdapter) routing, // Create router that handles health endpoints, X-Service (ServiceAdapter) routing,
// temporal endpoints, and passes others to upstream handler // temporal endpoints, and passes others to upstream handler
+104
View File
@@ -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)
}
}
+6
View File
@@ -50,6 +50,12 @@ type AuthConfig struct {
JWKSURL string JWKSURL string
// RequiredCapability is the permission required for LLM inference (e.g., "llm:inference"). // RequiredCapability is the permission required for LLM inference (e.g., "llm:inference").
RequiredCapability string 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. // Route represents a single route and its upstream configuration.
+5
View File
@@ -25,6 +25,8 @@ type rawAuth struct {
Audience string `yaml:"audience"` Audience string `yaml:"audience"`
JWKSURL string `yaml:"jwksUrl"` JWKSURL string `yaml:"jwksUrl"`
RequiredCapability string `yaml:"requiredCapability"` RequiredCapability string `yaml:"requiredCapability"`
TokenURL string `yaml:"tokenUrl"`
ClientID string `yaml:"clientId"`
} }
// rawRoute represents a single route in the YAML configuration. // 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, Audience: raw.Auth.Audience,
JWKSURL: raw.Auth.JWKSURL, JWKSURL: raw.Auth.JWKSURL,
RequiredCapability: raw.Auth.RequiredCapability, RequiredCapability: raw.Auth.RequiredCapability,
TokenURL: raw.Auth.TokenURL,
ClientID: raw.Auth.ClientID,
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
} }
return routes, models, adapters, authConfig, nil return routes, models, adapters, authConfig, nil
+111
View File
@@ -0,0 +1,111 @@
// Package identity extracts authenticated user identity from JWT claims
// and injects forwarding headers into proxied requests.
//
// Headers injected after JWT validation:
//
// X-Forwarded-User: subject (sub claim)
// X-Forwarded-Roles: comma-separated roles or permissions
// X-Acting-Service: authorized party (azp claim), only for service accounts
// X-Auth-Verified: "true" when gateway validated the JWT
//
// Security contract: downstream services MUST only accept traffic from the
// gateway (enforced by NetworkPolicy). They trust these headers because the
// gateway is the sole ingress path.
package identity
import (
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
// Headers that the gateway controls. Incoming values from clients are
// stripped to prevent spoofing.
const (
HeaderUser = "X-Forwarded-User"
HeaderRoles = "X-Forwarded-Roles"
HeaderActingService = "X-Acting-Service"
HeaderAuthVerified = "X-Auth-Verified"
)
// managed lists all headers this package owns. Used for stripping and cleanup.
var managed = []string{
HeaderUser,
HeaderRoles,
HeaderActingService,
HeaderAuthVerified,
}
// StripIncoming removes all gateway-managed identity headers from an
// inbound request, preventing clients from spoofing identity.
// Call this early in the handler chain, before any routing.
func StripIncoming(r *http.Request) {
for _, h := range managed {
r.Header.Del(h)
}
}
// Inject extracts identity from validated JWT claims and sets the
// corresponding forwarding headers on the request. Only call this
// after successful JWT validation.
func Inject(r *http.Request, claims jwt.MapClaims) {
r.Header.Set(HeaderAuthVerified, "true")
if sub := claimString(claims, "sub"); sub != "" {
r.Header.Set(HeaderUser, sub)
}
if roles := claimStringSlice(claims, "roles"); len(roles) > 0 {
r.Header.Set(HeaderRoles, strings.Join(roles, ","))
} else if perms := claimStringSlice(claims, "permissions"); len(perms) > 0 {
r.Header.Set(HeaderRoles, strings.Join(perms, ","))
}
if azp := claimString(claims, "azp"); azp != "" {
sub := claimString(claims, "sub")
// Only set acting-service when azp differs from sub
// (i.e., a service account acting, not the user themselves)
if azp != sub {
r.Header.Set(HeaderActingService, azp)
}
}
}
// claimString extracts a string value from claims, returning "" if
// the key is missing or not a string.
func claimString(claims jwt.MapClaims, key string) string {
val, ok := claims[key]
if !ok || val == nil {
return ""
}
s, ok := val.(string)
if !ok {
return ""
}
return s
}
// claimStringSlice extracts a []string from claims. JWT libraries
// deserialize JSON arrays as []interface{}, so each element is
// type-asserted individually. Non-string elements are skipped.
func claimStringSlice(claims jwt.MapClaims, key string) []string {
val, ok := claims[key]
if !ok || val == nil {
return nil
}
raw, ok := val.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
+205
View File
@@ -0,0 +1,205 @@
package identity
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/golang-jwt/jwt/v5"
)
func TestStripIncoming_RemovesSpoofedHeaders(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(HeaderUser, "evil-spoof")
r.Header.Set(HeaderRoles, "admin:*")
r.Header.Set(HeaderActingService, "fake-service")
r.Header.Set(HeaderAuthVerified, "true")
StripIncoming(r)
for _, h := range managed {
if got := r.Header.Get(h); got != "" {
t.Errorf("header %s should be stripped, got %q", h, got)
}
}
}
func TestStripIncoming_PreservesOtherHeaders(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", "Bearer token")
r.Header.Set("Content-Type", "application/json")
r.Header.Set(HeaderUser, "spoof")
StripIncoming(r)
if got := r.Header.Get("Authorization"); got != "Bearer token" {
t.Errorf("Authorization should be preserved, got %q", got)
}
if got := r.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type should be preserved, got %q", got)
}
}
func TestInject_ServiceAccount(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{
"sub": "abc123-hashed-id",
"azp": "portfolio-agent",
"roles": []interface{}{"llm:inference", "memory:read"},
}
Inject(r, claims)
assertHeader(t, r, HeaderAuthVerified, "true")
assertHeader(t, r, HeaderUser, "abc123-hashed-id")
assertHeader(t, r, HeaderRoles, "llm:inference,memory:read")
assertHeader(t, r, HeaderActingService, "portfolio-agent")
}
func TestInject_HumanUser(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{
"sub": "user-hash-456",
"azp": "api-gw",
"permissions": []interface{}{"*"},
}
Inject(r, claims)
assertHeader(t, r, HeaderAuthVerified, "true")
assertHeader(t, r, HeaderUser, "user-hash-456")
assertHeader(t, r, HeaderRoles, "*")
// azp != sub, so acting-service is set
assertHeader(t, r, HeaderActingService, "api-gw")
}
func TestInject_SameSubAndAzp_NoActingService(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{
"sub": "portfolio-agent",
"azp": "portfolio-agent",
"roles": []interface{}{"llm:inference"},
}
Inject(r, claims)
assertHeader(t, r, HeaderActingService, "")
}
func TestInject_RolesOverPermissions(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{
"sub": "user-1",
"roles": []interface{}{"llm:inference"},
"permissions": []interface{}{"admin:*"},
}
Inject(r, claims)
// roles takes precedence over permissions
assertHeader(t, r, HeaderRoles, "llm:inference")
}
func TestInject_PermissionsFallback(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{
"sub": "user-1",
"permissions": []interface{}{"grafana:read", "grafana:write"},
}
Inject(r, claims)
assertHeader(t, r, HeaderRoles, "grafana:read,grafana:write")
}
func TestInject_EmptyClaims(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{}
Inject(r, claims)
assertHeader(t, r, HeaderAuthVerified, "true")
assertHeader(t, r, HeaderUser, "")
assertHeader(t, r, HeaderRoles, "")
assertHeader(t, r, HeaderActingService, "")
}
func TestInject_NilValuesInClaims(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{
"sub": nil,
"azp": nil,
"roles": nil,
}
Inject(r, claims)
assertHeader(t, r, HeaderAuthVerified, "true")
assertHeader(t, r, HeaderUser, "")
assertHeader(t, r, HeaderRoles, "")
}
func TestInject_WildcardPermission(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{
"sub": "admin-user",
"permissions": []interface{}{"*"},
}
Inject(r, claims)
// Wildcard passed as literal, never expanded
assertHeader(t, r, HeaderRoles, "*")
}
func TestInject_MixedTypeRolesArray(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{
"sub": "user-1",
"roles": []interface{}{"llm:inference", 42, nil, "", "memory:read"},
}
Inject(r, claims)
// Non-string and empty elements skipped
assertHeader(t, r, HeaderRoles, "llm:inference,memory:read")
}
func TestInject_EmptyRolesArray(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
claims := jwt.MapClaims{
"sub": "user-1",
"roles": []interface{}{},
"permissions": []interface{}{"backup:read"},
}
Inject(r, claims)
// Empty roles falls through to permissions
assertHeader(t, r, HeaderRoles, "backup:read")
}
func TestStripThenInject_OverwritesSpoof(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(HeaderUser, "evil-spoof")
r.Header.Set(HeaderAuthVerified, "true")
StripIncoming(r)
claims := jwt.MapClaims{
"sub": "real-user",
"roles": []interface{}{"llm:inference"},
}
Inject(r, claims)
assertHeader(t, r, HeaderUser, "real-user")
assertHeader(t, r, HeaderAuthVerified, "true")
}
func assertHeader(t *testing.T, r *http.Request, key, want string) {
t.Helper()
got := r.Header.Get(key)
if got != want {
t.Errorf("header %s = %q, want %q", key, got, want)
}
}
+160
View File
@@ -0,0 +1,160 @@
package proxy
import (
"encoding/json"
"io"
"net/http"
"net/url"
"time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
)
// tokenRequest is the JSON body for POST /auth/token.
type tokenRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Scope string `json:"scope,omitempty"`
}
// refreshRequest is the JSON body for POST /auth/refresh.
type refreshRequest struct {
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope,omitempty"`
}
// authClient handles token exchange with the upstream identity provider.
// Extracted for testability — production uses http.DefaultClient,
// tests inject a stub.
type authClient interface {
PostForm(url string, data url.Values) (*http.Response, error)
}
// httpAuthClient wraps http.Client to implement authClient.
type httpAuthClient struct {
client *http.Client
}
func (c *httpAuthClient) PostForm(url string, data url.Values) (*http.Response, error) {
return c.client.PostForm(url, data)
}
func newAuthClient() authClient {
return &httpAuthClient{
client: &http.Client{Timeout: 10 * time.Second},
}
}
// handleAuthToken exchanges username+password for a JWT via the upstream
// identity provider's token endpoint using grant_type=password.
func (h *Handler) handleAuthToken(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeProblemDetail(w, http.StatusMethodNotAllowed,
"about:blank#method-not-allowed", "Method Not Allowed",
"POST only", nil)
return
}
if h.config.Auth.TokenURL == "" || h.config.Auth.ClientID == "" {
writeProblemDetail(w, http.StatusServiceUnavailable,
"about:blank#not-configured", "Not Configured",
"token endpoint not configured", nil)
return
}
var req tokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeProblemDetail(w, http.StatusBadRequest,
"about:blank#bad-request", "Bad Request",
"invalid JSON body", nil)
return
}
if req.Username == "" || req.Password == "" {
writeProblemDetail(w, http.StatusBadRequest,
"about:blank#bad-request", "Bad Request",
"username and password are required", nil)
return
}
scope := req.Scope
if scope == "" {
scope = "openid roles permissions"
}
form := url.Values{
"grant_type": {"password"},
"username": {req.Username},
"password": {req.Password},
"client_id": {h.config.Auth.ClientID},
"client_secret": {h.config.Auth.ClientSecret},
"scope": {scope},
}
h.forwardTokenResponse(w, form, "/auth/token")
}
// handleAuthRefresh exchanges a refresh token for a new JWT.
func (h *Handler) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeProblemDetail(w, http.StatusMethodNotAllowed,
"about:blank#method-not-allowed", "Method Not Allowed",
"POST only", nil)
return
}
if h.config.Auth.TokenURL == "" || h.config.Auth.ClientID == "" {
writeProblemDetail(w, http.StatusServiceUnavailable,
"about:blank#not-configured", "Not Configured",
"token endpoint not configured", nil)
return
}
var req refreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeProblemDetail(w, http.StatusBadRequest,
"about:blank#bad-request", "Bad Request",
"invalid JSON body", nil)
return
}
if req.RefreshToken == "" {
writeProblemDetail(w, http.StatusBadRequest,
"about:blank#bad-request", "Bad Request",
"refresh_token is required", nil)
return
}
form := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {req.RefreshToken},
"client_id": {h.config.Auth.ClientID},
"client_secret": {h.config.Auth.ClientSecret},
}
if req.Scope != "" {
form.Set("scope", req.Scope)
}
h.forwardTokenResponse(w, form, "/auth/refresh")
}
// forwardTokenResponse posts form data to the token endpoint and
// forwards the response verbatim to the client.
func (h *Handler) forwardTokenResponse(w http.ResponseWriter, form url.Values, logPath string) {
resp, err := h.authHTTP.PostForm(h.config.Auth.TokenURL, form)
if err != nil {
writeProblemDetail(w, http.StatusBadGateway,
"about:blank#bad-gateway", "Bad Gateway",
"identity provider unreachable", nil)
logging.Errorf("auth upstream error", err, map[string]string{
"path": logPath,
})
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
+315
View File
@@ -0,0 +1,315 @@
package proxy
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
)
// stubAuthClient captures the form data sent and returns a canned response.
type stubAuthClient struct {
lastForm url.Values
statusCode int
body string
err error
}
func (s *stubAuthClient) PostForm(u string, data url.Values) (*http.Response, error) {
s.lastForm = data
if s.err != nil {
return nil, s.err
}
return &http.Response{
StatusCode: s.statusCode,
Body: io.NopCloser(strings.NewReader(s.body)),
Header: http.Header{"Content-Type": {"application/json"}},
}, nil
}
func newTestHandler(tokenURL, clientID, clientSecret string, client authClient) *Handler {
cfg := &config.Config{
Auth: config.AuthConfig{
TokenURL: tokenURL,
ClientID: clientID,
ClientSecret: clientSecret,
},
}
h := &Handler{
config: cfg,
authHTTP: client,
routes: make(map[string]*Route),
transports: make(map[string]*http.Transport),
}
return h
}
func TestAuthToken_Success(t *testing.T) {
stub := &stubAuthClient{
statusCode: 200,
body: `{"access_token":"jwt.token.here","refresh_token":"refresh","expires_in":3600}`,
}
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
body := `{"username":"rock","password":"pass123"}`
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify form sent to upstream
if stub.lastForm.Get("grant_type") != "password" {
t.Errorf("grant_type = %q, want password", stub.lastForm.Get("grant_type"))
}
if stub.lastForm.Get("username") != "rock" {
t.Errorf("username = %q, want rock", stub.lastForm.Get("username"))
}
if stub.lastForm.Get("client_id") != "api-gw" {
t.Errorf("client_id = %q, want api-gw", stub.lastForm.Get("client_id"))
}
if stub.lastForm.Get("client_secret") != "secret" {
t.Errorf("client_secret = %q, want secret", stub.lastForm.Get("client_secret"))
}
if stub.lastForm.Get("scope") != "openid roles permissions" {
t.Errorf("scope = %q, want default scope", stub.lastForm.Get("scope"))
}
// Verify response forwarded
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["access_token"] != "jwt.token.here" {
t.Errorf("access_token not forwarded")
}
}
func TestAuthToken_CustomScope(t *testing.T) {
stub := &stubAuthClient{statusCode: 200, body: `{}`}
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
body := `{"username":"rock","password":"pass","scope":"openid roles"}`
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
if stub.lastForm.Get("scope") != "openid roles" {
t.Errorf("scope = %q, want custom scope", stub.lastForm.Get("scope"))
}
}
func TestAuthToken_MissingUsername(t *testing.T) {
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
body := `{"password":"pass"}`
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
if w.Code != 400 {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestAuthToken_MissingPassword(t *testing.T) {
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
body := `{"username":"rock"}`
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
if w.Code != 400 {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestAuthToken_InvalidJSON(t *testing.T) {
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader("not json"))
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
if w.Code != 400 {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestAuthToken_WrongMethod(t *testing.T) {
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
r := httptest.NewRequest("GET", "/auth/token", nil)
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
if w.Code != 405 {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestAuthToken_NotConfigured(t *testing.T) {
h := newTestHandler("", "", "", &stubAuthClient{})
body := `{"username":"rock","password":"pass"}`
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
if w.Code != 503 {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestAuthToken_UpstreamError(t *testing.T) {
stub := &stubAuthClient{err: io.ErrUnexpectedEOF}
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
body := `{"username":"rock","password":"pass"}`
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
if w.Code != 502 {
t.Errorf("expected 502, got %d", w.Code)
}
}
func TestAuthToken_UpstreamRejectsCredentials(t *testing.T) {
stub := &stubAuthClient{
statusCode: 400,
body: `{"error":"invalid_grant","error_description":"bad password"}`,
}
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
body := `{"username":"rock","password":"wrong"}`
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
// Upstream error forwarded verbatim
if w.Code != 400 {
t.Errorf("expected 400 (forwarded), got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "invalid_grant") {
t.Errorf("expected upstream error forwarded, got %s", w.Body.String())
}
}
// --- /auth/refresh tests ---
func TestAuthRefresh_Success(t *testing.T) {
stub := &stubAuthClient{
statusCode: 200,
body: `{"access_token":"new.jwt","refresh_token":"new.refresh","expires_in":3600}`,
}
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
body := `{"refresh_token":"old.refresh"}`
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleAuthRefresh(w, r)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if stub.lastForm.Get("grant_type") != "refresh_token" {
t.Errorf("grant_type = %q, want refresh_token", stub.lastForm.Get("grant_type"))
}
if stub.lastForm.Get("refresh_token") != "old.refresh" {
t.Errorf("refresh_token not sent")
}
}
func TestAuthRefresh_MissingToken(t *testing.T) {
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{}`))
w := httptest.NewRecorder()
h.handleAuthRefresh(w, r)
if w.Code != 400 {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestAuthRefresh_WrongMethod(t *testing.T) {
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
r := httptest.NewRequest("GET", "/auth/refresh", nil)
w := httptest.NewRecorder()
h.handleAuthRefresh(w, r)
if w.Code != 405 {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestAuthRefresh_NotConfigured(t *testing.T) {
h := newTestHandler("", "", "", &stubAuthClient{})
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{"refresh_token":"x"}`))
w := httptest.NewRecorder()
h.handleAuthRefresh(w, r)
if w.Code != 503 {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestAuthRefresh_ExpiredToken(t *testing.T) {
stub := &stubAuthClient{
statusCode: 401,
body: `{"error":"invalid_grant","error_description":"token expired"}`,
}
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{"refresh_token":"expired"}`))
w := httptest.NewRecorder()
h.handleAuthRefresh(w, r)
if w.Code != 401 {
t.Errorf("expected 401 (forwarded), got %d", w.Code)
}
}
// Verify no credentials are leaked in response bodies
func TestAuthToken_NoCredentialLeak(t *testing.T) {
stub := &stubAuthClient{statusCode: 200, body: `{"access_token":"tok"}`}
h := newTestHandler("https://auth.example.com/token/", "api-gw", "super-secret", stub)
body := `{"username":"rock","password":"my-password"}`
r := httptest.NewRequest("POST", "/auth/token", bytes.NewReader([]byte(body)))
w := httptest.NewRecorder()
h.handleAuthToken(w, r)
respBody := w.Body.String()
if strings.Contains(respBody, "super-secret") {
t.Error("client_secret leaked in response")
}
if strings.Contains(respBody, "my-password") {
t.Error("password leaked in response")
}
}
+28
View File
@@ -15,6 +15,7 @@ import (
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config" "forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging" "forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing" "forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
) )
@@ -28,6 +29,8 @@ type Handler struct {
config *config.Config config *config.Config
// jwtValidator validates JWT tokens for authenticated endpoints // jwtValidator validates JWT tokens for authenticated endpoints
jwtValidator *auth.Validator jwtValidator *auth.Validator
// authHTTP is the HTTP client for token exchange with the identity provider.
authHTTP authClient
// Default timeouts for synthesized routes (model-based dispatch) // Default timeouts for synthesized routes (model-based dispatch)
defaultConnectTimeout time.Duration defaultConnectTimeout time.Duration
defaultReadTimeout time.Duration defaultReadTimeout time.Duration
@@ -85,6 +88,10 @@ func New(cfg *config.Config) *Handler {
) )
} }
if cfg.Auth.TokenURL != "" {
h.authHTTP = newAuthClient()
}
for name, route := range cfg.Routes { for name, route := range cfg.Routes {
// Create a transport per unique upstream address for connection reuse // Create a transport per unique upstream address for connection reuse
transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream) transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream)
@@ -227,6 +234,20 @@ func writeProblemDetail(w http.ResponseWriter, status int, problemType, title, d
// ServeHTTP implements http.Handler. // ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Auth endpoints — no JWT required (they issue tokens)
if r.URL.Path == "/auth/token" {
h.handleAuthToken(w, r)
return
}
if r.URL.Path == "/auth/refresh" {
h.handleAuthRefresh(w, r)
return
}
if r.URL.Path == "/auth/exchange" {
h.handleAuthExchange(w, r)
return
}
// Handle /v1/models endpoint (no routing needed, derived from config) // Handle /v1/models endpoint (no routing needed, derived from config)
if r.URL.Path == "/v1/models" && r.Method == "GET" { if r.URL.Path == "/v1/models" && r.Method == "GET" {
h.handleModelsEndpoint(w, r) h.handleModelsEndpoint(w, r)
@@ -303,6 +324,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// Strip spoofed identity headers from all inbound requests.
// Must happen before any routing — even unauthenticated paths.
identity.StripIncoming(r)
// JWT Authentication for /v1/* endpoints // JWT Authentication for /v1/* endpoints
if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") { if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
@@ -331,6 +356,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// Inject identity headers for downstream services
identity.Inject(r, claims)
// Check required capability if configured // Check required capability if configured
if h.config.Auth.RequiredCapability != "" { if h.config.Auth.RequiredCapability != "" {
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") { if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
+229
View File
@@ -0,0 +1,229 @@
package proxy
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
)
// exchangeRequest represents a token exchange request (RFC 8693 subset).
type exchangeRequest struct {
SubjectToken string `json:"subject_token"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Scope string `json:"scope,omitempty"`
Resource string `json:"resource,omitempty"`
}
// exchangeResponse wraps the service token with subject identity metadata.
type exchangeResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IssuedTokenType string `json:"issued_token_type,omitempty"`
Subject string `json:"subject,omitempty"`
ActingParty string `json:"acting_party,omitempty"`
GrantedScope string `json:"scope,omitempty"`
}
// handleAuthExchange implements token exchange: a service presents a user's
// JWT and its own credentials to get a scoped service token with the user's
// identity attached.
//
// Flow:
// 1. Validate subject_token (user's JWT) — signature, expiry, issuer
// 2. Authenticate service via client_credentials against Authentik
// 3. Verify requested scope is a subset of service's roles
// 4. Return service token + subject identity metadata
func (h *Handler) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeProblemDetail(w, http.StatusMethodNotAllowed,
"about:blank#method-not-allowed", "Method Not Allowed",
"POST only", nil)
return
}
if h.jwtValidator == nil || h.config.Auth.TokenURL == "" {
writeProblemDetail(w, http.StatusServiceUnavailable,
"about:blank#not-configured", "Not Configured",
"token exchange not configured", nil)
return
}
var req exchangeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeProblemDetail(w, http.StatusBadRequest,
"about:blank#bad-request", "Bad Request",
"invalid JSON body", nil)
return
}
if req.SubjectToken == "" || req.ClientID == "" || req.ClientSecret == "" {
writeProblemDetail(w, http.StatusBadRequest,
"about:blank#bad-request", "Bad Request",
"subject_token, client_id, and client_secret are required", nil)
return
}
// Step 1: Validate subject token
subjectClaims, err := h.jwtValidator.ValidateBearerToken("Bearer " + req.SubjectToken)
if err != nil {
writeProblemDetail(w, http.StatusForbidden,
"about:blank#invalid-subject-token", "Invalid Subject Token",
fmt.Sprintf("subject token validation failed: %v", err), nil)
logging.Errorf("token exchange: invalid subject", err, map[string]string{
"path": "/auth/exchange",
})
return
}
subject := claimStr(subjectClaims, "sub")
// Step 2: Authenticate service via client_credentials
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {req.ClientID},
"client_secret": {req.ClientSecret},
"scope": {"openid roles"},
}
resp, err := h.authHTTP.PostForm(h.config.Auth.TokenURL, form)
if err != nil {
writeProblemDetail(w, http.StatusBadGateway,
"about:blank#bad-gateway", "Bad Gateway",
"identity provider unreachable", nil)
logging.Errorf("token exchange: upstream error", err, map[string]string{
"path": "/auth/exchange",
})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
writeProblemDetail(w, http.StatusForbidden,
"about:blank#invalid-actor", "Invalid Actor Credentials",
fmt.Sprintf("service authentication failed (HTTP %d)", resp.StatusCode), nil)
return
}
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
writeProblemDetail(w, http.StatusBadGateway,
"about:blank#bad-gateway", "Bad Gateway",
"invalid response from identity provider", nil)
return
}
// Step 3: Decode service token to check roles
serviceRoles, err := extractRolesFromJWT(tokenResp.AccessToken)
if err != nil {
writeProblemDetail(w, http.StatusBadGateway,
"about:blank#bad-gateway", "Bad Gateway",
"cannot decode service token", nil)
return
}
if req.Scope != "" && !scopeSubset(req.Scope, serviceRoles) {
writeProblemDetail(w, http.StatusForbidden,
"about:blank#scope-escalation", "Scope Escalation Denied",
fmt.Sprintf("requested scope %q exceeds service roles %v", req.Scope, serviceRoles), nil)
return
}
grantedScope := req.Scope
if grantedScope == "" {
grantedScope = strings.Join(serviceRoles, " ")
}
// Step 4: Return service token with subject metadata
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(exchangeResponse{
AccessToken: tokenResp.AccessToken,
TokenType: "Bearer",
ExpiresIn: tokenResp.ExpiresIn,
IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
Subject: subject,
ActingParty: req.ClientID,
GrantedScope: grantedScope,
})
}
// scopeSubset checks that every space-separated scope token is in allowed roles.
func scopeSubset(requested string, allowed []string) bool {
allowedSet := make(map[string]bool, len(allowed))
for _, r := range allowed {
allowedSet[r] = true
}
if allowedSet["*"] {
return true
}
for _, s := range strings.Fields(requested) {
if !allowedSet[s] {
return false
}
}
return true
}
// extractRolesFromJWT decodes the payload of a JWT without verification
// and returns the "roles" claim. Used after the token was already obtained
// from a trusted source (Authentik client_credentials response).
func extractRolesFromJWT(token string) ([]string, error) {
parts := strings.SplitN(token, ".", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("base64 decode failed: %w", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("JSON decode failed: %w", err)
}
return claimStrSlice(claims, "roles"), nil
}
// claimStr extracts a string claim.
func claimStr(claims map[string]interface{}, key string) string {
v, ok := claims[key]
if !ok || v == nil {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// claimStrSlice extracts a string slice from a JSON-deserialized []interface{}.
func claimStrSlice(claims map[string]interface{}, key string) []string {
v, ok := claims[key]
if !ok || v == nil {
return nil
}
raw, ok := v.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, item := range raw {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
+215
View File
@@ -0,0 +1,215 @@
package proxy
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
)
// fakeJWT creates a JWT-shaped string (header.payload.signature) with given claims.
// Not cryptographically signed — used only with stubbed validators.
func fakeJWT(claims map[string]interface{}) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
payload, _ := json.Marshal(claims)
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
return header + "." + payloadB64 + ".fakesig"
}
// stubJWTValidator returns claims from a pre-set map keyed by token.
type stubJWTValidator struct {
tokens map[string]map[string]interface{}
}
func (s *stubJWTValidator) ValidateBearerToken(authHeader string) (map[string]interface{}, error) {
token := strings.TrimPrefix(authHeader, "Bearer ")
if claims, ok := s.tokens[token]; ok {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
func (s *stubJWTValidator) CheckPermissions(claims map[string]interface{}, required ...string) bool {
return true
}
// We can't use stubJWTValidator directly because Handler expects *auth.Validator.
// Instead, test via the endpoint with a real JWKS server or test the helpers directly.
func TestScopeSubset(t *testing.T) {
tests := []struct {
requested string
allowed []string
want bool
}{
{"memory:read", []string{"llm:inference", "memory:read"}, true},
{"memory:read memory:write", []string{"memory:read", "memory:write"}, true},
{"memory:write", []string{"memory:read"}, false},
{"admin:*", []string{"memory:read"}, false},
{"anything", []string{"*"}, true},
{"", []string{"memory:read"}, true},
{"memory:read", []string{}, false},
}
for _, tt := range tests {
got := scopeSubset(tt.requested, tt.allowed)
if got != tt.want {
t.Errorf("scopeSubset(%q, %v) = %v, want %v", tt.requested, tt.allowed, got, tt.want)
}
}
}
func TestExtractRolesFromJWT(t *testing.T) {
token := fakeJWT(map[string]interface{}{
"roles": []interface{}{"llm:inference", "memory:read"},
"sub": "test-user",
})
roles, err := extractRolesFromJWT(token)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(roles) != 2 || roles[0] != "llm:inference" || roles[1] != "memory:read" {
t.Errorf("roles = %v, want [llm:inference memory:read]", roles)
}
}
func TestExtractRolesFromJWT_InvalidFormat(t *testing.T) {
_, err := extractRolesFromJWT("not-a-jwt")
if err == nil {
t.Error("expected error for invalid JWT")
}
}
func TestExtractRolesFromJWT_NoRoles(t *testing.T) {
token := fakeJWT(map[string]interface{}{"sub": "user"})
roles, err := extractRolesFromJWT(token)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if roles != nil {
t.Errorf("expected nil roles, got %v", roles)
}
}
func TestHandleAuthExchange_WrongMethod(t *testing.T) {
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
h.jwtValidator = auth.NewValidator("", "", "")
r := httptest.NewRequest("GET", "/auth/exchange", nil)
w := httptest.NewRecorder()
h.handleAuthExchange(w, r)
if w.Code != 405 {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestHandleAuthExchange_NotConfigured(t *testing.T) {
h := &Handler{
config: &config.Config{},
routes: make(map[string]*Route),
transports: make(map[string]*http.Transport),
}
body := `{"subject_token":"x","client_id":"y","client_secret":"z"}`
r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleAuthExchange(w, r)
if w.Code != 503 {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestHandleAuthExchange_MissingFields(t *testing.T) {
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
h.jwtValidator = auth.NewValidator("", "", "")
tests := []struct {
name string
body string
}{
{"missing subject", `{"client_id":"x","client_secret":"y"}`},
{"missing client_id", `{"subject_token":"x","client_secret":"y"}`},
{"missing client_secret", `{"subject_token":"x","client_id":"y"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(tt.body))
w := httptest.NewRecorder()
h.handleAuthExchange(w, r)
if w.Code != 400 {
t.Errorf("expected 400, got %d", w.Code)
}
})
}
}
func TestHandleAuthExchange_InvalidJSON(t *testing.T) {
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
h.jwtValidator = auth.NewValidator("", "", "")
r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader("not json"))
w := httptest.NewRecorder()
h.handleAuthExchange(w, r)
if w.Code != 400 {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestHandleAuthExchange_ScopeEscalation(t *testing.T) {
// Test scopeSubset directly since full integration needs JWKS
if scopeSubset("admin:delete", []string{"memory:read", "memory:write"}) {
t.Error("scope escalation should be denied")
}
if !scopeSubset("memory:read", []string{"memory:read", "memory:write"}) {
t.Error("valid scope should be allowed")
}
}
func TestClaimStr(t *testing.T) {
claims := map[string]interface{}{"sub": "user-1", "num": 42, "nil": nil}
if got := claimStr(claims, "sub"); got != "user-1" {
t.Errorf("claimStr(sub) = %q, want user-1", got)
}
if got := claimStr(claims, "num"); got != "" {
t.Errorf("claimStr(num) = %q, want empty", got)
}
if got := claimStr(claims, "nil"); got != "" {
t.Errorf("claimStr(nil) = %q, want empty", got)
}
if got := claimStr(claims, "missing"); got != "" {
t.Errorf("claimStr(missing) = %q, want empty", got)
}
}
func TestClaimStrSlice(t *testing.T) {
claims := map[string]interface{}{
"roles": []interface{}{"a", "b", "", nil, 42},
"empty": []interface{}{},
"str": "not-a-slice",
}
if got := claimStrSlice(claims, "roles"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Errorf("claimStrSlice(roles) = %v, want [a b]", got)
}
if got := claimStrSlice(claims, "empty"); len(got) != 0 {
t.Errorf("claimStrSlice(empty) = %v, want empty", got)
}
if got := claimStrSlice(claims, "str"); got != nil {
t.Errorf("claimStrSlice(str) = %v, want nil", got)
}
if got := claimStrSlice(claims, "missing"); got != nil {
t.Errorf("claimStrSlice(missing) = %v, want nil", got)
}
}
+97 -103
View File
@@ -14,42 +14,31 @@ import (
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem" "forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
) )
// Dispatcher routes X-Service requests to upstreams. // Dispatcher routes X-Service requests to upstreams.
// Auth per service:
// SQS: Gateway validates JWT (kmsvc code unverified)
// MinIO, Temporal: Native JWT support (dumb pipe pass-through)
// Memory, IAM: Services validate JWTs themselves
type Dispatcher struct { type Dispatcher struct {
registry *Registry registry *Registry
sqsJWTAuth *auth.Validator jwtValidator *auth.Validator
} }
// NewDispatcher creates a new service adapter dispatcher. // NewDispatcher creates a dispatcher with a shared multi-issuer JWT validator.
func NewDispatcher(registry *Registry) *Dispatcher { // Pass nil to disable auth enforcement (all requests pass through).
// Create JWT validator for SQS func NewDispatcher(registry *Registry, jwtValidator *auth.Validator) *Dispatcher {
// Issuer and JWKS URL should match Authentik application config
sqsValidator := auth.NewValidator(
"https://authentik.riotpiao.com/application/o/sqs/",
"sqs",
"https://authentik.riotpiao.com/application/o/sqs/jwks/",
)
return &Dispatcher{ return &Dispatcher{
registry: registry, registry: registry,
sqsJWTAuth: sqsValidator, jwtValidator: jwtValidator,
} }
} }
// Matches returns true if the request should be dispatched based on X-Service header. // Matches returns true if the request has an X-Service header.
func (d *Dispatcher) Matches(r *http.Request) bool { func (d *Dispatcher) Matches(r *http.Request) bool {
return r.Header.Get("X-Service") != "" return r.Header.Get("X-Service") != ""
} }
// Dispatch routes a request to the appropriate adapter. // Dispatch routes a request to the appropriate adapter.
// Returns a problem document if the adapter or resource is not found.
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) { func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
serviceName := r.Header.Get("X-Service") serviceName := r.Header.Get("X-Service")
if serviceName == "" { if serviceName == "" {
@@ -57,102 +46,117 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
return return
} }
// Look up service adapter
adapter := d.registry.Get(serviceName) adapter := d.registry.Get(serviceName)
if adapter == nil { if adapter == nil {
p := problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName)) d.writeError(w, problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName)))
_ = p.Write(w)
return return
} }
// Get resource and method from request
resourceName := r.Header.Get("X-Resource") resourceName := r.Header.Get("X-Resource")
if resourceName == "" { if resourceName == "" {
d.writeError(w, problem.BadRequest("X-Resource header required")) d.writeError(w, problem.BadRequest("X-Resource header required"))
return return
} }
// Find resource resource := findResource(adapter, resourceName)
var resource *Resource
for i := range adapter.Spec.Resources {
if adapter.Spec.Resources[i].Name == resourceName {
resource = &adapter.Spec.Resources[i]
break
}
}
if resource == nil { if resource == nil {
p := problem.NotFound(fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName)) d.writeError(w, problem.NotFound(
_ = p.Write(w) fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName)))
return return
} }
// Find method matching HTTP verb method := findMethod(resource, r.Method)
var method *Method
for i := range resource.Methods {
if resource.Methods[i].Verb == r.Method {
method = &resource.Methods[i]
break
}
}
if method == nil { if method == nil {
p := problem.NotFound(fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName)) d.writeError(w, problem.NotFound(
_ = p.Write(w) fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName)))
return return
} }
// Gateway-level JWT validation for SQS (code unverified in kmsvc) // JWT auth enforcement for adapters that require it
// MinIO, Temporal, Memory, IAM have native JWT support - pass through if adapter.Spec.Auth.Required && d.jwtValidator != nil {
if adapter.Spec.Auth.Required && serviceName == "sqs" { if !d.authenticate(w, r, serviceName, method.Verb) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
"Forbidden", "SQS requires Authorization header")
_ = p.Write(w)
return
}
// Validate JWT signature against Authentik JWKS
claims, err := d.sqsJWTAuth.ValidateBearerToken(authHeader)
if err != nil {
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
"Forbidden", fmt.Sprintf("JWT validation failed: %v", err))
_ = p.Write(w)
return
}
// Check required permissions (sqs:read or sqs:write or *)
hasPermission := d.sqsJWTAuth.CheckPermissions(claims, "sqs:read", "sqs:write", "*")
if !hasPermission {
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
"Forbidden", "Insufficient permissions for SQS")
_ = p.Write(w)
return return
} }
} }
// Detect protocol from upstream URL scheme
upstreamURL := adapter.Spec.Upstream.URL upstreamURL := adapter.Spec.Upstream.URL
if strings.HasPrefix(upstreamURL, "grpc://") { if strings.HasPrefix(upstreamURL, "grpc://") {
// gRPC upstream (Temporal, etc.)
d.dispatchGRPC(w, r, upstreamURL, method, adapter) d.dispatchGRPC(w, r, upstreamURL, method, adapter)
} else { } else {
// HTTP upstream (MinIO, Authentik, etc.)
d.dispatchHTTP(w, r, upstreamURL, method, adapter) d.dispatchHTTP(w, r, upstreamURL, method, adapter)
} }
} }
// dispatchHTTP forwards HTTP requests to upstream, passing Authorization header through. // authenticate validates the JWT and checks service-level capability.
// Returns false (and writes error response) if auth fails.
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName, verb string) bool {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
d.writeError(w, problem.NewProblem(http.StatusUnauthorized,
"about:blank#unauthorized", "Unauthorized",
fmt.Sprintf("service '%s' requires Authorization header", serviceName)))
return false
}
claims, err := d.jwtValidator.ValidateBearerToken(authHeader)
if err != nil {
d.writeError(w, problem.NewProblem(http.StatusForbidden,
"about:blank#forbidden", "Forbidden",
fmt.Sprintf("JWT validation failed: %v", err)))
return false
}
// Check capability: <service>:read for GET/HEAD, <service>:write for mutating verbs
required := capabilityForVerb(serviceName, verb)
if !d.jwtValidator.CheckPermissions(claims, required, "*") {
d.writeError(w, problem.NewProblem(http.StatusForbidden,
"about:blank#insufficient-permissions", "Insufficient Permissions",
fmt.Sprintf("required capability: %s", required)))
return false
}
// Inject identity headers for downstream
identity.Inject(r, claims)
return true
}
// capabilityForVerb maps HTTP verbs to <service>:read or <service>:write.
func capabilityForVerb(serviceName, verb string) string {
switch verb {
case "GET", "HEAD", "OPTIONS":
return serviceName + ":read"
default:
return serviceName + ":write"
}
}
func findResource(adapter *ServiceAdapter, name string) *Resource {
for i := range adapter.Spec.Resources {
if adapter.Spec.Resources[i].Name == name {
return &adapter.Spec.Resources[i]
}
}
return nil
}
func findMethod(resource *Resource, verb string) *Method {
for i := range resource.Methods {
if resource.Methods[i].Verb == verb {
return &resource.Methods[i]
}
}
return nil
}
func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) { func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
parsedURL, err := url.Parse(upstreamURL) parsedURL, err := url.Parse(upstreamURL)
if err != nil { if err != nil {
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error", d.writeError(w, problem.NewProblem(http.StatusInternalServerError,
"Internal Server Error", fmt.Sprintf("invalid upstream URL: %v", err))) "about:blank#server-error", "Internal Server Error",
fmt.Sprintf("invalid upstream URL: %v", err)))
return return
} }
// Create reverse proxy
proxy := httputil.NewSingleHostReverseProxy(parsedURL) proxy := httputil.NewSingleHostReverseProxy(parsedURL)
proxy.Director = func(req *http.Request) { proxy.Director = func(req *http.Request) {
req.URL.Scheme = parsedURL.Scheme req.URL.Scheme = parsedURL.Scheme
@@ -160,42 +164,36 @@ func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstre
req.URL.Path = method.UpstreamPath req.URL.Path = method.UpstreamPath
req.RequestURI = "" req.RequestURI = ""
req.Host = parsedURL.Host req.Host = parsedURL.Host
// Authorization header passes through unchanged
} }
// Set timeout
timeout := adapter.Spec.Upstream.TimeoutSeconds timeout := adapter.Spec.Upstream.TimeoutSeconds
if timeout <= 0 { if timeout <= 0 {
timeout = 30 timeout = 30
} }
proxy.Transport = &http.Transport{ proxy.Transport = &http.Transport{
DialContext: (&net.Dialer{Timeout: time.Duration(timeout) * time.Second}).DialContext, DialContext: (&net.Dialer{Timeout: time.Duration(timeout) * time.Second}).DialContext,
TLSHandshakeTimeout: time.Duration(timeout) * time.Second, TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
} }
// Forward the request
proxy.ServeHTTP(w, r) proxy.ServeHTTP(w, r)
} }
// dispatchGRPC forwards gRPC requests to upstream.
// gRPC URL format: grpc://host:port
func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) { func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
// Extract host:port from grpc://host:port
host := strings.TrimPrefix(upstreamURL, "grpc://") host := strings.TrimPrefix(upstreamURL, "grpc://")
if host == upstreamURL { if host == upstreamURL {
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error", d.writeError(w, problem.NewProblem(http.StatusInternalServerError,
"Internal Server Error", "invalid gRPC URL format")) "about:blank#server-error", "Internal Server Error",
"invalid gRPC URL format"))
return return
} }
// Validate that this is a gRPC request
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") { if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
d.writeError(w, problem.NewProblem(http.StatusBadRequest, "about:blank#bad-request", d.writeError(w, problem.NewProblem(http.StatusBadRequest,
"Bad Request", "gRPC service requires application/grpc content-type")) "about:blank#bad-request", "Bad Request",
"gRPC service requires application/grpc content-type"))
return return
} }
// Set timeout
timeout := adapter.Spec.Upstream.TimeoutSeconds timeout := adapter.Spec.Upstream.TimeoutSeconds
if timeout <= 0 { if timeout <= 0 {
timeout = 30 timeout = 30
@@ -204,25 +202,21 @@ func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstre
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second) ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
defer cancel() defer cancel()
// Dial gRPC upstream
conn, err := grpc.DialContext(ctx, host, conn, err := grpc.DialContext(ctx, host,
grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions( grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100*1024*1024)),
grpc.MaxCallRecvMsgSize(100 * 1024 * 1024), // 100MB
),
) )
if err != nil { if err != nil {
d.writeError(w, problem.NewProblem(http.StatusBadGateway, "about:blank#bad-gateway", d.writeError(w, problem.NewProblem(http.StatusBadGateway,
"Bad Gateway", fmt.Sprintf("failed to dial gRPC upstream: %v", err))) "about:blank#bad-gateway", "Bad Gateway",
fmt.Sprintf("failed to dial gRPC upstream: %v", err)))
return return
} }
defer conn.Close() defer conn.Close()
// Forward gRPC request d.writeError(w, problem.NewProblem(http.StatusNotImplemented,
// Note: Full gRPC forwarding requires grpcproxy or custom middleware. "about:blank#not-implemented", "Not Implemented",
// For now, return unimplemented (Temporal support coming in Phase 9) "gRPC forwarding not yet implemented"))
d.writeError(w, problem.NewProblem(http.StatusNotImplemented, "about:blank#not-implemented",
"Not Implemented", "gRPC forwarding not yet implemented - use in-cluster gRPC clients directly"))
} }
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) { func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
+265
View File
@@ -0,0 +1,265 @@
package serviceadapter
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
)
// stubValidator implements the minimum interface for testing auth.
// Real auth.Validator needs JWKS — we test the dispatcher logic, not JWT crypto.
func newTestRegistry(adapters ...ServiceAdapter) *Registry {
r := NewRegistry(nil)
for i := range adapters {
_ = r.Add(&adapters[i])
}
return r
}
func sqsAdapter(authRequired bool) ServiceAdapter {
return ServiceAdapter{
Name: "sqs",
ServiceName: "sqs",
Spec: Spec{
ServiceName: "sqs",
Upstream: Upstream{URL: "http://localhost:9999", TimeoutSeconds: 5},
Auth: Auth{Required: authRequired},
Resources: []Resource{
{
Name: "list-queues",
Methods: []Method{
{Verb: "GET", UpstreamPath: "/sqs/queues"},
},
},
{
Name: "send-message",
Methods: []Method{
{Verb: "POST", UpstreamPath: "/sqs/send"},
},
},
},
},
}
}
func memoryAdapter() ServiceAdapter {
return ServiceAdapter{
Name: "memory",
ServiceName: "memory",
Spec: Spec{
ServiceName: "memory",
Upstream: Upstream{URL: "http://localhost:8888", TimeoutSeconds: 5},
Auth: Auth{Required: false},
Resources: []Resource{
{
Name: "skills",
Methods: []Method{
{Verb: "GET", UpstreamPath: "/memory/skills"},
},
},
},
},
}
}
func TestDispatch_MissingXService(t *testing.T) {
d := NewDispatcher(newTestRegistry(), nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
d.Dispatch(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestDispatch_UnknownService(t *testing.T) {
d := NewDispatcher(newTestRegistry(), nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Service", "nonexistent")
r.Header.Set("X-Resource", "foo")
d.Dispatch(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestDispatch_MissingXResource(t *testing.T) {
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Service", "memory")
d.Dispatch(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestDispatch_UnknownResource(t *testing.T) {
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Service", "memory")
r.Header.Set("X-Resource", "nonexistent")
d.Dispatch(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestDispatch_WrongHTTPVerb(t *testing.T) {
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/", nil)
r.Header.Set("X-Service", "memory")
r.Header.Set("X-Resource", "skills")
d.Dispatch(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestDispatch_AuthRequired_NoToken(t *testing.T) {
// Use nil validator — auth required but no validator means 401
// Actually with nil validator, auth is skipped. Use a real scenario.
// We need a mock validator. For now test that auth.Required=false passes through.
// The real auth test needs the full JWKS setup which is an integration test.
// Test: auth required, no validator configured = passes through (defense in depth via NetworkPolicy)
d := NewDispatcher(newTestRegistry(sqsAdapter(true)), nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Service", "sqs")
r.Header.Set("X-Resource", "list-queues")
d.Dispatch(w, r)
// With nil validator, auth check is skipped — request reaches upstream (which will fail since localhost:9999 is down)
// The key assertion: it did NOT return 401/403, it tried to proxy
if w.Code == http.StatusUnauthorized || w.Code == http.StatusForbidden {
t.Errorf("expected proxy attempt (not auth rejection), got %d", w.Code)
}
}
func TestDispatch_AuthNotRequired_NoToken(t *testing.T) {
// Start a test upstream
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path})
}))
defer upstream.Close()
adapter := memoryAdapter()
adapter.Spec.Upstream.URL = upstream.URL
d := NewDispatcher(newTestRegistry(adapter), nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Service", "memory")
r.Header.Set("X-Resource", "skills")
d.Dispatch(w, r)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var body map[string]string
json.NewDecoder(w.Body).Decode(&body)
if body["path"] != "/memory/skills" {
t.Errorf("expected upstream path /memory/skills, got %s", body["path"])
}
}
func TestDispatch_PassThroughHeaders(t *testing.T) {
var receivedAuth string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
adapter := memoryAdapter()
adapter.Spec.Upstream.URL = upstream.URL
d := NewDispatcher(newTestRegistry(adapter), nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Service", "memory")
r.Header.Set("X-Resource", "skills")
r.Header.Set("Authorization", "Bearer some-jwt")
d.Dispatch(w, r)
if receivedAuth != "Bearer some-jwt" {
t.Errorf("Authorization header not passed through, got %q", receivedAuth)
}
}
func TestCapabilityForVerb(t *testing.T) {
tests := []struct {
service string
verb string
want string
}{
{"sqs", "GET", "sqs:read"},
{"sqs", "HEAD", "sqs:read"},
{"sqs", "OPTIONS", "sqs:read"},
{"sqs", "POST", "sqs:write"},
{"sqs", "PUT", "sqs:write"},
{"sqs", "DELETE", "sqs:write"},
{"sqs", "PATCH", "sqs:write"},
{"memory", "GET", "memory:read"},
{"memory", "POST", "memory:write"},
{"s3", "GET", "s3:read"},
{"s3", "PUT", "s3:write"},
}
for _, tt := range tests {
got := capabilityForVerb(tt.service, tt.verb)
if got != tt.want {
t.Errorf("capabilityForVerb(%s, %s) = %s, want %s", tt.service, tt.verb, got, tt.want)
}
}
}
func TestDispatch_IdentityHeadersNotSet_WhenNoAuth(t *testing.T) {
var gotUser, gotVerified string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser = r.Header.Get(identity.HeaderUser)
gotVerified = r.Header.Get(identity.HeaderAuthVerified)
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
adapter := memoryAdapter()
adapter.Spec.Upstream.URL = upstream.URL
d := NewDispatcher(newTestRegistry(adapter), nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Service", "memory")
r.Header.Set("X-Resource", "skills")
d.Dispatch(w, r)
if gotUser != "" {
t.Errorf("X-Forwarded-User should not be set without auth, got %q", gotUser)
}
if gotVerified != "" {
t.Errorf("X-Auth-Verified should not be set without auth, got %q", gotVerified)
}
}
+3 -1
View File
@@ -17,6 +17,8 @@ data:
audience: "api-gw" audience: "api-gw"
jwksUrl: "http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/" jwksUrl: "http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/"
requiredCapability: "llm:inference" 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) # Routes: standard HTTP proxy routes (not LLM-specific)
# These are for non-LLM services (agent-pod/console, etc.) # These are for non-LLM services (agent-pod/console, etc.)
@@ -113,7 +115,7 @@ data:
- serviceName: s3 - serviceName: s3
upstream: upstream:
url: http://minio.storage.svc.cluster.local:9000 url: http://minio.storage.svc.cluster.local:80
timeoutSeconds: 30 timeoutSeconds: 30
auth: auth:
required: false required: false
+6
View File
@@ -57,6 +57,12 @@ spec:
value: "0.0.0.0:8080" value: "0.0.0.0:8080"
- name: CONFIG_PATH - name: CONFIG_PATH
value: "/etc/gateway/config.yaml" value: "/etc/gateway/config.yaml"
- name: AUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: api-gw-client-secret
key: client-secret
optional: true
- name: SHUTDOWN_TIMEOUT - name: SHUTDOWN_TIMEOUT
value: "5m" value: "5m"
- name: LOG_LEVEL - name: LOG_LEVEL
+3 -1
View File
@@ -14,6 +14,8 @@ stringData:
audience: "api-gw" audience: "api-gw"
jwksUrl: "http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/" jwksUrl: "http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/"
requiredCapability: "llm:inference" requiredCapability: "llm:inference"
tokenUrl: "http://authentik-server.iam.svc.cluster.local/application/o/token/"
clientId: "api-gw"
routes: [] routes: []
models: models:
- name: "reasoning" - name: "reasoning"
@@ -91,7 +93,7 @@ stringData:
upstreamPath: /memory/skills upstreamPath: /memory/skills
- serviceName: s3 - serviceName: s3
upstream: upstream:
url: http://minio.storage.svc.cluster.local:9000 url: http://minio.storage.svc.cluster.local:80
timeoutSeconds: 30 timeoutSeconds: 30
auth: auth:
required: false required: false
+4
View File
@@ -123,10 +123,14 @@ spec:
- protocol: TCP - protocol: TCP
port: 8080 port: 8080
# Allow to MinIO (S3-compatible storage) # Allow to MinIO (S3-compatible storage)
# Service `minio` listens on port 80 (targetPort 9000).
# Headless `minio-cluster-hl` is 9000. Allow both.
- to: - to:
- namespaceSelector: - namespaceSelector:
matchLabels: matchLabels:
kubernetes.io/metadata.name: storage kubernetes.io/metadata.name: storage
ports: ports:
- protocol: TCP
port: 80
- protocol: TCP - protocol: TCP
port: 9000 port: 9000