Files
Alessandro Frigerio a1cb032df2 TUN-10801: Validate quick tunnel broker assertions
## Summary

Validates callback-bound Quick Tunnel broker assertions using the verification keys cached in !1937. The validator accepts only ES256 JWTs from the fixed broker issuer and audience, verifies signatures before claims, enforces the two-minute handoff lifetime and Access identity expiry, binds the callback hostname and browser state, validates and normalizes the verified email, and returns a trusted identity for recipient authorization.

The temporary hostname validator mirrors TUN-10800 and is documented for consolidation after that stack merges.

## Stack

- Depends on: !1937
- Part 2/2: this MR

## Validation

- `make test lint`
- focused `go test -race ./connection` coverage for JWT headers, signatures, claims, and identity binding
2026-09-16 18:30:13 +00:00

188 lines
6.2 KiB
Go

package quicktunnelauth
import (
"context"
"crypto/hmac"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
const (
quickTunnelAuthBrokerIssuer = "https://login.trycloudflare.com"
quickTunnelAuthBrokerAudience = "cloudflared-quick-tunnel"
quickTunnelAuthBrokerAssertionType = "quick_tunnel_auth"
quickTunnelAuthBrokerAssertionTTL = 2 * time.Minute
quickTunnelAuthBrokerClockSkew = 30 * time.Second
quickTunnelAuthBrokerJWTHeaderType = "JWT"
)
// QuickTunnelAuthIdentity contains the broker-verified identity required by
// local Quick Tunnel recipient authorization.
type QuickTunnelAuthIdentity struct {
Email string
ExpiresAt time.Time
}
type quickTunnelAuthBrokerAssertionClaims struct {
Type string `json:"type"`
Email string `json:"email"`
CallbackHostname string `json:"callback_host"`
State string `json:"state"`
// IdentityExpiresAt is the Access identity expiry, distinct from the assertion's short-lived expiry.
IdentityExpiresAt *int64 `json:"identity_exp"`
jwt.Claims
}
// Validate verifies a broker assertion and binds it to the expected Quick
// Tunnel hostname and browser authentication state.
func (v *QuickTunnelAuthAssertionValidator) Validate(
ctx context.Context,
assertion string,
expectedHostname string,
expectedState string,
) (*QuickTunnelAuthIdentity, error) {
if v == nil {
return nil, errors.New("authentication assertion validator cannot be nil")
}
if ctx == nil {
return nil, errors.New("authentication assertion validation context cannot be nil")
}
if assertion == "" {
return nil, errors.New("broker assertion cannot be empty")
}
if !isQuickTunnelHostname(expectedHostname) {
return nil, errors.New("expected callback hostname is not a valid Quick Tunnel hostname")
}
if !isCanonicalQuickTunnelAuthAssertionState(expectedState) {
return nil, errors.New("expected authentication state is invalid")
}
parsedToken, err := jwt.ParseSigned(assertion, []jose.SignatureAlgorithm{jose.ES256})
if err != nil {
return nil, fmt.Errorf("parse broker assertion: %w", err)
}
if len(parsedToken.Headers) != 1 {
return nil, errors.New("broker assertion must contain exactly one protected header")
}
header := parsedToken.Headers[0]
if err := validateQuickTunnelAuthBrokerHeader(header); err != nil {
return nil, err
}
verificationKey, err := v.verificationKey(ctx, header.KeyID)
if err != nil {
return nil, err
}
var claims quickTunnelAuthBrokerAssertionClaims
if err := parsedToken.Claims(verificationKey.Key, &claims); err != nil {
return nil, fmt.Errorf("verify broker assertion: %w", err)
}
return v.validateClaims(&claims, expectedHostname, expectedState)
}
func validateQuickTunnelAuthBrokerHeader(header jose.Header) error {
if header.JSONWebKey != nil {
return errors.New("broker assertion must not contain an embedded verification key")
}
if header.KeyID == "" ||
len(header.KeyID) > quickTunnelAuthBrokerMaxKeyIDLength ||
strings.TrimSpace(header.KeyID) != header.KeyID {
return errors.New("broker assertion key ID is invalid")
}
headerType, ok := header.ExtraHeaders[jose.HeaderType].(string)
if !ok || headerType != quickTunnelAuthBrokerJWTHeaderType {
return errors.New("broker assertion type header must be JWT")
}
return nil
}
func (v *QuickTunnelAuthAssertionValidator) validateClaims(
claims *quickTunnelAuthBrokerAssertionClaims,
expectedHostname string,
expectedState string,
) (*QuickTunnelAuthIdentity, error) {
if claims == nil {
return nil, errors.New("broker assertion claims cannot be nil")
}
if claims.Issuer == "" {
return nil, errors.New("broker assertion issuer is missing")
}
if len(claims.Audience) == 0 {
return nil, errors.New("broker assertion audience is missing")
}
if claims.IssuedAt == nil {
return nil, errors.New("broker assertion issued-at time is missing")
}
if claims.Expiry == nil {
return nil, errors.New("broker assertion expiry is missing")
}
if claims.IdentityExpiresAt == nil {
return nil, errors.New("broker assertion Access identity expiry is missing")
}
now := v.now().UTC()
if err := claims.ValidateWithLeeway(jwt.Expected{
Issuer: quickTunnelAuthBrokerIssuer,
AnyAudience: jwt.Audience{quickTunnelAuthBrokerAudience},
Time: now,
}, quickTunnelAuthBrokerClockSkew); err != nil {
return nil, fmt.Errorf("validate broker assertion standard claims: %w", err)
}
issuedAt := claims.IssuedAt.Time().UTC()
expiresAt := claims.Expiry.Time().UTC()
if !now.Before(expiresAt) {
return nil, errors.New("broker assertion has expired")
}
if issuedAt.After(expiresAt) {
return nil, errors.New("broker assertion expiry must not be before its issued-at time")
}
if expiresAt.After(issuedAt.Add(quickTunnelAuthBrokerAssertionTTL)) {
return nil, errors.New("broker assertion lifetime exceeds the maximum")
}
if claims.Type != quickTunnelAuthBrokerAssertionType {
return nil, errors.New("broker assertion has an invalid type")
}
if !isQuickTunnelHostname(claims.CallbackHostname) || claims.CallbackHostname != expectedHostname {
return nil, errors.New("broker assertion callback hostname does not match the expected Quick Tunnel")
}
if !isCanonicalQuickTunnelAuthAssertionState(claims.State) ||
!hmac.Equal([]byte(claims.State), []byte(expectedState)) {
return nil, errors.New("broker assertion state does not match the expected authentication state")
}
normalizedEmail := normalizeQuickTunnelEmail(claims.Email)
if !isValidQuickTunnelEmail(normalizedEmail) {
return nil, errors.New("broker assertion contains an invalid email")
}
identityExpiresAt := time.Unix(*claims.IdentityExpiresAt, 0).UTC()
if !now.Before(identityExpiresAt) {
return nil, errors.New("broker assertion Access identity has expired")
}
if expiresAt.After(identityExpiresAt) {
return nil, errors.New("broker assertion outlives the Access identity")
}
return &QuickTunnelAuthIdentity{
Email: normalizedEmail,
ExpiresAt: identityExpiresAt,
}, nil
}
func isCanonicalQuickTunnelAuthAssertionState(state string) bool {
decodedState, err := base64.RawURLEncoding.Strict().DecodeString(state)
return err == nil && len(decodedState) == quickTunnelAuthStateSize
}