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:
@@ -243,6 +243,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.handleAuthRefresh(w, r)
|
h.handleAuthRefresh(w, r)
|
||||||
return
|
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" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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