diff --git a/pkg/authserver/server/handlers/token.go b/pkg/authserver/server/handlers/token.go index 6fc5865cff..05478cb6dd 100644 --- a/pkg/authserver/server/handlers/token.go +++ b/pkg/authserver/server/handlers/token.go @@ -49,7 +49,7 @@ func (h *Handler) TokenHandler(w http.ResponseWriter, req *http.Request) { server.ErrInvalidTarget.WithHint("Multiple resource parameters are not supported")) return } - if len(resources) == 1 { + if len(resources) == 1 && resources[0] != "" { resource := resources[0] // Validate URI format per RFC 8707 if err := server.ValidateAudienceURI(resource); err != nil { @@ -75,6 +75,17 @@ func (h *Handler) TokenHandler(w http.ResponseWriter, req *http.Request) { "resource", resource, ) accessRequest.GrantAudience(resource) + } else if accessRequest.GetGrantTypes().ExactOne("authorization_code") && len(h.config.AllowedAudiences) == 1 { + // No resource parameter provided (or provided as empty) during an authorization_code + // exchange; default to the sole allowed audience. The len == 1 guard makes the + // intended audience unambiguous and the index access safe. We restrict this defaulting + // to authorization_code grants: for refresh_token grants, fosite already carries the + // originally-granted audience forward through the session, so re-granting here would + // conflict with fosite's audience matching strategy. + slog.Debug("no resource parameter, defaulting to sole allowed audience", + "audience", h.config.AllowedAudiences[0], + ) + accessRequest.GrantAudience(h.config.AllowedAudiences[0]) } // Generate the access response (tokens) diff --git a/pkg/authserver/server/handlers/token_test.go b/pkg/authserver/server/handlers/token_test.go index 84cee335ca..df4e9b7c9c 100644 --- a/pkg/authserver/server/handlers/token_test.go +++ b/pkg/authserver/server/handlers/token_test.go @@ -4,6 +4,7 @@ package handlers import ( + "encoding/json" "net/http" "net/http/httptest" "net/url" @@ -11,6 +12,8 @@ import ( "testing" "time" + "github.com/go-jose/go-jose/v4" + josejwt "github.com/go-jose/go-jose/v4/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -173,35 +176,78 @@ func TestTokenHandler_Success(t *testing.T) { assert.Contains(t, body, "expires_in") } -func TestTokenHandler_ResourceParameter(t *testing.T) { +func TestTokenHandler_AudienceClaim(t *testing.T) { t.Parallel() - handler, storState, _ := handlerTestSetup(t) - - // Simulate authorize flow - authorizeCode := simulateAuthorizeFlow(t, handler, storState) - // Exchange code with RFC 8707 resource parameter - form := url.Values{ - "grant_type": {"authorization_code"}, - "client_id": {testAuthClientID}, - "redirect_uri": {testAuthRedirectURI}, - "code": {authorizeCode}, - "code_verifier": {testPKCEVerifier}, - "resource": {"https://api.example.com"}, + // ptr is a helper to take the address of a string literal. + ptr := func(s string) *string { return &s } + + tests := []struct { + name string + resource *string // nil = omit parameter; non-nil = include (possibly empty) + wantAud string + }{ + { + name: "explicit resource grants matching audience", + resource: ptr("https://api.example.com"), + wantAud: "https://api.example.com", + }, + { + name: "absent resource defaults to sole AllowedAudience", + resource: nil, + wantAud: "https://api.example.com", + }, + { + name: "explicit empty resource defaults to sole AllowedAudience", + resource: ptr(""), + wantAud: "https://api.example.com", + }, } - req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rec := httptest.NewRecorder() - handler.TokenHandler(rec, req) - - require.Equal(t, http.StatusOK, rec.Code, "expected 200 OK, got %d: %s", rec.Code, rec.Body.String()) - - // The resource parameter should be granted as audience in the JWT - // We can't easily verify the JWT contents here without decoding, - // but we verify the request succeeded - body := rec.Body.String() - assert.Contains(t, body, "access_token") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + handler, storState, _ := handlerTestSetup(t) + authorizeCode := simulateAuthorizeFlow(t, handler, storState) + + form := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {testAuthClientID}, + "redirect_uri": {testAuthRedirectURI}, + "code": {authorizeCode}, + "code_verifier": {testPKCEVerifier}, + } + if tc.resource != nil { + form.Set("resource", *tc.resource) + } + + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + handler.TokenHandler(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "got %d: %s", rec.Code, rec.Body.String()) + + var tokenResp map[string]any + require.NoError(t, json.NewDecoder(rec.Body).Decode(&tokenResp)) + + accessToken, ok := tokenResp["access_token"].(string) + require.True(t, ok, "access_token should be a string") + require.NotEmpty(t, accessToken) + + parsedToken, err := josejwt.ParseSigned(accessToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + + var claims map[string]any + require.NoError(t, parsedToken.UnsafeClaimsWithoutVerification(&claims)) + + aud, ok := claims["aud"].([]any) + require.True(t, ok, "aud claim should be an array, got: %T %v", claims["aud"], claims["aud"]) + require.Len(t, aud, 1) + assert.Equal(t, tc.wantAud, aud[0]) + }) + } } func TestTokenHandler_RouteRegistered(t *testing.T) {