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"}
```
This commit was merged in pull request #19.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user