From 19589af59a350678b859d9ed177081e79caa41cf Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Wed, 1 Jul 2026 14:34:43 +0200 Subject: [PATCH 1/3] Accept SSO JWT audiences with or without a trailing slash --- CHANGELOG.md | 2 ++ pkg/auth/auth.go | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 248da9c6e..ec22e4826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ### Bug Fixes: +- fix(auth): accept SSO JWT audiences with or without a trailing slash when validating the Fastly API endpoint. + ### Enhancements: ### Dependencies: diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index b640f5eef..43f4fcae3 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -283,8 +283,8 @@ func (s *Server) ValidateAndRetrieveAPIToken(accessToken string) (string, *APITo return "", nil, errors.New("failed to extract aud from JWT claims") } - if aud != s.APIEndpoint { - return "", nil, fmt.Errorf("failed to match expected aud: %s", s.APIEndpoint) + if err := validateAudienceClaim(aud, s.APIEndpoint); err != nil { + return "", nil, err } email, ok := claims["email"] @@ -305,6 +305,24 @@ func (s *Server) ValidateAndRetrieveAPIToken(accessToken string) (string, *APITo return e, at, nil } +func validateAudienceClaim(aud any, apiEndpoint string) error { + audString, ok := aud.(string) + if !ok { + return fmt.Errorf("failed to type assert 'aud' (%#v) to a string", aud) + } + + if !audienceMatchesAPIEndpoint(audString, apiEndpoint) { + return fmt.Errorf("failed to match expected aud %q, got %q", apiEndpoint, audString) + } + return nil +} + +func audienceMatchesAPIEndpoint(aud, apiEndpoint string) bool { + // The auth provider has returned audiences with and without a trailing slash + // for the same API endpoint. Accept either form for compatibility. + return strings.TrimSuffix(aud, "/") == strings.TrimSuffix(apiEndpoint, "/") +} + // VerifyJWTSignature calls the jwks_uri endpoint and extracts its claims. func (s *Server) VerifyJWTSignature(accessToken string) (claims map[string]any, err error) { ctx := context.Background() From 14afcd80ad84cdff0f9491a611401ce2e60943f4 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Wed, 1 Jul 2026 15:01:58 +0200 Subject: [PATCH 2/3] Add PR link --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec22e4826..02b7bdd71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Bug Fixes: -- fix(auth): accept SSO JWT audiences with or without a trailing slash when validating the Fastly API endpoint. +- fix(auth): accept SSO JWT audiences with or without a trailing slash when validating the Fastly API endpoint. ([#1837](https://github.com/fastly/cli/pull/1837)) ### Enhancements: From 8d6466d6df76574838bf5340a0d261a92870bb49 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Wed, 1 Jul 2026 17:14:02 +0200 Subject: [PATCH 3/3] Add JWT relaxiation test --- pkg/auth/auth_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 pkg/auth/auth_test.go diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 000000000..8a7d7798a --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,100 @@ +package auth + +import "testing" + +func TestValidateAudienceClaim(t *testing.T) { + t.Parallel() + + const apiEndpoint = "https://api.fastly.com" + + tests := []struct { + name string + aud any + wantErr bool + }{ + { + name: "exact match", + aud: apiEndpoint, + }, + { + name: "trailing slash match", + aud: apiEndpoint + "/", + }, + { + name: "different audience", + aud: "https://api.example.com/", + wantErr: true, + }, + { + name: "non string audience", + aud: []string{apiEndpoint}, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateAudienceClaim(tt.aud, apiEndpoint) + if (err != nil) != tt.wantErr { + t.Fatalf("validateAudienceClaim() error = %v, wantErr %t", err, tt.wantErr) + } + }) + } +} + +func TestAudienceMatchesAPIEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + aud string + apiEndpoint string + want bool + }{ + { + name: "exact match without trailing slash", + aud: "https://api.fastly.com", + apiEndpoint: "https://api.fastly.com", + want: true, + }, + { + name: "audience trailing slash accepted", + aud: "https://api.fastly.com/", + apiEndpoint: "https://api.fastly.com", + want: true, + }, + { + name: "configured endpoint trailing slash accepted", + aud: "https://api.fastly.com", + apiEndpoint: "https://api.fastly.com/", + want: true, + }, + { + name: "double trailing slash rejected", + aud: "https://api.fastly.com//", + apiEndpoint: "https://api.fastly.com", + want: false, + }, + { + name: "different audience rejected", + aud: "https://example.com/", + apiEndpoint: "https://api.fastly.com", + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := audienceMatchesAPIEndpoint(tt.aud, tt.apiEndpoint) + if got != tt.want { + t.Fatalf("audienceMatchesAPIEndpoint(%q, %q) = %t, want %t", tt.aud, tt.apiEndpoint, got, tt.want) + } + }) + } +}