feat(proxy): add /auth/exchange token exchange endpoint #19

Merged
rock merged 1 commits from feat/p3.5-token-exchange into main 2026-09-09 00:31:13 +00:00
3 changed files with 448 additions and 0 deletions
+4
View File
@@ -243,6 +243,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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)
if r.URL.Path == "/v1/models" && r.Method == "GET" {
+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)
}
}