From 7a3827a0fc93d244b9f2f3e15d6f09b8d9955591 Mon Sep 17 00:00:00 2001 From: amatya-dev Date: Sun, 23 Aug 2026 11:34:35 +0530 Subject: [PATCH 1/5] feat(op): support resource indicators (RFC 8707) at the authorization endpoint The `resource` parameter was silently dropped at the authorization endpoint, so a Storage implementation had no way to learn which resource a token was requested for and could not bind the token audience to it. Add `Resource` to `oidc.AuthRequest` so the parameter is parsed and handed to `Storage.CreateAuthRequest`, validate the values per RFC 8707 section 2 (absolute URI, no fragment) and reject invalid ones with `invalid_target`, copy the values from a Request Object like the other authorization parameters, and let an OP advertise `resource_indicators_supported` in its discovery document through the new `Config.ResourceIndicatorsSupported` option. --- pkg/oidc/authorization.go | 13 ++++ pkg/oidc/discovery.go | 10 +++ pkg/op/auth_request.go | 35 +++++++++++ pkg/op/auth_request_test.go | 121 ++++++++++++++++++++++++++++++++++++ pkg/op/discovery.go | 13 ++++ pkg/op/discovery_test.go | 44 +++++++++++++ pkg/op/op.go | 1 + 7 files changed, 237 insertions(+) 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 From f6420ea7d41c3903e5e8a02e3dd807d22e420068 Mon Sep 17 00:00:00 2001 From: amartya-dev Date: Fri, 28 Aug 2026 20:06:53 +0530 Subject: [PATCH 2/5] feat(op): support resource indicators (RFC 8707) at the token endpoint The `resource` parameter was only accepted at the authorization endpoint, so a client could not narrow the audience of the token it receives when it exchanges a code or refreshes a token, which is what RFC 8707 section 2.2 defines the parameter for. Add `Resource` to `oidc.AccessTokenRequest`, `oidc.RefreshTokenRequest` and `oidc.ClientCredentialsRequest`, and validate the values on the token endpoint: the syntax as at the authorization endpoint, and, in addition, that every requested resource was granted by the original authorization request. A resource that was not granted is rejected with `invalid_target`. Since the library cannot know how a resource maps onto an audience, the requested values are handed to the Storage implementation through two optional interfaces on the request types it returns: `ResourceRequest` reports the granted resources and `CurrentResourceSetter` receives the narrowed ones before the tokens are created, mirroring the existing `SetCurrentScopes` of `RefreshTokenRequest`. Implementations that do not implement them keep their current behaviour. `ValidateAuthReqResources` is renamed to `ValidateResourceIndicators`, as it is no longer specific to the authorization request, and is now also called on the authorization path of the new `Server` API, which was missed. --- pkg/oidc/token_request.go | 24 ++++ pkg/op/auth_request.go | 31 +---- pkg/op/auth_request_test.go | 68 --------- pkg/op/resource.go | 102 ++++++++++++++ pkg/op/resource_test.go | 216 +++++++++++++++++++++++++++++ pkg/op/server_http.go | 3 + pkg/op/server_legacy.go | 9 ++ pkg/op/token_client_credentials.go | 4 + pkg/op/token_code.go | 3 + pkg/op/token_refresh.go | 3 + 10 files changed, 365 insertions(+), 98 deletions(-) create mode 100644 pkg/op/resource.go create mode 100644 pkg/op/resource_test.go diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index 61a7fbba..ccd8df66 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -78,6 +78,15 @@ type AccessTokenRequest struct { CodeVerifier string `schema:"code_verifier,omitempty"` ClientAssertion string `schema:"client_assertion,omitempty"` ClientAssertionType string `schema:"client_assertion_type,omitempty"` + + // Resource narrows the target service(s) or resource(s) of the issued token, + // as defined by [RFC 8707, section 2.2]. Every value must be an absolute URI + // without a fragment component and must have been requested at the + // authorization endpoint. If omitted, the resources of the authorization + // request are used. + // + // [RFC 8707, section 2.2]: https://www.rfc-editor.org/rfc/rfc8707#section-2.2 + Resource []string `schema:"resource,omitempty"` } func (a *AccessTokenRequest) GrantType() GrantType { @@ -113,6 +122,14 @@ type RefreshTokenRequest struct { ClientSecret string `schema:"client_secret"` ClientAssertion string `schema:"client_assertion"` ClientAssertionType string `schema:"client_assertion_type"` + + // Resource narrows the target service(s) or resource(s) of the issued token, + // as defined by [RFC 8707, section 2.2]. Every value must be an absolute URI + // without a fragment component and must have been granted to the refresh token. + // If omitted, the resources of the original authorization request are used. + // + // [RFC 8707, section 2.2]: https://www.rfc-editor.org/rfc/rfc8707#section-2.2 + Resource []string `schema:"resource,omitempty"` } func (a *RefreshTokenRequest) GrantType() GrantType { @@ -264,6 +281,13 @@ type ClientCredentialsRequest struct { ClientSecret string `schema:"client_secret"` ClientAssertion string `schema:"client_assertion,omitempty"` ClientAssertionType string `schema:"client_assertion_type,omitempty"` + + // Resource indicates the target service(s) or resource(s) at which the issued + // token is intended to be used, as defined by [RFC 8707]. Every value must be an + // absolute URI without a fragment component. + // + // [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 + Resource []string `schema:"resource,omitempty"` } // Deprecated: This function is no longer invoked because it violates diff --git a/pkg/op/auth_request.go b/pkg/op/auth_request.go index 67d0b3a3..3265ab46 100644 --- a/pkg/op/auth_request.go +++ b/pkg/op/auth_request.go @@ -278,7 +278,7 @@ 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 { + if err := ValidateResourceIndicators(authReq.Resource); err != nil { return "", err } return ValidateAuthReqIDTokenHint(ctx, authReq.IDTokenHint, verifier) @@ -317,35 +317,6 @@ 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 440ffbb2..e967bd01 100644 --- a/pkg/op/auth_request_test.go +++ b/pkg/op/auth_request_test.go @@ -920,74 +920,6 @@ func TestCopyRequestObjectToAuthRequest(t *testing.T) { 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/resource.go b/pkg/op/resource.go new file mode 100644 index 00000000..f92a344e --- /dev/null +++ b/pkg/op/resource.go @@ -0,0 +1,102 @@ +package op + +import ( + "net/url" + "slices" + "strings" + + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +// ResourceRequest is an optional interface which may be implemented by the request +// types returned by [Storage], such as [AuthRequest], [RefreshTokenRequest] or the +// [TokenRequest] of the client credentials grant. +// +// It reports the resources of [RFC 8707] that were granted to the request. The op +// package uses them to check that a `resource` value sent to the token endpoint was +// covered by the original authorization request. +// +// [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 +type ResourceRequest interface { + GetResource() []string +} + +// CurrentResourceSetter is an optional interface which may be implemented by the +// request types returned by [Storage], analogous to the `SetCurrentScopes` method of +// [RefreshTokenRequest]. +// +// When a token request narrows the granted resources with the `resource` parameter of +// [RFC 8707, section 2.2], the op package passes the requested values to the request +// before the tokens are created, so that the implementation can bind the audience of +// the issued tokens to them. +// +// [RFC 8707, section 2.2]: https://www.rfc-editor.org/rfc/rfc8707#section-2.2 +type CurrentResourceSetter interface { + SetCurrentResources(resources []string) +} + +// ValidateResourceIndicators 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 as part of the request. +// +// [RFC 8707, section 2]: https://www.rfc-editor.org/rfc/rfc8707#section-2 +func ValidateResourceIndicators(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 +} + +// ValidateTokenRequestResources validates the `resource` values of a token request +// against [RFC 8707, section 2.2]. +// +// Besides the syntax checked by [ValidateResourceIndicators], every requested value +// must be one of the resources already granted to request, if it reports any through +// the optional [ResourceRequest] interface. A request for a resource that was not +// granted is rejected with `invalid_target`. +// +// If request implements the optional [CurrentResourceSetter] interface, the requested +// resources are set on it, so that the [Storage] implementation can narrow the audience +// of the issued tokens accordingly. An empty `resource` parameter leaves the granted +// resources untouched, as the RFC requires. +// +// [RFC 8707, section 2.2]: https://www.rfc-editor.org/rfc/rfc8707#section-2.2 +func ValidateTokenRequestResources(requestedResources []string, request TokenRequest) error { + if err := ValidateResourceIndicators(requestedResources); err != nil { + return err + } + if len(requestedResources) == 0 { + return nil + } + if granted, ok := request.(ResourceRequest); ok { + grantedResources := granted.GetResource() + if len(grantedResources) > 0 { + for _, resource := range requestedResources { + if !slices.Contains(grantedResources, resource) { + return oidc.ErrInvalidTarget(). + WithDescription("The resource parameter %q was not granted by the authorization request.", resource) + } + } + } + } + if setter, ok := request.(CurrentResourceSetter); ok { + setter.SetCurrentResources(requestedResources) + } + return nil +} diff --git a/pkg/op/resource_test.go b/pkg/op/resource_test.go new file mode 100644 index 00000000..0506899f --- /dev/null +++ b/pkg/op/resource_test.go @@ -0,0 +1,216 @@ +package op_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/schema" + + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/oidc/v3/pkg/op" +) + +func TestValidateResourceIndicators(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.ValidateResourceIndicators(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) + }) + } +} + +// resourceRequest is a minimal op.TokenRequest which reports and narrows +// the granted resource indicators. +type resourceRequest struct { + resources []string + currentResources []string +} + +func (r *resourceRequest) GetSubject() string { return "id1" } +func (r *resourceRequest) GetAudience() []string { return []string{"client1"} } +func (r *resourceRequest) GetScopes() []string { return []string{"openid"} } +func (r *resourceRequest) GetResource() []string { return r.resources } +func (r *resourceRequest) SetCurrentResources(resources []string) { + r.currentResources = resources +} + +// plainRequest is an op.TokenRequest which implements neither op.ResourceRequest +// nor op.CurrentResourceSetter. +type plainRequest struct{} + +func (r *plainRequest) GetSubject() string { return "id1" } +func (r *plainRequest) GetAudience() []string { return []string{"client1"} } +func (r *plainRequest) GetScopes() []string { return []string{"openid"} } + +func TestValidateTokenRequestResources(t *testing.T) { + tests := []struct { + name string + requested []string + granted []string + wantErr bool + wantCurrent []string + }{ + { + name: "no resource requested", + granted: []string{"https://mcp.example.com/mcp"}, + wantErr: false, + }, + { + name: "requested resource was granted", + requested: []string{"https://mcp.example.com/mcp"}, + granted: []string{"https://mcp.example.com/mcp", "https://api.example.com"}, + wantCurrent: []string{"https://mcp.example.com/mcp"}, + }, + { + name: "all granted resources requested", + requested: []string{"https://mcp.example.com/mcp", "https://api.example.com"}, + granted: []string{"https://mcp.example.com/mcp", "https://api.example.com"}, + wantCurrent: []string{"https://mcp.example.com/mcp", "https://api.example.com"}, + }, + { + name: "requested resource was not granted", + requested: []string{"https://other.example.com"}, + granted: []string{"https://mcp.example.com/mcp"}, + wantErr: true, + }, + { + name: "one of the requested resources was not granted", + requested: []string{"https://mcp.example.com/mcp", "https://other.example.com"}, + granted: []string{"https://mcp.example.com/mcp"}, + wantErr: true, + }, + { + name: "invalid syntax", + requested: []string{"/mcp"}, + granted: []string{"/mcp"}, + wantErr: true, + }, + { + name: "nothing granted accepts any valid resource", + requested: []string{"https://mcp.example.com/mcp"}, + granted: nil, + wantCurrent: []string{"https://mcp.example.com/mcp"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := &resourceRequest{resources: tt.granted} + err := op.ValidateTokenRequestResources(tt.requested, request) + if tt.wantErr { + require.Error(t, err) + var oidcErr *oidc.Error + require.ErrorAs(t, err, &oidcErr) + assert.Equal(t, oidc.InvalidTarget, oidcErr.ErrorType) + assert.Nil(t, request.currentResources) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCurrent, request.currentResources) + }) + } +} + +func TestValidateTokenRequestResources_withoutResourceInterfaces(t *testing.T) { + require.NoError(t, op.ValidateTokenRequestResources(nil, &plainRequest{})) + require.NoError(t, op.ValidateTokenRequestResources([]string{"https://mcp.example.com/mcp"}, &plainRequest{})) + require.Error(t, op.ValidateTokenRequestResources([]string{"/mcp"}, &plainRequest{})) +} + +func TestParseTokenRequestResource(t *testing.T) { + decoder := schema.NewDecoder() + decoder.IgnoreUnknownKeys(true) + + const form = "client_id=myid&client_secret=mysecret&code=abc&refresh_token=xyz" + + "&resource=https%3A%2F%2Fmcp.example.com%2Fmcp&resource=https%3A%2F%2Fapi.example.com" + want := []string{"https://mcp.example.com/mcp", "https://api.example.com"} + + t.Run("authorization_code", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + got, err := op.ParseAccessTokenRequest(r, decoder) + require.NoError(t, err) + assert.Equal(t, want, got.Resource) + }) + + t.Run("refresh_token", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + got, err := op.ParseRefreshTokenRequest(r, decoder) + require.NoError(t, err) + assert.Equal(t, want, got.Resource) + }) + + t.Run("client_credentials", func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + got, err := op.ParseClientCredentialsRequest(r, decoder) + require.NoError(t, err) + assert.Equal(t, want, got.Resource) + }) +} diff --git a/pkg/op/server_http.go b/pkg/op/server_http.go index 274cabd3..cf0893b9 100644 --- a/pkg/op/server_http.go +++ b/pkg/op/server_http.go @@ -237,6 +237,9 @@ func (s *webServer) authorize(ctx context.Context, r *Request[oidc.AuthRequest]) if err := ValidateAuthReqResponseType(cr.Client, authReq.ResponseType); err != nil { return nil, err } + if err := ValidateResourceIndicators(authReq.Resource); err != nil { + return nil, err + } return s.server.Authorize(ctx, cr) } diff --git a/pkg/op/server_legacy.go b/pkg/op/server_legacy.go index 44d66e51..84431443 100644 --- a/pkg/op/server_legacy.go +++ b/pkg/op/server_legacy.go @@ -241,6 +241,9 @@ func (s *LegacyServer) CodeExchange(ctx context.Context, r *ClientRequest[oidc.A if r.Data.RedirectURI != authReq.GetRedirectURI() { return nil, oidc.ErrInvalidGrant().WithDescription("redirect_uri does not correspond") } + if err = ValidateTokenRequestResources(r.Data.Resource, authReq); err != nil { + return nil, err + } resp, err := CreateTokenResponse(ctx, authReq, r.Client, s.provider, true, r.Data.Code, "") if err != nil { return nil, err @@ -265,6 +268,9 @@ func (s *LegacyServer) RefreshToken(ctx context.Context, r *ClientRequest[oidc.R if err = ValidateRefreshTokenScopes(r.Data.Scopes, request); err != nil { return nil, err } + if err = ValidateTokenRequestResources(r.Data.Resource, request); err != nil { + return nil, err + } resp, err := CreateTokenResponse(ctx, request, r.Client, s.provider, true, "", r.Data.RefreshToken) if err != nil { return nil, err @@ -326,6 +332,9 @@ func (s *LegacyServer) ClientCredentialsExchange(ctx context.Context, r *ClientR if err != nil { return nil, err } + if err = ValidateTokenRequestResources(r.Data.Resource, tokenRequest); err != nil { + return nil, err + } resp, err := CreateClientCredentialsTokenResponse(ctx, tokenRequest, s.provider, r.Client) if err != nil { return nil, err diff --git a/pkg/op/token_client_credentials.go b/pkg/op/token_client_credentials.go index 0c43c545..3b146750 100644 --- a/pkg/op/token_client_credentials.go +++ b/pkg/op/token_client_credentials.go @@ -89,6 +89,10 @@ func ValidateClientCredentialsRequest(ctx context.Context, request *oidc.ClientC return nil, nil, err } + if err = ValidateTokenRequestResources(request.Resource, tokenRequest); err != nil { + return nil, nil, err + } + return tokenRequest, client, nil } diff --git a/pkg/op/token_code.go b/pkg/op/token_code.go index 753709fd..229a55bc 100644 --- a/pkg/op/token_code.go +++ b/pkg/op/token_code.go @@ -66,6 +66,9 @@ func ValidateAccessTokenRequest(ctx context.Context, tokenReq *oidc.AccessTokenR if tokenReq.RedirectURI != authReq.GetRedirectURI() { return nil, nil, oidc.ErrInvalidGrant().WithDescription("redirect_uri does not correspond") } + if err := ValidateTokenRequestResources(tokenReq.Resource, authReq); err != nil { + return nil, nil, err + } return authReq, client, nil } diff --git a/pkg/op/token_refresh.go b/pkg/op/token_refresh.go index 631a3096..583ef808 100644 --- a/pkg/op/token_refresh.go +++ b/pkg/op/token_refresh.go @@ -75,6 +75,9 @@ func ValidateRefreshTokenRequest(ctx context.Context, tokenReq *oidc.RefreshToke if err = ValidateRefreshTokenScopes(tokenReq.Scopes, request); err != nil { return nil, nil, err } + if err = ValidateTokenRequestResources(tokenReq.Resource, request); err != nil { + return nil, nil, err + } return request, client, nil } From e854fa4103e6b7d2eeacd561b5d2b23473390182 Mon Sep 17 00:00:00 2001 From: amartya-dev Date: Fri, 28 Aug 2026 20:07:34 +0530 Subject: [PATCH 3/5] feat(op): support resource indicators (RFC 8707) in the device flow The device authorization endpoint dropped the `resource` parameter, so a device could not ask for a token bound to the resource server it intends to call. Add `Resource` to `oidc.DeviceAuthorizationRequest` and `oidc.DeviceAccessTokenRequest` and validate both, and carry the values on `op.DeviceAuthorizationState`, which now implements the `ResourceRequest` and `CurrentResourceSetter` interfaces so a device access token request can narrow the granted resources down. Storing the resources requires an additional argument, which cannot be added to `DeviceAuthorizationStorage.StoreDeviceAuthorization` without breaking every implementation. The optional `CanStoreDeviceAuthorizationWithResources` interface is used instead when a storage implements it, following the pattern of the other optional `Can...` storage interfaces. The example storage implements it. --- example/server/storage/storage.go | 12 ++++++ pkg/oidc/device_authorization.go | 19 +++++++++ pkg/op/device.go | 33 ++++++++++++++- pkg/op/device_test.go | 68 +++++++++++++++++++++++++++++++ pkg/op/server_legacy.go | 3 ++ pkg/op/storage.go | 14 +++++++ 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 253c241c..8927bfc1 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -821,6 +821,14 @@ type deviceAuthorizationEntry struct { } func (s *Storage) StoreDeviceAuthorization(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes []string) error { + return s.StoreDeviceAuthorizationWithResources(ctx, clientID, deviceCode, userCode, expires, scopes, nil) +} + +// StoreDeviceAuthorizationWithResources implements the optional +// op.CanStoreDeviceAuthorizationWithResources interface, so that the resource +// indicators (RFC 8707) of the device authorization request are kept and can be used +// to determine the audience of the issued tokens. +func (s *Storage) StoreDeviceAuthorizationWithResources(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes, resources []string) error { s.lock.Lock() defer s.lock.Unlock() @@ -837,7 +845,11 @@ func (s *Storage) StoreDeviceAuthorization(ctx context.Context, clientID, device userCode: userCode, state: &op.DeviceAuthorizationState{ ClientID: clientID, + // op.DeviceAuthorizationState.GetAudience always adds the client_id, + // so the requested resources alone are enough to bind the audience. + Audience: resources, Scopes: scopes, + Resource: resources, Expires: expires, }, } diff --git a/pkg/oidc/device_authorization.go b/pkg/oidc/device_authorization.go index a6417ba5..e87280ae 100644 --- a/pkg/oidc/device_authorization.go +++ b/pkg/oidc/device_authorization.go @@ -8,6 +8,16 @@ import "encoding/json" type DeviceAuthorizationRequest struct { Scopes SpaceDelimitedArray `schema:"scope"` ClientID string `schema:"client_id"` + + // 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`. + // + // [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 + Resource []string `schema:"resource,omitempty"` } // DeviceAuthorizationResponse implements @@ -48,4 +58,13 @@ func (resp *DeviceAuthorizationResponse) UnmarshalJSON(data []byte) error { type DeviceAccessTokenRequest struct { GrantType GrantType `json:"grant_type" schema:"grant_type"` DeviceCode string `json:"device_code" schema:"device_code"` + + // Resource narrows the target service(s) or resource(s) of the issued token, + // as defined by [RFC 8707, section 2.2]. Every value must be an absolute URI + // without a fragment component and must have been requested at the device + // authorization endpoint. If omitted, the resources of the device authorization + // request are used. + // + // [RFC 8707, section 2.2]: https://www.rfc-editor.org/rfc/rfc8707#section-2.2 + Resource []string `json:"resource,omitempty" schema:"resource,omitempty"` } diff --git a/pkg/op/device.go b/pkg/op/device.go index b71b64e4..51d03615 100644 --- a/pkg/op/device.go +++ b/pkg/op/device.go @@ -85,6 +85,10 @@ func createDeviceAuthorization(ctx context.Context, req *oidc.DeviceAuthorizatio ctx, span := Tracer.Start(ctx, "createDeviceAuthorization") defer span.End() + if err := ValidateResourceIndicators(req.Resource); err != nil { + return nil, err + } + storage, err := assertDeviceStorage(o.Storage()) if err != nil { return nil, err @@ -98,7 +102,11 @@ func createDeviceAuthorization(ctx context.Context, req *oidc.DeviceAuthorizatio } expires := time.Now().Add(config.Lifetime) - err = storage.StoreDeviceAuthorization(ctx, clientID, deviceCode, userCode, expires, req.Scopes) + if resourceStorage, ok := storage.(CanStoreDeviceAuthorizationWithResources); ok { + err = resourceStorage.StoreDeviceAuthorizationWithResources(ctx, clientID, deviceCode, userCode, expires, req.Scopes, req.Resource) + } else { + err = storage.StoreDeviceAuthorization(ctx, clientID, deviceCode, userCode, expires, req.Scopes) + } if err != nil { return nil, NewStatusError(err, http.StatusInternalServerError) } @@ -227,6 +235,9 @@ func deviceAccessToken(w http.ResponseWriter, r *http.Request, exchanger Exchang if err != nil { return err } + if err = ValidateTokenRequestResources(req.Resource, tokenRequest); err != nil { + return err + } client, err := exchanger.Storage().GetClientByClientID(ctx, clientID) if err != nil { @@ -265,6 +276,16 @@ type DeviceAuthorizationState struct { Done bool // The user authenticated and approved the authorization request Denied bool // The user authenticated and denied the authorization request + // Resource holds the resource indicators of [RFC 8707] that were requested with + // the device authorization request, as far as the [DeviceAuthorizationStorage] + // implementation stores and returns them. When the device access token request + // narrows them with its own `resource` parameter, this field is reduced to the + // requested subset before the tokens are created, so that the [Storage] + // implementation can bind the audience of the issued tokens to it. + // + // [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 + Resource []string + // The following fields are populated after Done == true Subject string AMR []string @@ -294,6 +315,16 @@ func (r *DeviceAuthorizationState) GetScopes() []string { return r.Scopes } +// GetResource implements the [ResourceRequest] interface. +func (r *DeviceAuthorizationState) GetResource() []string { + return r.Resource +} + +// SetCurrentResources implements the [CurrentResourceSetter] interface. +func (r *DeviceAuthorizationState) SetCurrentResources(resources []string) { + r.Resource = resources +} + func (r *DeviceAuthorizationState) GetSubject() string { return r.Subject } diff --git a/pkg/op/device_test.go b/pkg/op/device_test.go index 5fd9c9b6..9091e64e 100644 --- a/pkg/op/device_test.go +++ b/pkg/op/device_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "io" mr "math/rand" "net/http" @@ -111,6 +112,14 @@ func TestParseDeviceCodeRequest(t *testing.T) { ClientID: "device", }, }, + { + name: "resource indicators", + req: &oidc.DeviceAuthorizationRequest{ + Scopes: oidc.SpaceDelimitedArray{"foo", "bar"}, + ClientID: "device", + Resource: []string{"https://mcp.example.com/mcp", "https://api.example.com"}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -536,3 +545,62 @@ func TestCreateDeviceTokenResponse(t *testing.T) { }) } } + +func TestDeviceAuthorizationResources(t *testing.T) { + newRequest := func(resource ...string) *http.Request { + req := &oidc.DeviceAuthorizationRequest{ + Scopes: []string{"foo", "bar"}, + ClientID: "device", + Resource: resource, + } + values := make(url.Values) + testProvider.Encoder().Encode(req, values) + + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(values.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return r.WithContext(op.ContextWithIssuer(r.Context(), testIssuer)) + } + + t.Run("invalid resource is rejected", func(t *testing.T) { + w := httptest.NewRecorder() + op.DeviceAuthorizationHandler(testProvider)(w, newRequest("/mcp")) + + result := w.Result() + assert.Equal(t, http.StatusBadRequest, result.StatusCode) + body, _ := io.ReadAll(result.Body) + assert.Contains(t, string(body), string(oidc.InvalidTarget)) + }) + + t.Run("resources are stored and narrowed on the token request", func(t *testing.T) { + const ( + granted = "https://mcp.example.com/mcp" + other = "https://api.example.com" + ) + + w := httptest.NewRecorder() + op.DeviceAuthorizationHandler(testProvider)(w, newRequest(granted, other)) + + result := w.Result() + require.Less(t, result.StatusCode, 300) + + response := new(oidc.DeviceAuthorizationResponse) + require.NoError(t, json.NewDecoder(result.Body).Decode(response)) + + state, err := testProvider.Storage().(op.DeviceAuthorizationStorage). + GetDeviceAuthorizatonState(context.Background(), "device", response.DeviceCode) + require.NoError(t, err) + assert.Equal(t, []string{granted, other}, state.GetResource()) + assert.Equal(t, []string{granted, other, "device"}, state.GetAudience()) + + // a device access token request may narrow the granted resources down + require.NoError(t, op.ValidateTokenRequestResources([]string{granted}, state)) + assert.Equal(t, []string{granted}, state.GetResource()) + + // but may not ask for a resource that was never granted + err = op.ValidateTokenRequestResources([]string{"https://other.example.com"}, state) + require.Error(t, err) + var oidcErr *oidc.Error + require.ErrorAs(t, err, &oidcErr) + assert.Equal(t, oidc.InvalidTarget, oidcErr.ErrorType) + }) +} diff --git a/pkg/op/server_legacy.go b/pkg/op/server_legacy.go index 84431443..37f0d63b 100644 --- a/pkg/op/server_legacy.go +++ b/pkg/op/server_legacy.go @@ -358,6 +358,9 @@ func (s *LegacyServer) DeviceToken(ctx context.Context, r *ClientRequest[oidc.De if err != nil { return nil, err } + if err = ValidateTokenRequestResources(r.Data.Resource, tokenRequest); err != nil { + return nil, err + } resp, err := CreateDeviceTokenResponse(ctx, tokenRequest, s.provider, r.Client) if err != nil { return nil, err diff --git a/pkg/op/storage.go b/pkg/op/storage.go index 973805b4..dbd3376c 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -202,6 +202,20 @@ type DeviceAuthorizationStorage interface { GetDeviceAuthorizatonState(ctx context.Context, clientID, deviceCode string) (*DeviceAuthorizationState, error) } +// CanStoreDeviceAuthorizationWithResources is an optional interface that may be +// implemented in addition to [DeviceAuthorizationStorage]. When it is, it is used +// instead of StoreDeviceAuthorization, so that the resource indicators of [RFC 8707] +// requested with the device authorization request are stored along with it. +// +// The stored resources should be returned on the [DeviceAuthorizationState] of +// GetDeviceAuthorizatonState, which allows the device access token request to narrow +// them and the audience of the issued tokens to be bound to them. +// +// [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 +type CanStoreDeviceAuthorizationWithResources interface { + StoreDeviceAuthorizationWithResources(ctx context.Context, clientID, deviceCode, userCode string, expires time.Time, scopes, resources []string) error +} + func assertDeviceStorage(s Storage) (DeviceAuthorizationStorage, error) { storage, ok := s.(DeviceAuthorizationStorage) if !ok { From c3608727fd3e0d8fb4b0761d740b4a66332ce7e1 Mon Sep 17 00:00:00 2001 From: amartya-dev Date: Fri, 28 Aug 2026 20:07:43 +0530 Subject: [PATCH 4/5] feat(rp): add WithResourceURLParam option `rp.WithURLParam` could already set a `resource` parameter, but callers had to spell the parameter name themselves. Add a dedicated option, as for the other well-known parameters. As a `URLParamOpt` it applies to both the authorization request and the token request, which is what RFC 8707 needs to bind the audience of the issued token. Only a single value can be set, because the underlying oauth2 package cannot express a repeated URL parameter; this is stated on the option. --- pkg/client/rp/relying_party.go | 14 ++++++++++++++ pkg/client/rp/relying_party_test.go | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go index 84088510..bb64e387 100644 --- a/pkg/client/rp/relying_party.go +++ b/pkg/client/rp/relying_party.go @@ -765,6 +765,20 @@ func WithResponseModeURLParam(mode oidc.ResponseMode) URLParamOpt { return withURLParam("response_mode", string(mode)) } +// WithResourceURLParam sets the `resource` parameter of [RFC 8707] in a URL, to +// indicate the resource server at which the requested token is intended to be used. +// It can be passed to both the authorization request and the token request, so that +// the issued token is bound to the resource as its audience. +// +// The value must be an absolute URI without a fragment component. [RFC 8707] allows +// the parameter to be repeated to request a token for multiple resources, which the +// underlying oauth2 package cannot express: only a single value can be set here. +// +// [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 +func WithResourceURLParam(resource string) URLParamOpt { + return withURLParam("resource", resource) +} + type AuthURLOpt func() []oauth2.AuthCodeOption // WithCodeChallenge sets the `code_challenge` params in the auth request diff --git a/pkg/client/rp/relying_party_test.go b/pkg/client/rp/relying_party_test.go index b09654e1..5952607a 100644 --- a/pkg/client/rp/relying_party_test.go +++ b/pkg/client/rp/relying_party_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -213,3 +214,22 @@ func Test_Oauth2OnlyRPWithPKCEFromDiscovery(t *testing.T) { t.Fatal("RP should be nil when calling 'WithPKCEFromDiscovery' on an OAuth2 only relying party") } } + +func TestWithResourceURLParam(t *testing.T) { + party, err := NewRelyingPartyOAuth(&oauth2.Config{ + ClientID: "clientID", + RedirectURL: "https://client.example.com/callback", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://op.example.com/authorize", + TokenURL: "https://op.example.com/token", + }, + }) + require.NoError(t, err) + + const resource = "https://mcp.example.com/mcp" + authURL := AuthURL("state", party, AuthURLOpt(WithResourceURLParam(resource))) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + assert.Equal(t, resource, parsed.Query().Get("resource")) +} From 59059982ded5a3e53514c33dfb401f709a376181 Mon Sep 17 00:00:00 2001 From: amartya-dev Date: Fri, 28 Aug 2026 20:08:04 +0530 Subject: [PATCH 5/5] feat(example): bind the token audience to the requested resources The example storage always used the client_id as the audience, so the resource indicators it now receives had no visible effect and the example did not show what the feature is for. Add the requested resources to the audience of the issued tokens and carry them on the stored access and refresh tokens, so a refresh token request can narrow them down again. The client_id is kept in the audience because an ID token must be addressed to the client it was issued for; a real implementation would check the requested resources against a policy of the client first and would likely restrict the audience of the access token to the resources alone, which is noted where the audience is built. --- example/server/storage/oidc.go | 67 ++++++++++++++++++++++++++++- example/server/storage/oidc_test.go | 58 +++++++++++++++++++++++++ example/server/storage/storage.go | 12 +++--- example/server/storage/token.go | 7 +++ 4 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 example/server/storage/oidc_test.go diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go index 3d5d86b2..5313f47e 100644 --- a/example/server/storage/oidc.go +++ b/example/server/storage/oidc.go @@ -2,6 +2,7 @@ package storage import ( "log/slog" + "slices" "time" "golang.org/x/text/language" @@ -39,6 +40,10 @@ type AuthRequest struct { Nonce string CodeChallenge *OIDCCodeChallenge + // Resource holds the resource indicators (RFC 8707) requested by the client. + // A token request may narrow them down again, see SetCurrentResources. + Resource []string + done bool authTime time.Time } @@ -73,7 +78,22 @@ func (a *AuthRequest) GetAMR() []string { } func (a *AuthRequest) GetAudience() []string { - return []string{a.ApplicationID} // this example will always just use the client_id as audience + return audienceFromResources(a.ApplicationID, a.Resource) +} + +// GetResource implements the optional op.ResourceRequest interface, which lets the +// op package check a `resource` parameter of a token request against the resources +// that were requested at the authorization endpoint. +func (a *AuthRequest) GetResource() []string { + return a.Resource +} + +// SetCurrentResources implements the optional op.CurrentResourceSetter interface. +// It is called with the `resource` values of a token request, after they have been +// checked against the resources of the authorization request, so that the audience +// of the issued tokens can be narrowed down to them. +func (a *AuthRequest) SetCurrentResources(resources []string) { + a.Resource = resources } func (a *AuthRequest) GetAuthTime() time.Time { @@ -166,6 +186,7 @@ func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthReques ResponseMode: authReq.ResponseMode, Nonce: authReq.Nonce, CodeChallenge: codeChallenge, + Resource: authReq.Resource, } } @@ -211,9 +232,22 @@ func (r *RefreshTokenRequest) GetAMR() []string { } func (r *RefreshTokenRequest) GetAudience() []string { + if len(r.Resource) > 0 { + return audienceFromResources(r.ApplicationID, r.Resource) + } return r.Audience } +// GetResource implements the optional op.ResourceRequest interface. +func (r *RefreshTokenRequest) GetResource() []string { + return r.Resource +} + +// SetCurrentResources implements the optional op.CurrentResourceSetter interface. +func (r *RefreshTokenRequest) SetCurrentResources(resources []string) { + r.Resource = resources +} + func (r *RefreshTokenRequest) GetAuthTime() time.Time { return r.AuthTime } @@ -233,3 +267,34 @@ func (r *RefreshTokenRequest) GetSubject() string { func (r *RefreshTokenRequest) SetCurrentScopes(scopes []string) { r.Scopes = scopes } + +// audienceFromResources shows how a Storage implementation can bind the audience of +// the issued tokens to the resource indicators of [RFC 8707] the client asked for. +// +// The client_id is always kept, because an ID token must be addressed to the client +// it was issued for. A real implementation would first check the requested resources +// against a policy of the client, and would likely restrict the audience of the access +// token to the resources alone. +// +// [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 +func audienceFromResources(clientID string, resources []string) []string { + audience := make([]string, 0, len(resources)+1) + audience = append(audience, clientID) + for _, resource := range resources { + if !slices.Contains(audience, resource) { + audience = append(audience, resource) + } + } + return audience +} + +// resourcesFromRequest returns the resource indicators of [RFC 8707] a token request +// was made for, if the request reports any. +// +// [RFC 8707]: https://www.rfc-editor.org/rfc/rfc8707 +func resourcesFromRequest(request op.TokenRequest) []string { + if resourceRequest, ok := request.(op.ResourceRequest); ok { + return resourceRequest.GetResource() + } + return nil +} diff --git a/example/server/storage/oidc_test.go b/example/server/storage/oidc_test.go new file mode 100644 index 00000000..2ad5d54f --- /dev/null +++ b/example/server/storage/oidc_test.go @@ -0,0 +1,58 @@ +package storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +func TestAuthRequestResources(t *testing.T) { + const ( + mcp = "https://mcp.example.com/mcp" + api = "https://api.example.com" + ) + + t.Run("without resource the audience is the client_id", func(t *testing.T) { + authReq := authRequestToInternal(&oidc.AuthRequest{ClientID: "web"}, "id1") + assert.Empty(t, authReq.GetResource()) + assert.Equal(t, []string{"web"}, authReq.GetAudience()) + }) + + t.Run("the requested resources are added to the audience", func(t *testing.T) { + authReq := authRequestToInternal(&oidc.AuthRequest{ + ClientID: "web", + Resource: []string{mcp, api}, + }, "id1") + assert.Equal(t, []string{mcp, api}, authReq.GetResource()) + assert.Equal(t, []string{"web", mcp, api}, authReq.GetAudience()) + }) + + t.Run("a token request narrows the audience down", func(t *testing.T) { + authReq := authRequestToInternal(&oidc.AuthRequest{ + ClientID: "web", + Resource: []string{mcp, api}, + }, "id1") + authReq.SetCurrentResources([]string{mcp}) + assert.Equal(t, []string{"web", mcp}, authReq.GetAudience()) + }) +} + +func TestRefreshTokenRequestResources(t *testing.T) { + const mcp = "https://mcp.example.com/mcp" + + request := RefreshTokenRequestFromBusiness(&RefreshToken{ + ApplicationID: "web", + Audience: []string{"web", mcp}, + Resource: []string{mcp}, + }) + assert.Equal(t, []string{"web", mcp}, request.GetAudience()) + + // a refresh request without any resource keeps the granted audience + stored := RefreshTokenRequestFromBusiness(&RefreshToken{ + ApplicationID: "web", + Audience: []string{"web", mcp}, + }) + assert.Equal(t, []string{"web", mcp}, stored.GetAudience()) +} diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 8927bfc1..aad21319 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -264,7 +264,7 @@ func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest applicationID = req.GetClientID() } - token, err := s.accessToken(applicationID, "", request.GetSubject(), request.GetAudience(), request.GetScopes()) + token, err := s.accessToken(applicationID, "", request.GetSubject(), request.GetAudience(), request.GetScopes(), resourcesFromRequest(request)) if err != nil { return "", time.Time{}, err } @@ -285,7 +285,7 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T // if currentRefreshToken is empty (Code Flow) we will have to create a new refresh token if currentRefreshToken == "" { refreshTokenID := uuid.NewString() - accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes()) + accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes(), resourcesFromRequest(request)) if err != nil { return "", "", time.Time{}, err } @@ -301,7 +301,7 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T newRefreshToken = uuid.NewString() - accessToken, err := s.accessToken(applicationID, newRefreshToken, request.GetSubject(), request.GetAudience(), request.GetScopes()) + accessToken, err := s.accessToken(applicationID, newRefreshToken, request.GetSubject(), request.GetAudience(), request.GetScopes(), resourcesFromRequest(request)) if err != nil { return "", "", time.Time{}, err } @@ -318,7 +318,7 @@ func (s *Storage) exchangeRefreshToken(ctx context.Context, request op.TokenExch authTime := request.GetAuthTime() refreshTokenID := uuid.NewString() - accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes()) + accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes(), resourcesFromRequest(request)) if err != nil { return "", "", time.Time{}, err } @@ -605,6 +605,7 @@ func (s *Storage) createRefreshToken(accessToken *Token, amr []string, authTime Expiration: time.Now().Add(5 * time.Hour), Scopes: accessToken.Scopes, AccessToken: accessToken.ID, + Resource: accessToken.Resource, } s.refreshTokens[token.ID] = token return token.Token, nil @@ -642,7 +643,7 @@ func (s *Storage) renewRefreshToken(currentRefreshToken, newRefreshToken, newAcc } // accessToken will store an access_token in-memory based on the provided information -func (s *Storage) accessToken(applicationID, refreshTokenID, subject string, audience, scopes []string) (*Token, error) { +func (s *Storage) accessToken(applicationID, refreshTokenID, subject string, audience, scopes, resources []string) (*Token, error) { s.lock.Lock() defer s.lock.Unlock() token := &Token{ @@ -653,6 +654,7 @@ func (s *Storage) accessToken(applicationID, refreshTokenID, subject string, aud Audience: audience, Expiration: time.Now().Add(5 * time.Minute), Scopes: scopes, + Resource: resources, } s.tokens[token.ID] = token return token, nil diff --git a/example/server/storage/token.go b/example/server/storage/token.go index beab38cc..746fec8e 100644 --- a/example/server/storage/token.go +++ b/example/server/storage/token.go @@ -10,6 +10,9 @@ type Token struct { Audience []string Expiration time.Time Scopes []string + + // Resource holds the resource indicators (RFC 8707) the token was requested for. + Resource []string } type RefreshToken struct { @@ -23,4 +26,8 @@ type RefreshToken struct { Expiration time.Time Scopes []string AccessToken string // Token.ID + + // Resource holds the resource indicators (RFC 8707) the refresh token was + // granted for. A refresh token request may narrow them down again. + Resource []string }