diff --git a/client/client.go b/client/client.go index 5399915..7d450bc 100644 --- a/client/client.go +++ b/client/client.go @@ -107,6 +107,9 @@ func validateConfig(cfg Config) error { if cfg.Limits.MaxHTTPResponseBytes <= 0 { return fmt.Errorf("client: config: limits.max_http_response_bytes must be positive") } + if cfg.Limits.MaxJOSECompactBytes <= 0 { + return fmt.Errorf("client: config: limits.max_jose_compact_bytes must be positive") + } if cfg.Profile == ProfileFAPISecurityWithMessageSigning { if !cfg.Algorithms.RequestObject.IsValid() { diff --git a/client/client_test.go b/client/client_test.go index d48975e..1f62efc 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -169,6 +169,7 @@ func validConfig(t *testing.T) client.Config { MaxClockSkew: 5 * time.Second, HTTPTimeout: 5 * time.Second, MaxHTTPResponseBytes: 1 << 16, + MaxJOSECompactBytes: 16 * 1024, }, } } @@ -211,22 +212,23 @@ func TestNewAcceptsValidConfig(t *testing.T) { func TestNewRejectsInvalidConfig(t *testing.T) { cases := map[string]func(*client.Config){ - "zero issuer": func(c *client.Config) { c.Issuer = fapi.URL{} }, - "empty client id": func(c *client.Config) { c.ClientID = "" }, - "empty redirect uri": func(c *client.Config) { c.RedirectURI = "" }, - "zero authorization ep": func(c *client.Config) { c.Endpoints.Authorization = fapi.URL{} }, - "zero token ep": func(c *client.Config) { c.Endpoints.Token = fapi.URL{} }, - "zero par ep": func(c *client.Config) { c.Endpoints.PushedAuthorizationRequest = fapi.URL{} }, - "invalid profile": func(c *client.Config) { c.Profile = 0 }, - "invalid client auth alg": func(c *client.Config) { c.Algorithms.ClientAuthentication = 0 }, - "invalid dpop alg": func(c *client.Config) { c.Algorithms.DPoP = 0 }, - "invalid id token alg": func(c *client.Config) { c.Algorithms.IDToken = 0 }, - "zero assertion lifetime": func(c *client.Config) { c.Limits.ClientAssertionLifetime = 0 }, - "zero session lifetime": func(c *client.Config) { c.Limits.SessionLifetime = 0 }, - "zero max id token life": func(c *client.Config) { c.Limits.MaxIDTokenLifetime = 0 }, - "negative clock skew": func(c *client.Config) { c.Limits.MaxClockSkew = -time.Second }, - "zero http timeout": func(c *client.Config) { c.Limits.HTTPTimeout = 0 }, - "zero max response bytes": func(c *client.Config) { c.Limits.MaxHTTPResponseBytes = 0 }, + "zero issuer": func(c *client.Config) { c.Issuer = fapi.URL{} }, + "empty client id": func(c *client.Config) { c.ClientID = "" }, + "empty redirect uri": func(c *client.Config) { c.RedirectURI = "" }, + "zero authorization ep": func(c *client.Config) { c.Endpoints.Authorization = fapi.URL{} }, + "zero token ep": func(c *client.Config) { c.Endpoints.Token = fapi.URL{} }, + "zero par ep": func(c *client.Config) { c.Endpoints.PushedAuthorizationRequest = fapi.URL{} }, + "invalid profile": func(c *client.Config) { c.Profile = 0 }, + "invalid client auth alg": func(c *client.Config) { c.Algorithms.ClientAuthentication = 0 }, + "invalid dpop alg": func(c *client.Config) { c.Algorithms.DPoP = 0 }, + "invalid id token alg": func(c *client.Config) { c.Algorithms.IDToken = 0 }, + "zero assertion lifetime": func(c *client.Config) { c.Limits.ClientAssertionLifetime = 0 }, + "zero session lifetime": func(c *client.Config) { c.Limits.SessionLifetime = 0 }, + "zero max id token life": func(c *client.Config) { c.Limits.MaxIDTokenLifetime = 0 }, + "negative clock skew": func(c *client.Config) { c.Limits.MaxClockSkew = -time.Second }, + "zero http timeout": func(c *client.Config) { c.Limits.HTTPTimeout = 0 }, + "zero max response bytes": func(c *client.Config) { c.Limits.MaxHTTPResponseBytes = 0 }, + "zero max jose compact bytes": func(c *client.Config) { c.Limits.MaxJOSECompactBytes = 0 }, "id_token key management set without content encryption": func(c *client.Config) { c.Algorithms.IDTokenKeyManagement = fapi.RSAOAEP256 }, diff --git a/client/config.go b/client/config.go index be9c84c..4d2f0f5 100644 --- a/client/config.go +++ b/client/config.go @@ -156,6 +156,17 @@ type Limits struct { // MaxHTTPResponseBytes bounds how much of a PAR or token-endpoint // response body this client reads before failing. MaxHTTPResponseBytes int64 + + // MaxJOSECompactBytes bounds how large a JOSE compact serialization + // (JWS or JWE) this client will parse for an ID token or a UserInfo + // response — the two artifacts whose size scales with however many + // scopes/claims this deployment's issuer was asked to grant, so a + // fixed size can fit one user's response and not another's. This is + // deliberately narrower than MaxHTTPResponseBytes, which still + // bounds the outer HTTP read first: a DPoP proof, client assertion + // or request object has no such variability and is not affected by + // this field at all — those stay on jose.DefaultMaxCompactBytes. + MaxJOSECompactBytes int } // Config is this client's immutable configuration. It is copied by New; diff --git a/client/errors.go b/client/errors.go index 1b49037..7c0b2cd 100644 --- a/client/errors.go +++ b/client/errors.go @@ -11,6 +11,14 @@ const ( ErrorInvalidResponse ErrorCode = "invalid_response" ErrorAuthorizationDenied ErrorCode = "authorization_denied" ErrorInternal ErrorCode = "internal" + + // ErrorResponseTooLarge indicates an ID token or UserInfo response + // exceeded Config.Limits.MaxJOSECompactBytes — distinct from + // ErrorInvalidResponse because the artifact wasn't malformed, it was + // simply larger than configured; PublicDescription names which + // artifact, and the wrapped cause (Unwrap) carries the observed and + // allowed byte counts for logs. + ErrorResponseTooLarge ErrorCode = "response_too_large" ) // Error is the error type every public Client method returns. Code and diff --git a/client/exchange_code.go b/client/exchange_code.go index a8fb5ed..a8a4d59 100644 --- a/client/exchange_code.go +++ b/client/exchange_code.go @@ -5,6 +5,7 @@ import ( "context" "crypto" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -14,6 +15,7 @@ import ( fapi "github.com/idfoundry/fapigo" "github.com/idfoundry/fapigo/internal/clientassertion" "github.com/idfoundry/fapigo/internal/dpop" + "github.com/idfoundry/fapigo/internal/jose" "github.com/idfoundry/fapigo/internal/jwe" "github.com/idfoundry/fapigo/internal/par" "github.com/idfoundry/fapigo/internal/token" @@ -298,12 +300,16 @@ func (c *Client) validateIDToken(ctx context.Context, raw, nonce string) (token. func (c *Client) decryptIDToken(ctx context.Context, raw string) (string, *Error) { unwrapper := decrypterUnwrapper{decrypter: c.deps.Decryption, purpose: keys.IDTokenDecryption} result, err := jwe.Decrypt(ctx, jwe.DecryptRequest{ - Algorithm: c.cfg.Algorithms.IDTokenKeyManagement, - Encryption: c.cfg.Algorithms.IDTokenContentEncryption, - RecipientKey: unwrapper, - Compact: raw, + Algorithm: c.cfg.Algorithms.IDTokenKeyManagement, + Encryption: c.cfg.Algorithms.IDTokenContentEncryption, + RecipientKey: unwrapper, + Compact: raw, + MaxCompactBytes: c.cfg.Limits.MaxJOSECompactBytes, }) if err != nil { + if errors.Is(err, jwe.ErrTooLarge) { + return "", newError(ErrorResponseTooLarge, "ID token exceeds the configured size limit", err) + } return "", newError(ErrorInvalidResponse, "ID token decryption failed", err) } // RFC 7519 §5.2 requires a producer of a nested JWT to set cty to @@ -340,8 +346,11 @@ func isAcceptableNestedJWTContentType(cty string) bool { // either one that arrived that way directly, or the inner JWT // decryptIDToken recovered from an encrypted one. func (c *Client) validateSignedIDToken(ctx context.Context, raw, nonce string) (token.ValidatedIDToken, *Error) { - parsed, err := token.ParseIDToken(raw) + parsed, err := token.ParseIDTokenMax(raw, c.cfg.Limits.MaxJOSECompactBytes) if err != nil { + if errors.Is(err, jose.ErrTooLarge) { + return token.ValidatedIDToken{}, newError(ErrorResponseTooLarge, "ID token exceeds the configured size limit", err) + } return token.ValidatedIDToken{}, newError(ErrorInvalidResponse, "malformed ID token", err) } diff --git a/client/idtoken_internal_test.go b/client/idtoken_internal_test.go index cd80126..1e5934d 100644 --- a/client/idtoken_internal_test.go +++ b/client/idtoken_internal_test.go @@ -74,7 +74,7 @@ func idTokenTestClient(t *testing.T, now time.Time, algorithms Algorithms, decry Issuer: issuer, ClientID: fapi.ClientID(idTokenTestClientID), Algorithms: algorithms, - Limits: Limits{MaxIDTokenLifetime: 5 * time.Minute, MaxClockSkew: 5 * time.Second}, + Limits: Limits{MaxIDTokenLifetime: 5 * time.Minute, MaxClockSkew: 5 * time.Second, MaxJOSECompactBytes: 16 * 1024}, }, deps: Dependencies{ IssuerKeys: fakeIDTokenIssuerKeys{pub: &idKey.PublicKey}, @@ -100,7 +100,7 @@ func idTokenTestClientWithIssuerKeys(t *testing.T, now time.Time, algorithms Alg Issuer: issuer, ClientID: fapi.ClientID(idTokenTestClientID), Algorithms: algorithms, - Limits: Limits{MaxIDTokenLifetime: 5 * time.Minute, MaxClockSkew: 5 * time.Second}, + Limits: Limits{MaxIDTokenLifetime: 5 * time.Minute, MaxClockSkew: 5 * time.Second, MaxJOSECompactBytes: 16 * 1024}, }, deps: Dependencies{ IssuerKeys: issuerKeys, diff --git a/client/issuerjws.go b/client/issuerjws.go index c04c596..edbb37c 100644 --- a/client/issuerjws.go +++ b/client/issuerjws.go @@ -2,6 +2,7 @@ package client import ( "context" + "errors" "github.com/idfoundry/fapigo/internal/jose" "github.com/idfoundry/fapigo/keys" @@ -21,8 +22,11 @@ import ( // caller still owns checking whatever claims that payload carries (iss, // aud, sub, expiry, ...) against its own policy. func (c *Client) VerifyIssuerJWS(ctx context.Context, compactJWS string) ([]byte, error) { - parsed, err := jose.ParseCompact(compactJWS) + parsed, err := jose.ParseCompactMax(compactJWS, c.cfg.Limits.MaxJOSECompactBytes) if err != nil { + if errors.Is(err, jose.ErrTooLarge) { + return nil, newError(ErrorResponseTooLarge, "JWS exceeds the configured size limit", err) + } return nil, newError(ErrorInvalidResponse, "malformed JWS", err) } diff --git a/client/issuerjws_internal_test.go b/client/issuerjws_internal_test.go index 5a84185..789a410 100644 --- a/client/issuerjws_internal_test.go +++ b/client/issuerjws_internal_test.go @@ -49,6 +49,7 @@ func issuerJWSTestClient(t *testing.T, issuerKeys keys.IssuerKeySource) *Client Issuer: issuer, ClientID: fapi.ClientID(idTokenTestClientID), Algorithms: Algorithms{UserInfo: fapi.ES256}, + Limits: Limits{MaxJOSECompactBytes: 16 * 1024}, }, deps: Dependencies{IssuerKeys: issuerKeys}, } diff --git a/client/presets.go b/client/presets.go index 61aa5e3..0223d32 100644 --- a/client/presets.go +++ b/client/presets.go @@ -4,6 +4,7 @@ import ( "time" fapi "github.com/idfoundry/fapigo" + "github.com/idfoundry/fapigo/internal/jose" ) // RecommendedLimits returns a Limits value grounded the same way @@ -62,6 +63,16 @@ func RecommendedLimits() Limits { // small JSON documents; 1 MiB is generous headroom without // being an effectively unbounded read. MaxHTTPResponseBytes: 1 << 20, + + // Not spec-mandated — the same 16 KiB this module's own JOSE + // package uses as its default ceiling for a fixed-shape + // artifact (jose.DefaultMaxCompactBytes), carried over here as + // a starting point for an ID token or UserInfo response too. A + // deployment whose issuer is configured to return many claims + // (verified/assured identity claims, in particular, can run + // well past this) should raise it explicitly rather than rely + // on this default. + MaxJOSECompactBytes: jose.DefaultMaxCompactBytes, } } diff --git a/client/userinfo.go b/client/userinfo.go index 4714921..97701d6 100644 --- a/client/userinfo.go +++ b/client/userinfo.go @@ -3,6 +3,7 @@ package client import ( "context" "encoding/json" + "errors" "fmt" "io" "mime" @@ -149,12 +150,16 @@ func (c *Client) decodeUserInfoJWT(ctx context.Context, raw string, expectEncryp func (c *Client) decryptUserInfoJWE(ctx context.Context, raw string) (string, *Error) { unwrapper := decrypterUnwrapper{decrypter: c.deps.Decryption, purpose: keys.UserInfoDecryption} result, err := jwe.Decrypt(ctx, jwe.DecryptRequest{ - Algorithm: c.cfg.Algorithms.UserInfoKeyManagement, - Encryption: c.cfg.Algorithms.UserInfoContentEncryption, - RecipientKey: unwrapper, - Compact: raw, + Algorithm: c.cfg.Algorithms.UserInfoKeyManagement, + Encryption: c.cfg.Algorithms.UserInfoContentEncryption, + RecipientKey: unwrapper, + Compact: raw, + MaxCompactBytes: c.cfg.Limits.MaxJOSECompactBytes, }) if err != nil { + if errors.Is(err, jwe.ErrTooLarge) { + return "", newError(ErrorResponseTooLarge, "UserInfo response exceeds the configured size limit", err) + } return "", newError(ErrorInvalidResponse, "UserInfo response decryption failed", err) } if !isAcceptableNestedJWTContentType(result.Header.ContentType) { diff --git a/client/userinfo_test.go b/client/userinfo_test.go index 4dd64d5..33b2ed6 100644 --- a/client/userinfo_test.go +++ b/client/userinfo_test.go @@ -7,6 +7,7 @@ import ( "crypto/elliptic" "crypto/rand" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -126,6 +127,36 @@ func TestFetchUserInfoVerifiesSignedJWTResponse(t *testing.T) { } } +func TestFetchUserInfoRejectsSignedJWTOverConfiguredSizeLimit(t *testing.T) { + userInfoKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate userinfo key: %v", err) + } + // A large claim value stands in for a claims-heavy response (many + // granted scopes/claims) that legitimately exceeds a small + // configured MaxJOSECompactBytes, distinct from a malformed one. + bigValue := make([]byte, 4096) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + compact := signUserInfoJWS(t, userInfoKey, map[string]any{"sub": userInfoTestSubject, "big": string(bigValue)}) + w.Header().Set("Content-Type", "application/jwt") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(compact)) + })) + defer ts.Close() + + c := newUserInfoTestClient(t, ts, &userInfoKey.PublicKey, func(cfg *client.Config) { + cfg.Limits.MaxJOSECompactBytes = 1024 + }, nil) + _, err = c.FetchUserInfo(context.Background(), userInfoTestTokens()) + var cerr *client.Error + if !errors.As(err, &cerr) { + t.Fatalf("FetchUserInfo error = %v, want *client.Error", err) + } + if cerr.Code() != client.ErrorResponseTooLarge { + t.Fatalf("FetchUserInfo error code = %q, want %q", cerr.Code(), client.ErrorResponseTooLarge) + } +} + func TestFetchUserInfoRejectsSignedJWTWithWrongKey(t *testing.T) { signingKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { diff --git a/cmd/conformance-as/smoke_test.go b/cmd/conformance-as/smoke_test.go index e8b91fa..acb7239 100644 --- a/cmd/conformance-as/smoke_test.go +++ b/cmd/conformance-as/smoke_test.go @@ -244,6 +244,7 @@ func newSmokeHarness(t *testing.T, format AccessTokenFormat) *smokeHarness { MaxClockSkew: 10 * time.Second, HTTPTimeout: 10 * time.Second, MaxHTTPResponseBytes: 1 << 16, + MaxJOSECompactBytes: 16 * 1024, }, } clientDeps := client.Dependencies{ diff --git a/cmd/conformance-client/main.go b/cmd/conformance-client/main.go index fbe3c39..7a2a3c5 100644 --- a/cmd/conformance-client/main.go +++ b/cmd/conformance-client/main.go @@ -285,6 +285,7 @@ func runModule(ctx context.Context, d moduleDriver, testName string) string { MaxClockSkew: 15 * time.Second, HTTPTimeout: fetchTimeout, MaxHTTPResponseBytes: 1 << 20, + MaxJOSECompactBytes: 16 * 1024, } if profile.signRequestObject { // This driver's key manager only ever generates ES256 keys, so it diff --git a/fapitest/discover_test.go b/fapitest/discover_test.go index ba51adc..8504b39 100644 --- a/fapitest/discover_test.go +++ b/fapitest/discover_test.go @@ -135,6 +135,7 @@ func TestDiscoverEndToEnd(t *testing.T) { ClientAssertionLifetime: time.Minute, SessionLifetime: 5 * time.Minute, MaxIDTokenLifetime: 5 * time.Minute, MaxClockSkew: 5 * time.Second, HTTPTimeout: 5 * time.Second, MaxHTTPResponseBytes: 1 << 16, + MaxJOSECompactBytes: 16 * 1024, }, } clientDeps := client.Dependencies{ diff --git a/fapitest/harness.go b/fapitest/harness.go index 44eb68a..2698e9f 100644 --- a/fapitest/harness.go +++ b/fapitest/harness.go @@ -257,6 +257,7 @@ func New(t *testing.T, cfg Config) *Harness { MaxClockSkew: 5 * time.Second, HTTPTimeout: 5 * time.Second, MaxHTTPResponseBytes: 1 << 16, + MaxJOSECompactBytes: 16 * 1024, }, } if cfg.EncryptIDTokens { diff --git a/internal/jose/compact.go b/internal/jose/compact.go index 66a0321..8c4c77f 100644 --- a/internal/jose/compact.go +++ b/internal/jose/compact.go @@ -10,10 +10,15 @@ import ( fapi "github.com/idfoundry/fapigo" ) -// maxCompactBytes bounds how large a compact serialization this package -// will attempt to parse, to avoid doing unbounded work on attacker- -// supplied input before any signature has been checked. -const maxCompactBytes = 16 * 1024 +// DefaultMaxCompactBytes bounds how large a compact serialization +// ParseCompact will attempt to parse, to avoid doing unbounded work on +// attacker-supplied input before any signature has been checked. It's +// sized for a JWS whose claims are fixed in shape and count (a DPoP +// proof, a client assertion, a request object) — a caller whose +// accepted input can legitimately scale beyond that (an issuer +// response shaped by however many scopes/claims a deployment grants) +// should call ParseCompactMax with its own configured ceiling instead. +const DefaultMaxCompactBytes = 16 * 1024 // Sign produces a JWS compact serialization: // BASE64URL(header) || "." || BASE64URL(payload) || "." || BASE64URL(signature). @@ -75,10 +80,16 @@ type Compact struct { } // ParseCompact splits and decodes a compact JWS without verifying its -// signature. +// signature, rejecting one longer than DefaultMaxCompactBytes. func ParseCompact(s string) (Compact, error) { - if len(s) > maxCompactBytes { - return Compact{}, ErrTooLarge + return ParseCompactMax(s, DefaultMaxCompactBytes) +} + +// ParseCompactMax is ParseCompact with an explicit size ceiling, in +// bytes, instead of DefaultMaxCompactBytes. +func ParseCompactMax(s string, maxBytes int) (Compact, error) { + if len(s) > maxBytes { + return Compact{}, fmt.Errorf("%w: %d bytes exceeds the %d byte limit", ErrTooLarge, len(s), maxBytes) } parts := strings.Split(s, ".") if len(parts) != 3 { diff --git a/internal/jose/compact_test.go b/internal/jose/compact_test.go index 24d1393..fbadf61 100644 --- a/internal/jose/compact_test.go +++ b/internal/jose/compact_test.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "crypto/rsa" "encoding/base64" + "errors" "strings" "testing" @@ -247,8 +248,18 @@ func TestParseCompactRejectsMalformed(t *testing.T) { } func TestParseCompactRejectsOversized(t *testing.T) { - huge := strings.Repeat("a", maxCompactBytes+1) + huge := strings.Repeat("a", DefaultMaxCompactBytes+1) if _, err := ParseCompact(huge); err == nil { t.Fatalf("ParseCompact(oversized) = nil error, want error") } + if _, err := ParseCompact(huge); !errors.Is(err, ErrTooLarge) { + t.Fatalf("ParseCompact(oversized) error = %v, want ErrTooLarge", err) + } +} + +func TestParseCompactMaxRespectsExplicitLimit(t *testing.T) { + tooLarge := strings.Repeat("a", 101) + if _, err := ParseCompactMax(tooLarge, 100); !errors.Is(err, ErrTooLarge) { + t.Fatalf("ParseCompactMax(101 bytes, max 100) error = %v, want ErrTooLarge", err) + } } diff --git a/internal/jwe/errors.go b/internal/jwe/errors.go index a65713c..c4c8a90 100644 --- a/internal/jwe/errors.go +++ b/internal/jwe/errors.go @@ -18,4 +18,8 @@ var ( // undifferentiated: which specific step failed is not safe to expose // to a caller processing untrusted input. ErrDecryptionFailed = errors.New("jwe: decryption failed") + + // ErrTooLarge indicates a compact serialization larger than + // DecryptRequest.MaxCompactBytes. + ErrTooLarge = errors.New("jwe: compact serialization exceeds maximum size") ) diff --git a/internal/jwe/jwe.go b/internal/jwe/jwe.go index 5101f59..9733bfa 100644 --- a/internal/jwe/jwe.go +++ b/internal/jwe/jwe.go @@ -278,6 +278,14 @@ type DecryptRequest struct { RecipientKey any Compact string + + // MaxCompactBytes bounds how large Compact may be before Decrypt + // rejects it with ErrTooLarge, checked before any parsing or + // decryption work. Zero (or negative) means no limit is enforced + // here — a caller relying on that must already bound Compact's size + // some other way (e.g. a capped HTTP response read) before it + // reaches Decrypt. + MaxCompactBytes int } // Unwrapper delivers the content-encryption key for one JWE without @@ -314,6 +322,9 @@ func Decrypt(ctx context.Context, req DecryptRequest) (DecryptResult, error) { if !req.Encryption.IsValid() { return DecryptResult{}, fmt.Errorf("jwe: invalid content encryption algorithm %v", req.Encryption) } + if req.MaxCompactBytes > 0 && len(req.Compact) > req.MaxCompactBytes { + return DecryptResult{}, fmt.Errorf("%w: %d bytes exceeds the %d byte limit", ErrTooLarge, len(req.Compact), req.MaxCompactBytes) + } parts := strings.Split(req.Compact, ".") if len(parts) != 5 { diff --git a/internal/jwe/jwe_test.go b/internal/jwe/jwe_test.go index 9ebb988..9c76d40 100644 --- a/internal/jwe/jwe_test.go +++ b/internal/jwe/jwe_test.go @@ -201,6 +201,35 @@ func TestDecryptRejectsContentEncryptionMismatch(t *testing.T) { } } +// TestDecryptRejectsOversizedCompact confirms MaxCompactBytes is +// enforced before any parsing or decryption work happens, and that a +// zero MaxCompactBytes (the field's default) leaves Decrypt unbounded, +// for a caller that already bounds Compact's size some other way. +func TestDecryptRejectsOversizedCompact(t *testing.T) { + priv := generateRSAKey(t) + compact, err := Encrypt(EncryptRequest{ + Algorithm: fapi.RSAOAEP256, Encryption: fapi.A256GCM, + RecipientKey: &priv.PublicKey, Plaintext: []byte("secret"), + }) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + if _, err := Decrypt(context.Background(), DecryptRequest{ + Algorithm: fapi.RSAOAEP256, Encryption: fapi.A256GCM, + RecipientKey: priv, Compact: compact, MaxCompactBytes: len(compact) - 1, + }); !errors.Is(err, ErrTooLarge) { + t.Fatalf("Decrypt(over limit) = %v, want ErrTooLarge", err) + } + + if _, err := Decrypt(context.Background(), DecryptRequest{ + Algorithm: fapi.RSAOAEP256, Encryption: fapi.A256GCM, + RecipientKey: priv, Compact: compact, MaxCompactBytes: 0, + }); err != nil { + t.Fatalf("Decrypt(zero MaxCompactBytes) = %v, want no error", err) + } +} + // TestDecryptRejectsTamperedTagA256CBCHS512 mirrors // TestDecryptRejectsTamperedTag for the CBC-HMAC family, where tag // verification is this package's own code (cbcHMACTag/hmac.Equal), not diff --git a/internal/token/validate.go b/internal/token/validate.go index ce8767c..51dc45b 100644 --- a/internal/token/validate.go +++ b/internal/token/validate.go @@ -177,9 +177,18 @@ type IDToken struct { claims IDTokenClaims } -// ParseIDToken parses an ID token without verifying its signature. +// ParseIDToken parses an ID token without verifying its signature, +// rejecting one longer than jose.DefaultMaxCompactBytes. func ParseIDToken(tok string) (IDToken, error) { - compact, err := jose.ParseCompact(tok) + return ParseIDTokenMax(tok, jose.DefaultMaxCompactBytes) +} + +// ParseIDTokenMax is ParseIDToken with an explicit size ceiling, in +// bytes, instead of jose.DefaultMaxCompactBytes — for a caller whose +// issuer may legitimately return an ID token shaped by however many +// scopes/claims it granted, rather than a fixed handful. +func ParseIDTokenMax(tok string, maxBytes int) (IDToken, error) { + compact, err := jose.ParseCompactMax(tok, maxBytes) if err != nil { return IDToken{}, fmt.Errorf("token: %w", err) }