flex-auth/internal/callerauth/tokenreview.go
tegwick ca070df32d
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 1m0s
Record authenticated caller in the decision envelope.
FLEX-WP-0023-T04: provenance.caller is additive (mode required;
principal/audience/not_after when a token was reviewed). TokenReview
keeps the JWT exp. request_digest is unchanged because the caller is
not binding material.

Assistant: grok
Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
2026-09-14 04:44:07 +02:00

139 lines
4 KiB
Go

package callerauth
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
type KubernetesTokenReviewer struct {
endpoint string
audience string
reviewerTokenFile string
client *http.Client
}
func NewKubernetesTokenReviewer(endpoint, audience, reviewerTokenFile, caFile string) (*KubernetesTokenReviewer, error) {
ca, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read Kubernetes CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("Kubernetes CA file contains no certificates")
}
return &KubernetesTokenReviewer{
endpoint: strings.TrimRight(endpoint, "/") + "/apis/authentication.k8s.io/v1/tokenreviews",
audience: audience,
reviewerTokenFile: reviewerTokenFile,
client: &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: pool,
}},
},
}, nil
}
type tokenReview struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Spec tokenReviewSpec `json:"spec"`
Status tokenReviewStatus `json:"status,omitempty"`
}
type tokenReviewSpec struct {
Token string `json:"token"`
Audiences []string `json:"audiences"`
}
type tokenReviewStatus struct {
Authenticated bool `json:"authenticated"`
Audiences []string `json:"audiences"`
Error string `json:"error"`
User struct {
Username string `json:"username"`
} `json:"user"`
}
func (r *KubernetesTokenReviewer) Review(ctx context.Context, callerToken string) (Identity, error) {
reviewerToken, err := os.ReadFile(r.reviewerTokenFile)
if err != nil {
return Identity{}, fmt.Errorf("read reviewer credential: %w", err)
}
payload, err := json.Marshal(tokenReview{
APIVersion: "authentication.k8s.io/v1",
Kind: "TokenReview",
Spec: tokenReviewSpec{
Token: callerToken,
Audiences: []string{r.audience},
},
})
if err != nil {
return Identity{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.endpoint, bytes.NewReader(payload))
if err != nil {
return Identity{}, err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(reviewerToken)))
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return Identity{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return Identity{}, fmt.Errorf("TokenReview returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
var review tokenReview
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&review); err != nil {
return Identity{}, fmt.Errorf("decode TokenReview: %w", err)
}
if review.Status.Error != "" {
return Identity{}, fmt.Errorf("%w: TokenReview: %s", ErrUnauthenticated, review.Status.Error)
}
if !review.Status.Authenticated {
return Identity{}, nil
}
identity := Identity{Username: review.Status.User.Username, Audiences: review.Status.Audiences}
if exp, ok := tokenExpiry(callerToken); ok {
identity.NotAfter = exp
}
return identity, nil
}
// tokenExpiry reads exp from a JWT payload. TokenReview already validated the
// token, including expiry; the claim is captured here because the review
// response does not return it.
func tokenExpiry(token string) (time.Time, bool) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Time{}, false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
payload, err = base64.URLEncoding.DecodeString(parts[1])
if err != nil {
return time.Time{}, false
}
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp <= 0 {
return time.Time{}, false
}
return time.Unix(claims.Exp, 0).UTC(), true
}