Skip to content
Draft
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
2 changes: 1 addition & 1 deletion access_error.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down
2 changes: 1 addition & 1 deletion access_error_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite_test
Expand Down
2 changes: 1 addition & 1 deletion access_request.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down
8 changes: 7 additions & 1 deletion access_request_handler.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down Expand Up @@ -65,6 +65,12 @@ func (f *Fosite) NewAccessRequest(ctx context.Context, r *http.Request, session
}

accessRequest.SetRequestedScopes(RemoveEmpty(strings.Split(r.PostForm.Get("scope"), " ")))
// RFC 8707: validate the shape of any "resource" parameter values (absolute URI,
// no fragment) before they are merged into the audience list by GetAudiences.
// If "resource" is absent, ValidateResourceIndicators is a no-op.
if _, err := ValidateResourceIndicators(r.PostForm); err != nil {
return accessRequest, err
}
accessRequest.SetRequestedAudience(GetAudiences(r.PostForm))
accessRequest.GrantTypes = RemoveEmpty(strings.Split(r.PostForm.Get("grant_type"), " "))
if len(accessRequest.GrantTypes) < 1 {
Expand Down
104 changes: 103 additions & 1 deletion access_request_handler_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite_test

import (
"context"
"encoding/base64"
"fmt"
"net/http"
Expand Down Expand Up @@ -446,3 +447,104 @@ func TestNewAccessRequestWithMixedClientAuth(t *testing.T) {
func basicAuth(username, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
}

// TestNewAccessRequest_RFC8707Resource exercises the RFC 8707 "resource"
// parameter through the full NewAccessRequest pipeline. It verifies that:
// 1. A valid "resource" parameter is parsed and surfaced on the request via
// GetRequestedAudience(), so downstream handlers can bind the audience.
// 2. An invalid "resource" parameter (relative URI, fragment) is rejected
// before client authentication or handler dispatch.
// 3. Existing "audience"-only requests are unaffected (backward compatibility).
func TestNewAccessRequest_RFC8707Resource(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
store := internal.NewMockStorage(ctrl)
handler := internal.NewMockTokenEndpointHandler(ctrl)
handler.EXPECT().CanHandleTokenEndpointRequest(gomock.Any(), gomock.Any()).Return(true).AnyTimes()
handler.EXPECT().CanSkipClientAuth(gomock.Any(), gomock.Any()).Return(true).AnyTimes()
hasher := internal.NewMockHasher(ctrl)

config := &Config{
ClientSecretsHasher: hasher,
AudienceMatchingStrategy: DefaultAudienceMatchingStrategy,
TokenEndpointHandlers: TokenEndpointHandlers{handler},
}
f := &Fosite{Store: store, Config: config}

for _, tc := range []struct {
name string
form url.Values
wantErr bool
wantAudience []string
}{
{
name: "resource only, single valid URI",
form: url.Values{
"grant_type": {"foo"},
"resource": {"https://mcp.example.com"},
},
wantAudience: []string{"https://mcp.example.com"},
},
{
name: "resource only, multiple valid URIs",
form: url.Values{
"grant_type": {"foo"},
"resource": {"https://a.example.com", "https://b.example.com"},
},
wantAudience: []string{"https://a.example.com", "https://b.example.com"},
},
{
name: "audience and resource merged",
form: url.Values{
"grant_type": {"foo"},
"audience": {"https://aud.example.com"},
"resource": {"https://res.example.com"},
},
wantAudience: []string{"https://aud.example.com", "https://res.example.com"},
},
{
name: "audience only is unchanged (backward compat)",
form: url.Values{
"grant_type": {"foo"},
"audience": {"https://aud.example.com"},
},
wantAudience: []string{"https://aud.example.com"},
},
{
name: "invalid resource (relative URI) is rejected",
form: url.Values{
"grant_type": {"foo"},
"resource": {"/relative/path"},
},
wantErr: true,
},
{
name: "invalid resource (fragment) is rejected",
form: url.Values{
"grant_type": {"foo"},
"resource": {"https://mcp.example.com/api#section"},
},
wantErr: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
if !tc.wantErr {
handler.EXPECT().HandleTokenEndpointRequest(gomock.Any(), gomock.Any()).Return(nil).Times(1)
}

r := &http.Request{
Header: http.Header{},
PostForm: tc.form,
Form: tc.form,
Method: "POST",
}
ar, err := f.NewAccessRequest(context.Background(), r, new(DefaultSession))
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantAudience, []string(ar.GetRequestedAudience()))
})
}
}
2 changes: 1 addition & 1 deletion access_request_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down
2 changes: 1 addition & 1 deletion access_response.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down
2 changes: 1 addition & 1 deletion access_response_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite_test
Expand Down
2 changes: 1 addition & 1 deletion access_response_writer.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down
2 changes: 1 addition & 1 deletion access_response_writer_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite_test
Expand Down
2 changes: 1 addition & 1 deletion access_write.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down
2 changes: 1 addition & 1 deletion access_write_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite_test
Expand Down
2 changes: 1 addition & 1 deletion arguments.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down
2 changes: 1 addition & 1 deletion arguments_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down
110 changes: 101 additions & 9 deletions audience_strategy.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright © 2025 Ory Corp
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package fosite
Expand Down Expand Up @@ -81,19 +81,111 @@ func ExactAudienceMatchingStrategy(haystack []string, needle []string) error {
// query parameters, while RFC 6749 says that that request parameter must not be included
// more than once (and thus why we use space-delimited value). This function tries to satisfy both.
// If "audience" form parameter is repeated, we do not split the value by space.
//
// In addition, this function reads the RFC 8707 "resource" parameter
// (https://datatracker.ietf.org/doc/html/rfc8707#section-2). Per the spec, "resource"
// is a repeatable parameter; each value is treated as an audience indicator and is
// merged with any values supplied through the "audience" parameter. Unlike "audience",
// the "resource" parameter values are NOT space-split — RFC 8707 mandates one URI per
// parameter occurrence. Duplicate values across "audience" and "resource" are
// de-duplicated while preserving the order of first appearance ("audience" values
// first, then "resource" values).
//
// Note: this function does not validate that "resource" values are absolute URIs
// without a fragment as required by RFC 8707 §2 — that validation is performed by
// ValidateResourceIndicators, which callers invoke after parsing.
func GetAudiences(form url.Values) []string {
audiences := form["audience"]
if len(audiences) > 1 {
return RemoveEmpty(audiences)
} else if len(audiences) == 1 {
return RemoveEmpty(strings.Split(audiences[0], " "))
} else {
return []string{}
var audiences []string
formAudiences := form["audience"]
if len(formAudiences) > 1 {
audiences = RemoveEmpty(formAudiences)
} else if len(formAudiences) == 1 {
audiences = RemoveEmpty(strings.Split(formAudiences[0], " "))
}

resources := RemoveEmpty(form["resource"])
if len(resources) == 0 {
if audiences == nil {
return []string{}
}
return audiences
}

// Merge audience and resource values, preserving order and de-duplicating.
seen := make(map[string]struct{}, len(audiences)+len(resources))
merged := make([]string, 0, len(audiences)+len(resources))
for _, v := range audiences {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
merged = append(merged, v)
}
for _, v := range resources {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
merged = append(merged, v)
}
return merged
}

// ValidateResourceIndicators validates that every value of the RFC 8707 "resource"
// form parameter is an absolute URI without a fragment, as required by RFC 8707 §2:
//
// The "resource" parameter URI value MUST NOT include a fragment component.
// ... It MUST be an absolute URI as specified by Section 4.3 of [RFC3986].
//
// On success the validated resource values are returned. If the form contains no
// "resource" parameter, the function returns (nil, nil) — RFC 8707 is opt-in.
//
// Callers (e.g. the access and authorize request pipelines) should invoke this
// function after parsing the form and reject the request with ErrInvalidTarget
// (RFC 8707 §3, the "invalid_target" error code) when validation fails. Because
// fosite does not yet ship a dedicated "invalid_target" error, ErrInvalidRequest
// is the closest fit in current callers; this function returns a plain error so
// the caller can wrap it appropriately.
func ValidateResourceIndicators(form url.Values) ([]string, error) {
resources, ok := form["resource"]
if !ok {
return nil, nil
}
resources = RemoveEmpty(resources)
if len(resources) == 0 {
return nil, nil
}

for _, r := range resources {
u, err := url.Parse(r)
if err != nil {
return nil, errorsx.WithStack(ErrInvalidRequest.
WithHintf("Unable to parse 'resource' parameter value '%s'.", r).
WithWrap(err).WithDebug(err.Error()))
}
if !u.IsAbs() {
return nil, errorsx.WithStack(ErrInvalidRequest.
WithHintf("The 'resource' parameter value '%s' must be an absolute URI as per RFC 8707.", r))
}
if u.Fragment != "" || strings.Contains(r, "#") {
return nil, errorsx.WithStack(ErrInvalidRequest.
WithHintf("The 'resource' parameter value '%s' must not contain a fragment component as per RFC 8707.", r))
}
}
return resources, nil
}

func (f *Fosite) validateAudience(ctx context.Context, r *http.Request, request Requester) error {
audience := GetAudiences(request.GetRequestForm())
form := request.GetRequestForm()

// RFC 8707: validate "resource" parameter shape (absolute URI, no fragment)
// before merging into the audience list. Validation runs unconditionally — if
// the parameter is absent, ValidateResourceIndicators is a no-op.
if _, err := ValidateResourceIndicators(form); err != nil {
return err
}

audience := GetAudiences(form)

if err := f.Config.GetAudienceStrategy(ctx)(request.GetClient().GetAudience(), audience); err != nil {
return err
Expand Down
Loading
Loading