diff --git a/pkg/oidc/authorization.go b/pkg/oidc/authorization.go index fa37dbfe..17e52d3e 100644 --- a/pkg/oidc/authorization.go +++ b/pkg/oidc/authorization.go @@ -84,6 +84,19 @@ type AuthRequest struct { LoginHint string `json:"login_hint" schema:"login_hint"` ACRValues SpaceDelimitedArray `json:"acr_values" schema:"acr_values"` + // Resource indicates the target service(s) or resource(s) at which the requested + // token is intended to be used, as defined by [RFC 8707]. The parameter may be + // repeated to request a token that is valid at multiple resources. + // + // Each value must be an absolute URI without a fragment component; the op package + // validates the syntax and rejects invalid values with `invalid_target`. Whether a + // resource is acceptable, and how it translates into the audience of the issued + // tokens, is up to the Storage implementation, which receives these values as part + // of the auth request. + // + // [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 + Resource []string `json:"resource" schema:"resource"` + CodeChallenge string `json:"code_challenge" schema:"code_challenge"` CodeChallengeMethod CodeChallengeMethod `json:"code_challenge_method" schema:"code_challenge_method"` diff --git a/pkg/oidc/discovery.go b/pkg/oidc/discovery.go index 11ba8064..fdf2b6bf 100644 --- a/pkg/oidc/discovery.go +++ b/pkg/oidc/discovery.go @@ -126,6 +126,16 @@ type DiscoveryConfiguration struct { // CodeChallengeMethodsSupported contains a list of Proof Key for Code Exchange (PKCE) code challenge methods supported by the OP. CodeChallengeMethodsSupported []CodeChallengeMethod `json:"code_challenge_methods_supported,omitempty"` + // ResourceIndicatorsSupported specifies whether the OP supports the `resource` parameter + // defined by [RFC 8707]. If omitted, the default value is false. + // + // [RFC 8707] does not register a metadata parameter of its own; + // `resource_indicators_supported` is the name authorization servers use by convention + // to advertise the capability. + // + // [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 + ResourceIndicatorsSupported bool `json:"resource_indicators_supported,omitempty"` + // ServiceDocumentation is a URL where developers can get information about the OP and its usage. ServiceDocumentation string `json:"service_documentation,omitempty"` diff --git a/pkg/op/auth_request.go b/pkg/op/auth_request.go index 70984fe7..67d0b3a3 100644 --- a/pkg/op/auth_request.go +++ b/pkg/op/auth_request.go @@ -232,6 +232,9 @@ func CopyRequestObjectToAuthRequest(authReq *oidc.AuthRequest, requestObject *oi if len(requestObject.ACRValues) > 0 { authReq.ACRValues = requestObject.ACRValues } + if len(requestObject.Resource) > 0 { + authReq.Resource = requestObject.Resource + } if requestObject.CodeChallenge != "" { authReq.CodeChallenge = requestObject.CodeChallenge } @@ -275,6 +278,9 @@ func ValidateAuthRequestClient(ctx context.Context, authReq *oidc.AuthRequest, c if err := ValidateAuthReqResponseType(client, authReq.ResponseType); err != nil { return "", err } + if err := ValidateAuthReqResources(authReq.Resource); err != nil { + return "", err + } return ValidateAuthReqIDTokenHint(ctx, authReq.IDTokenHint, verifier) } @@ -311,6 +317,35 @@ func ValidateAuthReqScopes(client Client, scopes []string) ([]string, error) { return scopes, nil } +// ValidateAuthReqResources validates the values of the `resource` parameter against +// [RFC 8707, section 2]: every value must be an absolute URI and must not include a +// fragment component. Invalid values are rejected with the `invalid_target` error code. +// +// Only the syntax is validated here. Whether a resource is acceptable for the client, +// and how it translates into the audience of the issued tokens, is up to the [Storage] +// implementation, which receives the values on the [oidc.AuthRequest] passed to +// [Storage.CreateAuthRequest]. +// +// [RFC 8707, section 2]: https://www.rfc-editor.org/rfc/rfc8707#section-2 +func ValidateAuthReqResources(resources []string) error { + for _, resource := range resources { + if strings.Contains(resource, "#") { + return oidc.ErrInvalidTarget(). + WithDescription("The resource parameter %q must not include a fragment component.", resource) + } + uri, err := url.Parse(resource) + if err != nil { + return oidc.ErrInvalidTarget().WithParent(err). + WithDescription("The resource parameter %q is not a valid URI.", resource) + } + if !uri.IsAbs() { + return oidc.ErrInvalidTarget(). + WithDescription("The resource parameter %q must be an absolute URI.", resource) + } + } + return nil +} + // checkURIAgainstRedirects just checks against the valid redirect URIs and ignores // other factors. func checkURIAgainstRedirects(client Client, uri string) error { diff --git a/pkg/op/auth_request_test.go b/pkg/op/auth_request_test.go index 31dbbc59..440ffbb2 100644 --- a/pkg/op/auth_request_test.go +++ b/pkg/op/auth_request_test.go @@ -197,6 +197,24 @@ func TestParseAuthorizeRequest(t *testing.T) { false, }, }, + { + "parsing repeated resource ok", + args{ + &http.Request{URL: &url.URL{RawQuery: "scope=openid&resource=https%3A%2F%2Fapi.example.com%2F&resource=https%3A%2F%2Fmcp.example.com%2Fmcp"}}, + func() httphelper.Decoder { + decoder := schema.NewDecoder() + decoder.IgnoreUnknownKeys(false) + return decoder + }(), + }, + res{ + &oidc.AuthRequest{ + Scopes: oidc.SpaceDelimitedArray{"openid"}, + Resource: []string{"https://api.example.com/", "https://mcp.example.com/mcp"}, + }, + false, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -242,6 +260,20 @@ func TestValidateAuthRequest(t *testing.T) { args{&oidc.AuthRequest{Scopes: []string{"openid"}, ResponseType: oidc.ResponseTypeCode, ClientID: "client_id"}, mock.NewMockStorageExpectValidClientID(t), nil}, oidc.ErrInvalidRequest(), }, + { + "resource with fragment fails", + args{&oidc.AuthRequest{ + Scopes: []string{"openid"}, + ResponseType: oidc.ResponseTypeCode, + ClientID: "web_client", + RedirectURI: "https://registered.com/callback", + Resource: []string{"https://mcp.example.com/mcp#fragment"}, + }, mock.NewMockStorageExpectValidClientID(t), nil}, + oidc.ErrInvalidTarget().WithDescription( + "The resource parameter %q must not include a fragment component.", + "https://mcp.example.com/mcp#fragment", + ), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -867,6 +899,95 @@ func TestValidateAuthReqResponseType(t *testing.T) { } } +func TestCopyRequestObjectToAuthRequest(t *testing.T) { + authReq := &oidc.AuthRequest{ + Scopes: oidc.SpaceDelimitedArray{"openid"}, + ClientID: "web_client", + RedirectURI: "https://registered.com/callback", + Resource: []string{"https://api.example.com/"}, + } + requestObject := &oidc.RequestObject{ + AuthRequest: oidc.AuthRequest{ + Resource: []string{"https://mcp.example.com/mcp"}, + }, + } + + op.CopyRequestObjectToAuthRequest(authReq, requestObject) + assert.Equal(t, []string{"https://mcp.example.com/mcp"}, authReq.Resource) + + // an empty resource in the request object must not clear the request parameter + op.CopyRequestObjectToAuthRequest(authReq, &oidc.RequestObject{}) + assert.Equal(t, []string{"https://mcp.example.com/mcp"}, authReq.Resource) +} + +func TestValidateAuthReqResources(t *testing.T) { + tests := []struct { + name string + resources []string + wantErr bool + }{ + { + name: "no resource", + resources: nil, + }, + { + name: "absolute URI", + resources: []string{"https://mcp.example.com/mcp"}, + }, + { + name: "multiple absolute URIs", + resources: []string{"https://mcp.example.com/mcp", "urn:example:resource"}, + }, + { + name: "query component is allowed", + resources: []string{"https://mcp.example.com/mcp?tenant=1"}, + }, + { + name: "empty value", + resources: []string{""}, + wantErr: true, + }, + { + name: "relative reference", + resources: []string{"/mcp"}, + wantErr: true, + }, + { + name: "missing scheme", + resources: []string{"mcp.example.com/mcp"}, + wantErr: true, + }, + { + name: "fragment component", + resources: []string{"https://mcp.example.com/mcp#fragment"}, + wantErr: true, + }, + { + name: "empty fragment component", + resources: []string{"https://mcp.example.com/mcp#"}, + wantErr: true, + }, + { + name: "second value invalid", + resources: []string{"https://mcp.example.com/mcp", "not a uri"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := op.ValidateAuthReqResources(tt.resources) + if !tt.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + var oidcErr *oidc.Error + require.ErrorAs(t, err, &oidcErr) + assert.Equal(t, oidc.InvalidTarget, oidcErr.ErrorType) + }) + } +} + func TestRedirectToLogin(t *testing.T) { type args struct { authReqID string diff --git a/pkg/op/discovery.go b/pkg/op/discovery.go index e3ca6035..16d70eae 100644 --- a/pkg/op/discovery.go +++ b/pkg/op/discovery.go @@ -60,6 +60,7 @@ func CreateDiscoveryConfig(ctx context.Context, config Configuration, storage Di RevocationEndpointAuthMethodsSupported: AuthMethodsRevocationEndpoint(config), ClaimsSupported: SupportedClaims(config), CodeChallengeMethodsSupported: CodeChallengeMethods(config), + ResourceIndicatorsSupported: ResourceIndicatorsSupported(config), UILocalesSupported: config.SupportedUILocales(), RequestParameterSupported: config.RequestObjectSupported(), BackChannelLogoutSupported: config.BackChannelLogoutSupported(), @@ -93,6 +94,7 @@ func createDiscoveryConfigV2(ctx context.Context, config Configuration, storage RevocationEndpointAuthMethodsSupported: AuthMethodsRevocationEndpoint(config), ClaimsSupported: SupportedClaims(config), CodeChallengeMethodsSupported: CodeChallengeMethods(config), + ResourceIndicatorsSupported: ResourceIndicatorsSupported(config), UILocalesSupported: config.SupportedUILocales(), RequestParameterSupported: config.RequestObjectSupported(), BackChannelLogoutSupported: config.BackChannelLogoutSupported(), @@ -100,6 +102,17 @@ func createDiscoveryConfigV2(ctx context.Context, config Configuration, storage } } +// ResourceIndicatorsSupported reports whether the OP advertises support for the +// `resource` parameter defined by [RFC 8707]. Enable it with +// [Config.ResourceIndicatorsSupported] once the [Storage] implementation honours the +// requested resources when it determines the audience of the issued tokens. +// +// [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 +func ResourceIndicatorsSupported(c Configuration) bool { + provider, ok := c.(*Provider) + return ok && provider.config.ResourceIndicatorsSupported +} + func Scopes(c Configuration) []string { provider, ok := c.(*Provider) if ok && provider.config.SupportedScopes != nil { diff --git a/pkg/op/discovery_test.go b/pkg/op/discovery_test.go index 4206d0da..3257e10b 100644 --- a/pkg/op/discovery_test.go +++ b/pkg/op/discovery_test.go @@ -45,6 +45,17 @@ func TestDiscover(t *testing.T) { }, `{"issuer":"https://issuer.com","client_id_metadata_document_supported":true,"request_uri_parameter_supported":false}`, }, + { + "resource_indicators_supported", + args{ + httptest.NewRecorder(), + &oidc.DiscoveryConfiguration{ + Issuer: "https://issuer.com", + ResourceIndicatorsSupported: true, + }, + }, + `{"issuer":"https://issuer.com","resource_indicators_supported":true,"request_uri_parameter_supported":false}`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -105,6 +116,39 @@ func Test_scopes(t *testing.T) { } } +func Test_ResourceIndicatorsSupported(t *testing.T) { + type args struct { + c op.Configuration + } + tests := []struct { + name string + args args + want bool + }{ + { + "not a provider", + args{}, + false, + }, + { + "disabled by default", + args{newTestProvider(&op.Config{})}, + false, + }, + { + "enabled", + args{newTestProvider(&op.Config{ResourceIndicatorsSupported: true})}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := op.ResourceIndicatorsSupported(tt.args.c) + assert.Equal(t, tt.want, got) + }) + } +} + func Test_ResponseTypes(t *testing.T) { type args struct { c op.Configuration diff --git a/pkg/op/op.go b/pkg/op/op.go index bb789a39..66a830ac 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -170,6 +170,7 @@ type Config struct { SupportedUILocales []language.Tag SupportedClaims []string SupportedScopes []string + ResourceIndicatorsSupported bool DeviceAuthorization DeviceAuthorizationConfig BackChannelLogoutSupported bool BackChannelLogoutSessionSupported bool