CI / CI (pull_request) Successful in 3m38s
POST /auth/exchange: service presents user JWT + own credentials, gets scoped service token with subject identity metadata. Flow: validate subject token, authenticate service via client_credentials, verify scope subset, return token + metadata. Scope escalation denied if requested scope exceeds service roles. 14 tests covering: helpers, validation, missing fields, scope subset, JWT decoding, edge cases. Closes homelab#10 Co-authored-by: poimen <[email protected]>
230 lines
6.7 KiB
Go
230 lines
6.7 KiB
Go
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
|
|
}
|