Skip to content
Open
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
13 changes: 13 additions & 0 deletions pkg/oidc/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
10 changes: 10 additions & 0 deletions pkg/oidc/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
35 changes: 35 additions & 0 deletions pkg/op/auth_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
121 changes: 121 additions & 0 deletions pkg/op/auth_request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions pkg/op/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -93,13 +94,25 @@ 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(),
BackChannelLogoutSessionSupported: config.BackChannelLogoutSessionSupported(),
}
}

// 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 {
Expand Down
44 changes: 44 additions & 0 deletions pkg/op/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/op/op.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ type Config struct {
SupportedUILocales []language.Tag
SupportedClaims []string
SupportedScopes []string
ResourceIndicatorsSupported bool
DeviceAuthorization DeviceAuthorizationConfig
BackChannelLogoutSupported bool
BackChannelLogoutSessionSupported bool
Expand Down
Loading