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
67 changes: 66 additions & 1 deletion example/server/storage/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package storage

import (
"log/slog"
"slices"
"time"

"golang.org/x/text/language"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -166,6 +186,7 @@ func authRequestToInternal(authReq *oidc.AuthRequest, userID string) *AuthReques
ResponseMode: authReq.ResponseMode,
Nonce: authReq.Nonce,
CodeChallenge: codeChallenge,
Resource: authReq.Resource,
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
58 changes: 58 additions & 0 deletions example/server/storage/oidc_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
24 changes: 19 additions & 5 deletions example/server/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand All @@ -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
Expand Down Expand Up @@ -821,6 +823,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()

Expand All @@ -837,7 +847,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,
},
}
Expand Down
7 changes: 7 additions & 0 deletions example/server/storage/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
14 changes: 14 additions & 0 deletions pkg/client/rp/relying_party.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions pkg/client/rp/relying_party_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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"))
}
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
19 changes: 19 additions & 0 deletions pkg/oidc/device_authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
}
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
Loading
Loading