Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions controlplane/admin/alb_oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
//go:build kubernetes

package admin

import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)

// ALBOIDCVerifier verifies the X-Amzn-Oidc-Data JWT that an AWS ALB injects
// after a successful Cognito authentication.
//
// The verifier checks the ES256 signature against the ALB's regional public
// key. AWS publishes these keys at a fixed HTTPS endpoint per region. The
// verifier also checks the exp, signer, iss, and client claims. A request
// that never crossed the ALB cannot produce a JWT that passes these checks.
//
// This verification is the trust boundary for operator identity. Earlier
// versions trusted the header without a signature check. That trusted the
// network path instead: any caller that reached the pod directly could forge
// the header.
type ALBOIDCVerifier struct {
region string
issuer string
clientID string

// keyURL builds the public-key URL for a kid. Tests override it.
keyURL func(kid string) string
// httpClient fetches public keys. Tests override it.
httpClient *http.Client
// now returns the current time. Tests override it.
now func() time.Time

mu sync.Mutex
keys map[string]*ecdsa.PublicKey
}

// albKeyIDPattern restricts kid values before they enter a URL path.
// AWS key IDs contain only these characters.
var albKeyIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)

// NewALBOIDCVerifier returns a verifier for ALB OIDC JWTs.
//
// region is the AWS region of the ALB. The verifier uses it for the public
// key endpoint and the signer claim prefix. issuer is the expected iss claim.
// clientID is the expected client claim. Empty issuer or clientID skips that
// check. Keep both set in production.
func NewALBOIDCVerifier(region, issuer, clientID string) (*ALBOIDCVerifier, error) {
if region == "" {
return nil, fmt.Errorf("ALB OIDC verifier requires an AWS region")
}
v := &ALBOIDCVerifier{
region: region,
issuer: issuer,
clientID: clientID,
now: time.Now,
}
v.keyURL = func(kid string) string {
return fmt.Sprintf("https://public-keys.auth.elb.%s.amazonaws.com/%s", region, kid)
}
v.httpClient = &http.Client{Timeout: 5 * time.Second}
return v, nil
}

// Verify checks the signature and claims of an X-Amzn-Oidc-Data JWT. It
// returns the claims on success.
func (v *ALBOIDCVerifier) Verify(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, errMalformedJWT
}
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
headerBytes, err = base64.URLEncoding.DecodeString(parts[0])
if err != nil {
return nil, errMalformedJWT
}
}
var header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
if err := json.Unmarshal(headerBytes, &header); err != nil {
return nil, errMalformedJWT
}
// The ALB signs with ES256. Reject any other algorithm. This prevents
// algorithm-confusion attacks.
if header.Alg != "ES256" {
return nil, &jwtError{"unexpected JWT algorithm"}
}
if !albKeyIDPattern.MatchString(header.Kid) {
return nil, &jwtError{"invalid JWT key id"}
}

key, err := v.publicKey(header.Kid)
if err != nil {
return nil, fmt.Errorf("fetch ALB public key: %w", err)
}

sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
sig, err = base64.URLEncoding.DecodeString(parts[2])
if err != nil {
return nil, errMalformedJWT
}
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.VerifyASN1(key, digest[:], sig) {
return nil, &jwtError{"JWT signature verification failed"}
}

claims, err := decodeJWTClaims(token)
if err != nil {
return nil, err
}
if err := v.checkClaims(claims); err != nil {
return nil, err
}
return claims, nil
}

// checkClaims validates the registered claims of a verified JWT.
func (v *ALBOIDCVerifier) checkClaims(claims map[string]any) error {
// The ALB always sets exp. Reject tokens without it.
exp, ok := claims["exp"].(float64)
if !ok {
return &jwtError{"missing exp claim"}
}
// Allow 60 seconds of clock skew between the ALB and this process.
if time.Unix(int64(exp), 0).Add(60 * time.Second).Before(v.now()) {
return &jwtError{"JWT expired"}
}
// The signer claim names the ALB that signed the JWT. It must be an ALB
// in the configured region. This blocks JWTs that a different ALB signed.
signer := stringClaim(claims, "signer")
expectedPrefix := "arn:aws:elasticloadbalancing:" + v.region + ":"
if !strings.HasPrefix(signer, expectedPrefix) {
return &jwtError{"unexpected JWT signer"}
}
if v.issuer != "" && stringClaim(claims, "iss") != v.issuer {
return &jwtError{"unexpected JWT issuer"}
}
if v.clientID != "" && stringClaim(claims, "client") != v.clientID {
return &jwtError{"unexpected JWT client"}
}
return nil
}

// publicKey returns the cached key for kid. It fetches the key from AWS on
// a cache miss. A forged JWT with an unknown kid causes one fetch per kid.
// The kid pattern and the per-kid cache bound that fetch rate.
func (v *ALBOIDCVerifier) publicKey(kid string) (*ecdsa.PublicKey, error) {
v.mu.Lock()
if v.keys == nil {
v.keys = make(map[string]*ecdsa.PublicKey)
}
if key, ok := v.keys[kid]; ok {
v.mu.Unlock()
return key, nil
}
v.mu.Unlock()

key, err := v.fetchKey(kid)
if err != nil {
return nil, err
}

v.mu.Lock()
v.keys[kid] = key
v.mu.Unlock()
return key, nil
}

// fetchKey downloads the PEM-encoded public key for kid from AWS.
func (v *ALBOIDCVerifier) fetchKey(kid string) (*ecdsa.PublicKey, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, v.keyURL(kid), nil)
if err != nil {
return nil, err
}
resp, err := v.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("key endpoint returned %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
block, _ := pem.Decode(body)
if block == nil {
return nil, fmt.Errorf("key endpoint returned no PEM block")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
key, ok := parsed.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("public key is not ECDSA")
}
if key.Curve != elliptic.P256() {
return nil, fmt.Errorf("public key is not P-256")
}
return key, nil
}
161 changes: 161 additions & 0 deletions controlplane/admin/alb_oidc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//go:build kubernetes

package admin

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)

// testALBSigningKey generates a throwaway P-256 key for signed test JWTs.
func testALBSigningKey(t *testing.T) *ecdsa.PrivateKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate test key: %v", err)
}
return key
}

// testALBVerifier returns a verifier whose key endpoint is a local test
// server that serves the public half of key for any kid.
func testALBVerifier(t *testing.T, key *ecdsa.PrivateKey, issuer, clientID string) *ALBOIDCVerifier {
t.Helper()
der, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
t.Fatalf("marshal test public key: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(pemBytes)
}))
t.Cleanup(srv.Close)

v, err := NewALBOIDCVerifier("us-east-1", issuer, clientID)
if err != nil {
t.Fatalf("NewALBOIDCVerifier: %v", err)
}
v.keyURL = func(kid string) string { return srv.URL + "/" + url.PathEscape(kid) }
v.httpClient = srv.Client()
return v
}

// signedOIDC builds an ES256-signed JWT with the given claims, in the
// X-Amzn-Oidc-Data wire format (header.payload.signature).
func signedOIDC(t *testing.T, key *ecdsa.PrivateKey, claims map[string]any) string {
t.Helper()
headerSeg := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256","kid":"test-kid","typ":"JWT"}`))
payload, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
payloadSeg := base64.RawURLEncoding.EncodeToString(payload)
digest := sha256.Sum256([]byte(headerSeg + "." + payloadSeg))
sig, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
t.Fatalf("sign test JWT: %v", err)
}
return headerSeg + "." + payloadSeg + "." + base64.RawURLEncoding.EncodeToString(sig)
}

// validALBClaims returns claims that pass the verifier's claim checks for a
// verifier built with issuer testALBIssuer and clientID testALBClientID.
// Callers override individual keys to build rejection cases.
const (
testALBIssuer = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_TEST"
testALBClientID = "test-client-id"
)

func validALBClaims(overrides map[string]any) map[string]any {
claims := map[string]any{
"exp": float64(time.Now().Add(time.Hour).Unix()),
"signer": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/internal/test/abc",
"iss": testALBIssuer,
"client": testALBClientID,
}
for k, v := range overrides {
claims[k] = v
}
return claims
}

func TestALBOIDCVerifierAcceptsValidToken(t *testing.T) {
key := testALBSigningKey(t)
v := testALBVerifier(t, key, testALBIssuer, testALBClientID)
claims, err := v.Verify(signedOIDC(t, key, validALBClaims(map[string]any{"email": "a@posthog.com"})))
if err != nil {
t.Fatalf("Verify: %v", err)
}
if claims["email"] != "a@posthog.com" {
t.Fatalf("email = %v", claims["email"])
}
}

func TestALBOIDCVerifierRejections(t *testing.T) {
key := testALBSigningKey(t)
otherKey := testALBSigningKey(t)
v := testALBVerifier(t, key, testALBIssuer, testALBClientID)

wrongSig := signedOIDC(t, otherKey, validALBClaims(nil))

tampered := signedOIDC(t, key, validALBClaims(nil))
// Replace the payload with attacker claims, keep the original signature.
forgedPayload, _ := json.Marshal(validALBClaims(map[string]any{"email": "admin@posthog.com", "role": "admin"}))
parts := splitForTest(t, tampered)
tampered = parts[0] + "." + base64.RawURLEncoding.EncodeToString(forgedPayload) + "." + parts[2]

cases := []struct {
name string
token string
}{
{"signature from wrong key", wrongSig},
{"tampered payload", tampered},
{"unsigned legacy token", mkUnsignedOIDC(map[string]any{"email": "a@posthog.com"})},
{"expired", signedOIDC(t, key, validALBClaims(map[string]any{"exp": float64(time.Now().Add(-time.Hour).Unix())}))},
{"missing exp", signedOIDC(t, key, map[string]any{"signer": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/internal/test/abc", "iss": testALBIssuer, "client": testALBClientID})},
{"signer in another region", signedOIDC(t, key, validALBClaims(map[string]any{"signer": "arn:aws:elasticloadbalancing:eu-central-1:123456789012:loadbalancer/internal/test/abc"}))},
{"wrong issuer", signedOIDC(t, key, validALBClaims(map[string]any{"iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_OTHER"}))},
{"wrong client", signedOIDC(t, key, validALBClaims(map[string]any{"client": "other-client"}))},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := v.Verify(tc.token); err == nil {
t.Fatal("Verify accepted a token it must reject")
}
})
}
}

// splitForTest splits a JWT into its three segments.
func splitForTest(t *testing.T, token string) [3]string {
t.Helper()
var out [3]string
start := 0
for i := 0; i < 2; i++ {
idx := -1
for j := start; j < len(token); j++ {
if token[j] == '.' {
idx = j
break
}
}
if idx < 0 {
t.Fatalf("token has fewer than 3 segments")
}
out[i] = token[start:idx]
start = idx + 1
}
out[2] = token[start:]
return out
}
Loading
Loading