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 }