diff --git a/authorize_request_handler.go b/authorize_request_handler.go index e117b8f5a..8859554e5 100644 --- a/authorize_request_handler.go +++ b/authorize_request_handler.go @@ -275,7 +275,51 @@ func (f *Fosite) validateResponseMode(r *http.Request, request *AuthorizeRequest return nil } +func (f *Fosite) authorizeRequestFromPAR(ctx context.Context, r *http.Request, request *AuthorizeRequest) (bool, error) { + requestURI := r.Form.Get("request_uri") + if requestURI == "" || !strings.HasPrefix(requestURI, f.PushedAuthorizationRequestURIPrefix) { + // nothing to do here + return false, nil + } + + clientID := r.Form.Get("client_id") + + storage, ok := f.Store.(PARStorage) + if !ok { + return false, errorsx.WithStack(ErrServerError.WithHint("Invalid storage").WithDebug("PARStorage not implemented")) + } + + // hydrate the requester + var parRequest AuthorizeRequester + var err error + if parRequest, err = storage.GetPARSession(ctx, requestURI); err != nil { + return false, errorsx.WithStack(ErrInvalidRequestURI.WithHint("Invalid PAR session").WithWrap(err).WithDebug(err.Error())) + } + + // hydrate the request object + request.Merge(parRequest) + request.RedirectURI = parRequest.GetRedirectURI() + request.ResponseTypes = parRequest.GetResponseTypes() + request.State = parRequest.GetState() + request.ResponseMode = parRequest.GetResponseMode() + + if err := storage.DeletePARSession(ctx, requestURI); err != nil { + return false, errorsx.WithStack(ErrServerError.WithWrap(err).WithDebug(err.Error())) + } + + // validate the clients match + if clientID != request.GetClient().GetID() { + return false, errorsx.WithStack(ErrInvalidRequest.WithHint("The 'client_id' must match the pushed authorization request.")) + } + + return true, nil +} + func (f *Fosite) NewAuthorizeRequest(ctx context.Context, r *http.Request) (AuthorizeRequester, error) { + return f.newAuthorizeRequest(ctx, r, false) +} + +func (f *Fosite) newAuthorizeRequest(ctx context.Context, r *http.Request, isPARRequest bool) (AuthorizeRequester, error) { request := NewAuthorizeRequest() request.Request.Lang = i18n.GetLangFromRequest(f.MessageCatalog, r) @@ -290,6 +334,18 @@ func (f *Fosite) NewAuthorizeRequest(ctx context.Context, r *http.Request) (Auth // Save state to the request to be returned in error conditions (https://github.com/ory/hydra/issues/1642) request.State = request.Form.Get("state") + // Check if this is a continuation from a pushed authorization request + if !isPARRequest { + if isPAR, err := f.authorizeRequestFromPAR(ctx, r, request); err != nil { + return request, err + } else if isPAR { + // No need to continue + return request, nil + } else if f.EnforcePushedAuthorization { + return request, errorsx.WithStack(ErrInvalidRequest.WithHint("Pushed authorization request is enforced.")) + } + } + client, err := f.Store.GetClient(ctx, request.GetRequestForm().Get("client_id")) if err != nil { return request, errorsx.WithStack(ErrInvalidClient.WithHint("The requested OAuth 2.0 Client does not exist.").WithWrap(err).WithDebug(err.Error())) diff --git a/compose/compose.go b/compose/compose.go index 731c91645..82b575c8b 100644 --- a/compose/compose.go +++ b/compose/compose.go @@ -23,11 +23,18 @@ package compose import ( "crypto/rsa" + "time" "github.com/ory/fosite" "github.com/ory/fosite/token/jwt" ) +const ( + defaultPARPrefix = "urn:ietf:params:oauth:request_uri:" + defaultPARContextLifetime = 5 * time.Minute + defaultJWTLifetimeToleranceWindow = 24 * time.Hour +) + type Factory func(config *Config, storage interface{}, strategy interface{}) interface{} // Compose takes a config, a storage, a strategy and handlers to instantiate an OAuth2Provider: @@ -53,28 +60,32 @@ type Factory func(config *Config, storage interface{}, strategy interface{}) int // // Compose makes use of interface{} types in order to be able to handle a all types of stores, strategies and handlers. func Compose(config *Config, storage interface{}, strategy interface{}, hasher fosite.Hasher, factories ...Factory) fosite.OAuth2Provider { + setDefaults(config) if hasher == nil { hasher = &fosite.BCrypt{WorkFactor: config.GetHashCost()} } f := &fosite.Fosite{ - Store: storage.(fosite.Storage), - AuthorizeEndpointHandlers: fosite.AuthorizeEndpointHandlers{}, - TokenEndpointHandlers: fosite.TokenEndpointHandlers{}, - TokenIntrospectionHandlers: fosite.TokenIntrospectionHandlers{}, - RevocationHandlers: fosite.RevocationHandlers{}, - Hasher: hasher, - ScopeStrategy: config.GetScopeStrategy(), - AudienceMatchingStrategy: config.GetAudienceStrategy(), - SendDebugMessagesToClients: config.SendDebugMessagesToClients, - TokenURL: config.TokenURL, - JWKSFetcherStrategy: config.GetJWKSFetcherStrategy(), - MinParameterEntropy: config.GetMinParameterEntropy(), - UseLegacyErrorFormat: config.UseLegacyErrorFormat, - ClientAuthenticationStrategy: config.GetClientAuthenticationStrategy(), - ResponseModeHandlerExtension: config.ResponseModeHandlerExtension, - MessageCatalog: config.MessageCatalog, - FormPostHTMLTemplate: config.FormPostHTMLTemplate, + Store: storage.(fosite.Storage), + AuthorizeEndpointHandlers: fosite.AuthorizeEndpointHandlers{}, + TokenEndpointHandlers: fosite.TokenEndpointHandlers{}, + TokenIntrospectionHandlers: fosite.TokenIntrospectionHandlers{}, + RevocationHandlers: fosite.RevocationHandlers{}, + Hasher: hasher, + ScopeStrategy: config.GetScopeStrategy(), + AudienceMatchingStrategy: config.GetAudienceStrategy(), + SendDebugMessagesToClients: config.SendDebugMessagesToClients, + TokenURL: config.TokenURL, + JWKSFetcherStrategy: config.GetJWKSFetcherStrategy(), + MinParameterEntropy: config.GetMinParameterEntropy(), + UseLegacyErrorFormat: config.UseLegacyErrorFormat, + ClientAuthenticationStrategy: config.GetClientAuthenticationStrategy(), + ResponseModeHandlerExtension: config.ResponseModeHandlerExtension, + MessageCatalog: config.MessageCatalog, + FormPostHTMLTemplate: config.FormPostHTMLTemplate, + PushedAuthorizationRequestURIPrefix: config.PushedAuthorizationRequestURIPrefix, + PushedAuthorizationContextLifespan: config.PushedAuthorizationContextLifespan, + EnforcePushedAuthorization: config.EnforcePushedAuthorization, } for _, factory := range factories { @@ -91,6 +102,9 @@ func Compose(config *Config, storage interface{}, strategy interface{}, hasher f if rh, ok := res.(fosite.RevocationHandler); ok { f.RevocationHandlers.Append(rh) } + if ph, ok := res.(fosite.PushedAuthorizeEndpointHandler); ok { + f.PushedAuthorizeEndpointHandlers.Append(ph) + } } return f @@ -128,3 +142,13 @@ func ComposeAllEnabled(config *Config, storage interface{}, secret []byte, key * OAuth2PKCEFactory, ) } + +func setDefaults(config *Config) { + if config.PushedAuthorizationRequestURIPrefix == "" { + config.PushedAuthorizationRequestURIPrefix = defaultPARPrefix + } + + if config.PushedAuthorizationContextLifespan <= 0 { + config.PushedAuthorizationContextLifespan = defaultPARContextLifetime + } +} diff --git a/compose/compose_par.go b/compose/compose_par.go new file mode 100644 index 000000000..03f1ca201 --- /dev/null +++ b/compose/compose_par.go @@ -0,0 +1,17 @@ +package compose + +import ( + "github.com/ory/fosite/handler/par" +) + +// PushedAuthorizeHandlerFactory creates the basic PAR handler +func PushedAuthorizeHandlerFactory(config *Config, storage interface{}, strategy interface{}) interface{} { + return &par.PushedAuthorizeHandler{ + Storage: storage, + RequestURIPrefix: config.PushedAuthorizationRequestURIPrefix, + PARContextLifetime: config.PushedAuthorizationContextLifespan, + ScopeStrategy: config.GetScopeStrategy(), + AudienceMatchingStrategy: config.GetAudienceStrategy(), + IsRedirectURISecure: config.GetRedirectSecureChecker(), + } +} diff --git a/compose/config.go b/compose/config.go index b4a5bbd1c..d00ad6a52 100644 --- a/compose/config.go +++ b/compose/config.go @@ -125,6 +125,16 @@ type Config struct { // FormPostHTMLTemplate sets html template for rendering the authorization response when the request has response_mode=form_post. FormPostHTMLTemplate *template.Template + + // PushedAuthorizationRequestURIPrefix is the URI prefix for the PAR request_uri. + // This is defaulted to 'urn:ietf:params:oauth:request_uri:'. + PushedAuthorizationRequestURIPrefix string + + // PushedAuthorizationContextLifespan is the lifespan of the PAR context + PushedAuthorizationContextLifespan time.Duration + + // EnforcePushedAuthorization enforces pushed authorization request for /authorize + EnforcePushedAuthorization bool } // GetScopeStrategy returns the scope strategy to be used. Defaults to glob scope strategy. diff --git a/context.go b/context.go index 48558e9a3..df877cfed 100644 --- a/context.go +++ b/context.go @@ -35,4 +35,6 @@ const ( AccessResponseContextKey = ContextKey("accessResponse") AuthorizeRequestContextKey = ContextKey("authorizeRequest") AuthorizeResponseContextKey = ContextKey("authorizeResponse") + // PushedAuthorizeResponseContextKey is the response context + PushedAuthorizeResponseContextKey = ContextKey("pushedAuthorizeResponse") ) diff --git a/fosite.go b/fosite.go index d19753234..8ddd00520 100644 --- a/fosite.go +++ b/fosite.go @@ -25,6 +25,7 @@ import ( "html/template" "net/http" "reflect" + "time" "github.com/ory/fosite/i18n" ) @@ -85,19 +86,34 @@ func (t *RevocationHandlers) Append(h RevocationHandler) { *t = append(*t, h) } +// PushedAuthorizeEndpointHandlers is a list of PushedAuthorizeEndpointHandler +type PushedAuthorizeEndpointHandlers []PushedAuthorizeEndpointHandler + +// Append adds an AuthorizeEndpointHandler to this list. Ignores duplicates based on reflect.TypeOf. +func (a *PushedAuthorizeEndpointHandlers) Append(h PushedAuthorizeEndpointHandler) { + for _, this := range *a { + if reflect.TypeOf(this) == reflect.TypeOf(h) { + return + } + } + + *a = append(*a, h) +} + // Fosite implements OAuth2Provider. type Fosite struct { - Store Storage - AuthorizeEndpointHandlers AuthorizeEndpointHandlers - TokenEndpointHandlers TokenEndpointHandlers - TokenIntrospectionHandlers TokenIntrospectionHandlers - RevocationHandlers RevocationHandlers - Hasher Hasher - ScopeStrategy ScopeStrategy - AudienceMatchingStrategy AudienceMatchingStrategy - JWKSFetcherStrategy JWKSFetcherStrategy - HTTPClient *http.Client - UseLegacyErrorFormat bool + Store Storage + AuthorizeEndpointHandlers AuthorizeEndpointHandlers + TokenEndpointHandlers TokenEndpointHandlers + TokenIntrospectionHandlers TokenIntrospectionHandlers + RevocationHandlers RevocationHandlers + PushedAuthorizeEndpointHandlers PushedAuthorizeEndpointHandlers + Hasher Hasher + ScopeStrategy ScopeStrategy + AudienceMatchingStrategy AudienceMatchingStrategy + JWKSFetcherStrategy JWKSFetcherStrategy + HTTPClient *http.Client + UseLegacyErrorFormat bool // TokenURL is the the URL of the Authorization Server's Token Endpoint. TokenURL string @@ -120,6 +136,16 @@ type Fosite struct { // MessageCatalog is the catalog of messages used for i18n MessageCatalog i18n.MessageCatalog + + // PushedAuthorizationRequestURIPrefix is the URI prefix for the PAR request_uri. + // This is defaulted to 'urn:ietf:params:oauth:request_uri:'. + PushedAuthorizationRequestURIPrefix string + + // PushedAuthorizationContextLifespan is the lifespan of the PAR context + PushedAuthorizationContextLifespan time.Duration + + // EnforcePushedAuthorization enforces pushed authorization request for /authorize + EnforcePushedAuthorization bool } const MinParameterEntropy = 8 diff --git a/handler.go b/handler.go index a73dc760b..fe3025836 100644 --- a/handler.go +++ b/handler.go @@ -76,3 +76,11 @@ type RevocationHandler interface { // RevokeToken handles access and refresh token revocation. RevokeToken(ctx context.Context, token string, tokenType TokenType, client Client) error } + +// PushedAuthorizeEndpointHandler is the interface that handles PAR (https://datatracker.ietf.org/doc/html/rfc9126) +type PushedAuthorizeEndpointHandler interface { + // HandlePushedAuthorizeRequest handles a pushed authorize endpoint request. To extend the handler's capabilities, the http request + // is passed along, if further information retrieval is required. If the handler feels that he is not responsible for + // the pushed authorize request, he must return nil and NOT modify session nor responder neither requester. + HandlePushedAuthorizeEndpointRequest(ctx context.Context, requester AuthorizeRequester, responder PushedAuthorizeResponder) error +} diff --git a/handler/par/flow_pushed_authorize.go b/handler/par/flow_pushed_authorize.go new file mode 100644 index 000000000..032d70b48 --- /dev/null +++ b/handler/par/flow_pushed_authorize.go @@ -0,0 +1,88 @@ +package par + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/token/hmac" + "github.com/ory/x/errorsx" +) + +const ( + defaultPARKeyLength = 32 +) + +var b64 = base64.URLEncoding.WithPadding(base64.NoPadding) + +// PushedAuthorizeHandler handles the PAR request +type PushedAuthorizeHandler struct { + Storage interface{} + PARContextLifetime time.Duration + RequestURIPrefix string + ScopeStrategy fosite.ScopeStrategy + AudienceMatchingStrategy fosite.AudienceMatchingStrategy + + IsRedirectURISecure func(*url.URL) bool +} + +// HandlePushedAuthorizeEndpointRequest handles a pushed authorize endpoint request. To extend the handler's capabilities, the http request +// is passed along, if further information retrieval is required. If the handler feels that he is not responsible for +// the pushed authorize request, he must return nil and NOT modify session nor responder neither requester. +func (c *PushedAuthorizeHandler) HandlePushedAuthorizeEndpointRequest(ctx context.Context, ar fosite.AuthorizeRequester, resp fosite.PushedAuthorizeResponder) error { + storage, ok := c.Storage.(fosite.PARStorage) + if !ok { + return errorsx.WithStack(fosite.ErrServerError.WithHint("Invalid storage type")) + } + + if !ar.GetResponseTypes().HasOneOf("token", "code", "id_token") { + return nil + } + + if !c.secureChecker()(ar.GetRedirectURI()) { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Redirect URL is using an insecure protocol, http is only allowed for hosts with suffix `localhost`, for example: http://myapp.localhost/.")) + } + + client := ar.GetClient() + for _, scope := range ar.GetRequestedScopes() { + if !c.ScopeStrategy(client.GetScopes(), scope) { + return errorsx.WithStack(fosite.ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", scope)) + } + } + + if err := c.AudienceMatchingStrategy(client.GetAudience(), ar.GetRequestedAudience()); err != nil { + return err + } + + expiresIn := c.PARContextLifetime + if ar.GetSession() != nil { + ar.GetSession().SetExpiresAt(fosite.PushedAuthorizeRequestContext, time.Now().UTC().Add(expiresIn)) + } + + // generate an ID + stateKey, err := hmac.RandomBytes(defaultPARKeyLength) + if err != nil { + return errorsx.WithStack(fosite.ErrInsufficientEntropy.WithHint("Unable to generate the random part of the request_uri.").WithWrap(err).WithDebug(err.Error())) + } + + requestURI := fmt.Sprintf("%s%s", c.RequestURIPrefix, b64.EncodeToString(stateKey)) + + // store + if err = storage.CreatePARSession(ctx, requestURI, ar); err != nil { + return errorsx.WithStack(fosite.ErrServerError.WithHint("Unable to store the PAR session").WithWrap(err).WithDebug(err.Error())) + } + + resp.SetRequestURI(requestURI) + resp.SetExpiresIn(int(expiresIn.Seconds())) + return nil +} + +func (c *PushedAuthorizeHandler) secureChecker() func(*url.URL) bool { + if c.IsRedirectURISecure == nil { + c.IsRedirectURISecure = fosite.IsRedirectURISecure + } + return c.IsRedirectURISecure +} diff --git a/handler/par/flow_pushed_authorize_test.go b/handler/par/flow_pushed_authorize_test.go new file mode 100644 index 000000000..50c118db7 --- /dev/null +++ b/handler/par/flow_pushed_authorize_test.go @@ -0,0 +1,130 @@ +package par_test + +import ( + "context" + "net/url" + "strings" + "testing" + "time" + + "github.com/ory/fosite/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/fosite" + . "github.com/ory/fosite/handler/par" +) + +func parseURL(uu string) *url.URL { + u, _ := url.Parse(uu) + return u +} + +func TestAuthorizeCode_HandleAuthorizeEndpointRequest(t *testing.T) { + requestURIPrefix := "urn:ietf:params:oauth:request_uri_diff:" + store := storage.NewMemoryStore() + handler := PushedAuthorizeHandler{ + Storage: store, + PARContextLifetime: 30 * time.Minute, + RequestURIPrefix: requestURIPrefix, + ScopeStrategy: fosite.HierarchicScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + } + for _, c := range []struct { + handler PushedAuthorizeHandler + areq *fosite.AuthorizeRequest + description string + expectErr error + expect func(t *testing.T, areq *fosite.AuthorizeRequest, aresp *fosite.PushedAuthorizeResponse) + }{ + { + handler: handler, + areq: &fosite.AuthorizeRequest{ + ResponseTypes: fosite.Arguments{""}, + Request: *fosite.NewRequest(), + }, + description: "should pass because not responsible for handling an empty response type", + }, + { + handler: handler, + areq: &fosite.AuthorizeRequest{ + ResponseTypes: fosite.Arguments{"foo"}, + Request: *fosite.NewRequest(), + }, + description: "should pass because not responsible for handling an invalid response type", + }, + { + handler: handler, + areq: &fosite.AuthorizeRequest{ + ResponseTypes: fosite.Arguments{"code"}, + Request: fosite.Request{ + Client: &fosite.DefaultClient{ + ResponseTypes: fosite.Arguments{"code"}, + RedirectURIs: []string{"http://asdf.com/cb"}, + }, + }, + RedirectURI: parseURL("http://asdf.com/cb"), + }, + description: "should fail because redirect uri is not https", + expectErr: fosite.ErrInvalidRequest, + }, + { + handler: handler, + areq: &fosite.AuthorizeRequest{ + ResponseTypes: fosite.Arguments{"code"}, + Request: fosite.Request{ + Client: &fosite.DefaultClient{ + ResponseTypes: fosite.Arguments{"code"}, + RedirectURIs: []string{"https://asdf.com/cb"}, + Audience: []string{"https://www.ory.sh/api"}, + }, + RequestedAudience: []string{"https://www.ory.sh/not-api"}, + }, + RedirectURI: parseURL("https://asdf.com/cb"), + }, + description: "should fail because audience doesn't match", + expectErr: fosite.ErrInvalidRequest, + }, + { + handler: handler, + areq: &fosite.AuthorizeRequest{ + ResponseTypes: fosite.Arguments{"code"}, + Request: fosite.Request{ + Client: &fosite.DefaultClient{ + ResponseTypes: fosite.Arguments{"code"}, + RedirectURIs: []string{"https://asdf.de/cb"}, + Audience: []string{"https://www.ory.sh/api"}, + }, + RequestedAudience: []string{"https://www.ory.sh/api"}, + GrantedScope: fosite.Arguments{"a", "b"}, + Session: &fosite.DefaultSession{ + ExpiresAt: map[fosite.TokenType]time.Time{fosite.AccessToken: time.Now().UTC().Add(time.Hour)}, + }, + RequestedAt: time.Now().UTC(), + }, + State: "superstate", + RedirectURI: parseURL("https://asdf.de/cb"), + }, + description: "should pass", + expect: func(t *testing.T, areq *fosite.AuthorizeRequest, aresp *fosite.PushedAuthorizeResponse) { + requestURI := aresp.RequestURI + assert.NotEmpty(t, requestURI) + assert.True(t, strings.HasPrefix(requestURI, requestURIPrefix), "requestURI does not match: %s", requestURI) + }, + }, + } { + t.Run("case="+c.description, func(t *testing.T) { + aresp := &fosite.PushedAuthorizeResponse{} + err := c.handler.HandlePushedAuthorizeEndpointRequest(context.Background(), c.areq, aresp) + if c.expectErr != nil { + require.EqualError(t, err, c.expectErr.Error()) + } else { + require.NoError(t, err) + } + + if c.expect != nil { + c.expect(t, c.areq, aresp) + } + }) + } +} diff --git a/internal/pushed_authorize_handler.go b/internal/pushed_authorize_handler.go new file mode 100644 index 000000000..77b77a143 --- /dev/null +++ b/internal/pushed_authorize_handler.go @@ -0,0 +1,47 @@ +package internal + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + + fosite "github.com/ory/fosite" +) + +// MockPushedAuthorizeEndpointHandler is a mock of PushedAuthorizeEndpointHandler interface +type MockPushedAuthorizeEndpointHandler struct { + ctrl *gomock.Controller + recorder *MockPushedAuthorizeEndpointHandlerMockRecorder +} + +// MockPushedAuthorizeEndpointHandlerMockRecorder is the mock recorder for PushedMockAuthorizeEndpointHandler +type MockPushedAuthorizeEndpointHandlerMockRecorder struct { + mock *MockPushedAuthorizeEndpointHandler +} + +// NewMockPushedAuthorizeEndpointHandler creates a new mock instance +func NewMockPushedAuthorizeEndpointHandler(ctrl *gomock.Controller) *MockPushedAuthorizeEndpointHandler { + mock := &MockPushedAuthorizeEndpointHandler{ctrl: ctrl} + mock.recorder = &MockPushedAuthorizeEndpointHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockPushedAuthorizeEndpointHandler) EXPECT() *MockPushedAuthorizeEndpointHandlerMockRecorder { + return m.recorder +} + +// HandlePushedAuthorizeEndpointRequest mocks base method +func (m *MockPushedAuthorizeEndpointHandler) HandlePushedAuthorizeEndpointRequest(arg0 context.Context, arg1 fosite.AuthorizeRequester, arg2 fosite.PushedAuthorizeResponder) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandlePushedAuthorizeEndpointRequest", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// HandlePushedAuthorizeEndpointRequest indicates an expected call of HandlePushedAuthorizeEndpointRequest +func (mr *MockPushedAuthorizeEndpointHandlerMockRecorder) HandlePushedAuthorizeEndpointRequest(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandlePushedAuthorizeEndpointRequest", reflect.TypeOf((*MockPushedAuthorizeEndpointHandler)(nil).HandlePushedAuthorizeEndpointRequest), arg0, arg1, arg2) +} diff --git a/oauth2.go b/oauth2.go index 9d5d6f3e9..1acfd9e25 100644 --- a/oauth2.go +++ b/oauth2.go @@ -39,12 +39,14 @@ const ( RefreshToken TokenType = "refresh_token" AuthorizeCode TokenType = "authorize_code" IDToken TokenType = "id_token" + // PushedAuthorizeRequestContext represents the PAR context object + PushedAuthorizeRequestContext TokenType = "par_context" BearerAccessToken string = "bearer" ) // OAuth2Provider is an interface that enables you to write OAuth2 handlers with only a few lines of code. -// Check fosite.Fosite for an implementation of this interface. +// Check Fosite for an implementation of this interface. type OAuth2Provider interface { // NewAuthorizeRequest returns an AuthorizeRequest. // @@ -162,6 +164,18 @@ type OAuth2Provider interface { // WriteIntrospectionResponse responds with token metadata discovered by token introspection as defined in // https://tools.ietf.org/search/rfc7662#section-2.2 WriteIntrospectionResponse(rw http.ResponseWriter, r IntrospectionResponder) + + // NewPushedAuthorizeRequest validates the request and produces an AuthorizeRequester object that can be stored + NewPushedAuthorizeRequest(ctx context.Context, r *http.Request) (AuthorizeRequester, error) + + // NewPushedAuthorizeResponse executes the handlers and builds the response + NewPushedAuthorizeResponse(ctx context.Context, ar AuthorizeRequester, session Session) (PushedAuthorizeResponder, error) + + // WritePushedAuthorizeResponse writes the PAR response + WritePushedAuthorizeResponse(rw http.ResponseWriter, ar AuthorizeRequester, resp PushedAuthorizeResponder) + + // WritePushedAuthorizeError writes the PAR error + WritePushedAuthorizeError(rw http.ResponseWriter, ar AuthorizeRequester, err error) } // IntrospectionResponder is the response object that will be returned when token introspection was successful, @@ -328,6 +342,33 @@ type AuthorizeResponder interface { AddParameter(key, value string) } +// PushedAuthorizeResponder is the response object for PAR +type PushedAuthorizeResponder interface { + // GetRequestURI returns the request_uri + GetRequestURI() string + // SetRequestURI sets the request_uri + SetRequestURI(requestURI string) + // GetExpiresIn gets the expires_in + GetExpiresIn() int + // SetExpiresIn sets the expires_in + SetExpiresIn(seconds int) + + // GetHeader returns the response's header + GetHeader() (header http.Header) + + // AddHeader adds an header key value pair to the response + AddHeader(key, value string) + + // SetExtra sets a key value pair for the response. + SetExtra(key string, value interface{}) + + // GetExtra returns a key's value. + GetExtra(key string) interface{} + + // ToMap converts the response to a map. + ToMap() map[string]interface{} +} + // G11NContext is the globalization context type G11NContext interface { // GetLang returns the current language in the context diff --git a/pushed_authorize_request_handler.go b/pushed_authorize_request_handler.go new file mode 100644 index 000000000..7129706f9 --- /dev/null +++ b/pushed_authorize_request_handler.go @@ -0,0 +1,63 @@ +package fosite + +import ( + "context" + "errors" + "net/http" + + "github.com/ory/fosite/i18n" + "github.com/ory/x/errorsx" +) + +// NewPushedAuthorizeRequest validates the request and produces an AuthorizeRequester object that can be stored +func (f *Fosite) NewPushedAuthorizeRequest(ctx context.Context, r *http.Request) (AuthorizeRequester, error) { + request := NewAuthorizeRequest() + request.Request.Lang = i18n.GetLangFromRequest(f.MessageCatalog, r) + + if r.Method != "" && r.Method != "POST" { + return request, errorsx.WithStack(ErrInvalidRequest.WithHintf("HTTP method is '%s', expected 'POST'.", r.Method)) + } + + if err := r.ParseMultipartForm(1 << 20); err != nil && err != http.ErrNotMultipart { + return request, errorsx.WithStack(ErrInvalidRequest.WithHint("Unable to parse HTTP body, make sure to send a properly formatted form request body.").WithWrap(err).WithDebug(err.Error())) + } + request.Form = r.Form + request.State = request.Form.Get("state") + + // Authenticate the client in the same way as at the token endpoint + // (Section 2.3 of [RFC6749]). + client, err := f.AuthenticateClient(ctx, r, r.Form) + if err != nil { + var rfcerr *RFC6749Error + if errors.As(err, &rfcerr) && rfcerr.ErrorField != ErrInvalidClient.ErrorField { + return request, errorsx.WithStack(ErrInvalidClient.WithHint("The requested OAuth 2.0 Client could not be authenticated.").WithWrap(err).WithDebug(err.Error())) + } + + return request, err + } + request.Client = client + + // Reject the request if the "request_uri" authorization request + // parameter is provided. + if r.Form.Get("request_uri") != "" { + return request, errorsx.WithStack(ErrInvalidRequest.WithHint("The request must not contain 'request_uri'.")) + } + + // For private_key_jwt or basic auth client authentication, "client_id" may not inside the form + // However this is required by NewAuthorizeRequest implementation + if len(r.Form.Get("client_id")) == 0 { + r.Form.Set("client_id", client.GetID()) + } + + // Validate as if this is a new authorize request + fr, err := f.newAuthorizeRequest(ctx, r, true) + if err != nil { + return fr, err + } + + if fr.GetRequestedScopes().Has("openid") && r.Form.Get("redirect_uri") == "" { + return fr, errorsx.WithStack(ErrInvalidRequest.WithHint("Redirect URI information is required.")) + } + + return fr, nil +} diff --git a/pushed_authorize_request_handler_test.go b/pushed_authorize_request_handler_test.go new file mode 100644 index 000000000..3bf3e9fa4 --- /dev/null +++ b/pushed_authorize_request_handler_test.go @@ -0,0 +1,652 @@ +package fosite_test + +import ( + "fmt" + "net/http" + "net/url" + "runtime/debug" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + . "github.com/ory/fosite" + "github.com/ory/fosite/internal" +) + +// Should pass +// +// * https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#Terminology +// The OAuth 2.0 specification allows for registration of space-separated response_type parameter values. +// If a Response Type contains one of more space characters (%20), it is compared as a space-delimited list of +// values in which the order of values does not matter. +func TestNewPushedAuthorizeRequest(t *testing.T) { + var store *internal.MockStorage + var hasher *internal.MockHasher + ctx := NewContext() + + redir, _ := url.Parse("https://foo.bar/cb") + specialCharRedir, _ := url.Parse("web+application://callback") + for _, c := range []struct { + desc string + conf *Fosite + r *http.Request + query url.Values + expectedError error + mock func() + expect *AuthorizeRequest + }{ + /* empty request */ + { + desc: "empty request fails", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + r: &http.Request{}, + expectedError: ErrInvalidClient, + mock: func() {}, + }, + /* invalid redirect uri */ + { + desc: "invalid redirect uri fails", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{"redirect_uri": []string{"invalid"}}, + expectedError: ErrInvalidClient, + mock: func() {}, + }, + /* invalid client */ + { + desc: "invalid client fails", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{"redirect_uri": []string{"https://foo.bar/cb"}}, + expectedError: ErrInvalidClient, + mock: func() {}, + }, + /* redirect client mismatch */ + { + desc: "client and request redirects mismatch", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "client_id": []string{"1234"}, + "client_secret": []string{"1234"}, + }, + expectedError: ErrInvalidRequest, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"invalid"}, Scopes: []string{}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + }, + /* redirect client mismatch */ + { + desc: "client and request redirects mismatch", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": []string{""}, + "client_id": []string{"1234"}, + "client_secret": []string{"1234"}, + }, + expectedError: ErrInvalidRequest, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"invalid"}, Scopes: []string{}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + }, + /* redirect client mismatch */ + { + desc: "client and request redirects mismatch", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": []string{"https://foo.bar/cb"}, + "client_id": []string{"1234"}, + "client_secret": []string{"1234"}, + }, + expectedError: ErrInvalidRequest, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"invalid"}, Scopes: []string{}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + }, + /* no state */ + { + desc: "no state", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": []string{"https://foo.bar/cb"}, + "client_id": []string{"1234"}, + "client_secret": []string{"1234"}, + "response_type": []string{"code"}, + }, + expectedError: ErrInvalidState, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"https://foo.bar/cb"}, Scopes: []string{}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + }, + /* short state */ + { + desc: "short state", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code"}, + "state": {"short"}, + }, + expectedError: ErrInvalidState, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"https://foo.bar/cb"}, Scopes: []string{}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + }, + /* fails because scope not given */ + { + desc: "should fail because client does not have scope baz", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar baz"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"https://foo.bar/cb"}, Scopes: []string{"foo", "bar"}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expectedError: ErrInvalidScope, + }, + /* fails because scope not given */ + { + desc: "should fail because client does not have scope baz", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "audience": {"https://cloud.ory.sh/api https://www.ory.sh/api"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{ + RedirectURIs: []string{"https://foo.bar/cb"}, Scopes: []string{"foo", "bar"}, + Audience: []string{"https://cloud.ory.sh/api"}, + Secret: []byte("1234"), + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expectedError: ErrInvalidRequest, + }, + /* success case */ + { + desc: "should pass", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "audience": {"https://cloud.ory.sh/api https://www.ory.sh/api"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{ + ResponseTypes: []string{"code token"}, + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expect: &AuthorizeRequest{ + RedirectURI: redir, + ResponseTypes: []string{"code", "token"}, + State: "strong-state", + Request: Request{ + Client: &DefaultClient{ + ResponseTypes: []string{"code token"}, RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + RequestedScope: []string{"foo", "bar"}, + RequestedAudience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + }, + }, + }, + /* repeated audience parameter */ + { + desc: "repeated audience parameter", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "audience": {"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{ + ResponseTypes: []string{"code token"}, + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expect: &AuthorizeRequest{ + RedirectURI: redir, + ResponseTypes: []string{"code", "token"}, + State: "strong-state", + Request: Request{ + Client: &DefaultClient{ + ResponseTypes: []string{"code token"}, RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + RequestedScope: []string{"foo", "bar"}, + RequestedAudience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + }, + }, + }, + /* repeated audience parameter with tricky values */ + { + desc: "repeated audience parameter with tricky values", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: ExactAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "audience": {"test value", ""}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{ + ResponseTypes: []string{"code token"}, + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"test value"}, + Secret: []byte("1234"), + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expect: &AuthorizeRequest{ + RedirectURI: redir, + ResponseTypes: []string{"code", "token"}, + State: "strong-state", + Request: Request{ + Client: &DefaultClient{ + ResponseTypes: []string{"code token"}, RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"test value"}, + Secret: []byte("1234"), + }, + RequestedScope: []string{"foo", "bar"}, + RequestedAudience: []string{"test value"}, + }, + }, + }, + /* redirect_uri with special character in protocol*/ + { + desc: "redirect_uri with special character", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"web+application://callback"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "audience": {"https://cloud.ory.sh/api https://www.ory.sh/api"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{ + ResponseTypes: []string{"code token"}, + RedirectURIs: []string{"web+application://callback"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expect: &AuthorizeRequest{ + RedirectURI: specialCharRedir, + ResponseTypes: []string{"code", "token"}, + State: "strong-state", + Request: Request{ + Client: &DefaultClient{ + ResponseTypes: []string{"code token"}, RedirectURIs: []string{"web+application://callback"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + RequestedScope: []string{"foo", "bar"}, + RequestedAudience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + }, + }, + }, + /* audience with double spaces between values */ + { + desc: "audience with double spaces between values", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "audience": {"https://cloud.ory.sh/api https://www.ory.sh/api"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{ + ResponseTypes: []string{"code token"}, + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expect: &AuthorizeRequest{ + RedirectURI: redir, + ResponseTypes: []string{"code", "token"}, + State: "strong-state", + Request: Request{ + Client: &DefaultClient{ + ResponseTypes: []string{"code token"}, RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + RequestedScope: []string{"foo", "bar"}, + RequestedAudience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + }, + }, + }, + /* fails because unknown response_mode*/ + { + desc: "should fail because unknown response_mode", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "response_mode": {"unknown"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"https://foo.bar/cb"}, Scopes: []string{"foo", "bar"}, ResponseTypes: []string{"code token"}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expectedError: ErrUnsupportedResponseMode, + }, + /* fails because response_mode is requested but the OAuth 2.0 client doesn't support response mode */ + { + desc: "should fail because response_mode is requested but the OAuth 2.0 client doesn't support response mode", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "response_mode": {"form_post"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"https://foo.bar/cb"}, Scopes: []string{"foo", "bar"}, ResponseTypes: []string{"code token"}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expectedError: ErrUnsupportedResponseMode, + }, + /* fails because requested response mode is not allowed */ + { + desc: "should fail because requested response mode is not allowed", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "response_mode": {"form_post"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultResponseModeClient{ + DefaultClient: &DefaultClient{ + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + ResponseTypes: []string{"code token"}, + Secret: []byte("1234"), + }, + ResponseModes: []ResponseModeType{ResponseModeQuery}, + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expectedError: ErrUnsupportedResponseMode, + }, + /* success with response mode */ + { + desc: "success with response mode", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "response_mode": {"form_post"}, + "audience": {"https://cloud.ory.sh/api https://www.ory.sh/api"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultResponseModeClient{ + DefaultClient: &DefaultClient{ + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + ResponseTypes: []string{"code token"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + ResponseModes: []ResponseModeType{ResponseModeFormPost}, + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expect: &AuthorizeRequest{ + RedirectURI: redir, + ResponseTypes: []string{"code", "token"}, + State: "strong-state", + Request: Request{ + Client: &DefaultResponseModeClient{ + DefaultClient: &DefaultClient{ + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + ResponseTypes: []string{"code token"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + ResponseModes: []ResponseModeType{ResponseModeFormPost}, + }, + RequestedScope: []string{"foo", "bar"}, + RequestedAudience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + }, + }, + }, + /* determine correct response mode if default */ + { + desc: "success with response mode", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "audience": {"https://cloud.ory.sh/api https://www.ory.sh/api"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultResponseModeClient{ + DefaultClient: &DefaultClient{ + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + ResponseTypes: []string{"code"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + ResponseModes: []ResponseModeType{ResponseModeQuery}, + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expect: &AuthorizeRequest{ + RedirectURI: redir, + ResponseTypes: []string{"code"}, + State: "strong-state", + Request: Request{ + Client: &DefaultResponseModeClient{ + DefaultClient: &DefaultClient{ + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + ResponseTypes: []string{"code"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + ResponseModes: []ResponseModeType{ResponseModeQuery}, + }, + RequestedScope: []string{"foo", "bar"}, + RequestedAudience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + }, + }, + }, + /* determine correct response mode if default */ + { + desc: "success with response mode", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "audience": {"https://cloud.ory.sh/api https://www.ory.sh/api"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultResponseModeClient{ + DefaultClient: &DefaultClient{ + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + ResponseTypes: []string{"code token"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + ResponseModes: []ResponseModeType{ResponseModeFragment}, + }, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expect: &AuthorizeRequest{ + RedirectURI: redir, + ResponseTypes: []string{"code", "token"}, + State: "strong-state", + Request: Request{ + Client: &DefaultResponseModeClient{ + DefaultClient: &DefaultClient{ + RedirectURIs: []string{"https://foo.bar/cb"}, + Scopes: []string{"foo", "bar"}, + ResponseTypes: []string{"code token"}, + Audience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + Secret: []byte("1234"), + }, + ResponseModes: []ResponseModeType{ResponseModeFragment}, + }, + RequestedScope: []string{"foo", "bar"}, + RequestedAudience: []string{"https://cloud.ory.sh/api", "https://www.ory.sh/api"}, + }, + }, + }, + /* fails because request_uri is included */ + { + desc: "should fail because request_uri is provided in the request", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "request_uri": {"https://foo.bar/ru"}, + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"1234"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "response_mode": {"form_post"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"https://foo.bar/cb"}, Scopes: []string{"foo", "bar"}, ResponseTypes: []string{"code token"}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("1234"))).Return(nil) + }, + expectedError: ErrInvalidRequest.WithHint("The request must not contain 'request_uri'."), + }, + /* fails because of invalid client credentials */ + { + desc: "should fail because of invalid client creds", + conf: &Fosite{Store: store, ScopeStrategy: ExactScopeStrategy, AudienceMatchingStrategy: DefaultAudienceMatchingStrategy}, + query: url.Values{ + "request_uri": {"https://foo.bar/ru"}, + "redirect_uri": {"https://foo.bar/cb"}, + "client_id": {"1234"}, + "client_secret": []string{"4321"}, + "response_type": {"code token"}, + "state": {"strong-state"}, + "scope": {"foo bar"}, + "response_mode": {"form_post"}, + }, + mock: func() { + store.EXPECT().GetClient(gomock.Any(), "1234").Return(&DefaultClient{RedirectURIs: []string{"https://foo.bar/cb"}, Scopes: []string{"foo", "bar"}, ResponseTypes: []string{"code token"}, Secret: []byte("1234")}, nil).MaxTimes(2) + hasher.EXPECT().Compare(ctx, gomock.Eq([]byte("1234")), gomock.Eq([]byte("4321"))).Return(fmt.Errorf("invalid hash")) + }, + expectedError: ErrInvalidClient, + }, + } { + t.Run(fmt.Sprintf("case=%s", c.desc), func(t *testing.T) { + ctrl := gomock.NewController(t) + + store = internal.NewMockStorage(ctrl) + hasher = internal.NewMockHasher(ctrl) + ctx := NewContext() + defer ctrl.Finish() + + c.mock() + if c.r == nil { + c.r = &http.Request{Header: http.Header{}} + if c.query != nil { + c.r.URL = &url.URL{RawQuery: c.query.Encode()} + } + } + + c.conf.Store = store + c.conf.Hasher = hasher + ar, err := c.conf.NewPushedAuthorizeRequest(ctx, c.r) + if c.expectedError != nil { + assert.EqualError(t, err, c.expectedError.Error(), "Stack: %s", string(debug.Stack())) + // https://github.com/ory/hydra/issues/1642 + AssertObjectKeysEqual(t, &AuthorizeRequest{State: c.query.Get("state")}, ar, "State") + } else { + require.NoError(t, err) + AssertObjectKeysEqual(t, c.expect, ar, "ResponseTypes", "RequestedAudience", "RequestedScope", "Client", "RedirectURI", "State") + assert.NotNil(t, ar.GetRequestedAt()) + } + }) + } +} diff --git a/pushed_authorize_response.go b/pushed_authorize_response.go new file mode 100644 index 000000000..62bf2b151 --- /dev/null +++ b/pushed_authorize_response.go @@ -0,0 +1,58 @@ +package fosite + +import "net/http" + +// PushedAuthorizeResponse is the response object for PAR +type PushedAuthorizeResponse struct { + RequestURI string `json:"request_uri"` + ExpiresIn int `json:"expires_in"` + Header http.Header + Extra map[string]interface{} +} + +// GetRequestURI gets +func (a *PushedAuthorizeResponse) GetRequestURI() string { + return a.RequestURI +} + +// SetRequestURI sets +func (a *PushedAuthorizeResponse) SetRequestURI(requestURI string) { + a.RequestURI = requestURI +} + +// GetExpiresIn gets +func (a *PushedAuthorizeResponse) GetExpiresIn() int { + return a.ExpiresIn +} + +// SetExpiresIn sets +func (a *PushedAuthorizeResponse) SetExpiresIn(seconds int) { + a.ExpiresIn = seconds +} + +// GetHeader gets +func (a *PushedAuthorizeResponse) GetHeader() http.Header { + return a.Header +} + +// AddHeader adds +func (a *PushedAuthorizeResponse) AddHeader(key, value string) { + a.Header.Add(key, value) +} + +// SetExtra sets +func (a *PushedAuthorizeResponse) SetExtra(key string, value interface{}) { + a.Extra[key] = value +} + +// GetExtra gets +func (a *PushedAuthorizeResponse) GetExtra(key string) interface{} { + return a.Extra[key] +} + +// ToMap converts to a map +func (a *PushedAuthorizeResponse) ToMap() map[string]interface{} { + a.Extra["request_uri"] = a.RequestURI + a.Extra["expires_in"] = a.ExpiresIn + return a.Extra +} diff --git a/pushed_authorize_response_writer.go b/pushed_authorize_response_writer.go new file mode 100644 index 000000000..3ecaf8d2f --- /dev/null +++ b/pushed_authorize_response_writer.go @@ -0,0 +1,77 @@ +package fosite + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// NewPushedAuthorizeResponse executes the handlers and builds the response +func (f *Fosite) NewPushedAuthorizeResponse(ctx context.Context, ar AuthorizeRequester, session Session) (PushedAuthorizeResponder, error) { + var resp = &PushedAuthorizeResponse{ + Header: http.Header{}, + Extra: map[string]interface{}{}, + } + + ctx = context.WithValue(ctx, AuthorizeRequestContextKey, ar) + ctx = context.WithValue(ctx, PushedAuthorizeResponseContextKey, resp) + + ar.SetSession(session) + for _, h := range f.PushedAuthorizeEndpointHandlers { + if err := h.HandlePushedAuthorizeEndpointRequest(ctx, ar, resp); err != nil { + return nil, err + } + } + + return resp, nil +} + +// WritePushedAuthorizeResponse writes the PAR response +func (f *Fosite) WritePushedAuthorizeResponse(rw http.ResponseWriter, ar AuthorizeRequester, resp PushedAuthorizeResponder) { + // Set custom headers, e.g. "X-MySuperCoolCustomHeader" or "X-DONT-CACHE-ME"... + wh := rw.Header() + rh := resp.GetHeader() + for k := range rh { + wh.Set(k, rh.Get(k)) + } + + wh.Set("Cache-Control", "no-store") + wh.Set("Pragma", "no-cache") + wh.Set("Content-Type", "application/json;charset=UTF-8") + + js, err := json.Marshal(resp.ToMap()) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + rw.Header().Set("Content-Type", "application/json;charset=UTF-8") + + rw.WriteHeader(http.StatusCreated) + _, _ = rw.Write(js) +} + +// WritePushedAuthorizeError writes the PAR error +func (f *Fosite) WritePushedAuthorizeError(rw http.ResponseWriter, ar AuthorizeRequester, err error) { + rw.Header().Set("Cache-Control", "no-store") + rw.Header().Set("Pragma", "no-cache") + rw.Header().Set("Content-Type", "application/json;charset=UTF-8") + + rfcerr := ErrorToRFC6749Error(err).WithLegacyFormat(f.UseLegacyErrorFormat). + WithExposeDebug(f.SendDebugMessagesToClients).WithLocalizer(f.MessageCatalog, getLangFromRequester(ar)) + + js, err := json.Marshal(rfcerr) + if err != nil { + if f.SendDebugMessagesToClients { + errorMessage := EscapeJSONString(err.Error()) + http.Error(rw, fmt.Sprintf(`{"error":"server_error","error_description":"%s"}`, errorMessage), http.StatusInternalServerError) + } else { + http.Error(rw, `{"error":"server_error"}`, http.StatusInternalServerError) + } + return + } + + rw.WriteHeader(rfcerr.CodeField) + _, _ = rw.Write(js) +} diff --git a/pushed_authorize_response_writer_test.go b/pushed_authorize_response_writer_test.go new file mode 100644 index 000000000..8f5d57563 --- /dev/null +++ b/pushed_authorize_response_writer_test.go @@ -0,0 +1,57 @@ +package fosite_test + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + + . "github.com/ory/fosite" + . "github.com/ory/fosite/internal" +) + +func TestNewPushedAuthorizeResponse(t *testing.T) { + ctrl := gomock.NewController(t) + handlers := []*MockPushedAuthorizeEndpointHandler{NewMockPushedAuthorizeEndpointHandler(ctrl)} + ar := NewMockAuthorizeRequester(ctrl) + defer ctrl.Finish() + + ctx := context.Background() + oauth2 := &Fosite{ + PushedAuthorizeEndpointHandlers: PushedAuthorizeEndpointHandlers{handlers[0]}, + } + ar.EXPECT().SetSession(gomock.Eq(new(DefaultSession))).AnyTimes() + fooErr := errors.New("foo") + for k, c := range []struct { + isErr bool + mock func() + expectErr error + }{ + { + mock: func() { + handlers[0].EXPECT().HandlePushedAuthorizeEndpointRequest(gomock.Any(), gomock.Eq(ar), gomock.Any()).Return(fooErr) + }, + isErr: true, + expectErr: fooErr, + }, + { + mock: func() { + handlers[0].EXPECT().HandlePushedAuthorizeEndpointRequest(gomock.Any(), gomock.Eq(ar), gomock.Any()).Return(nil) + }, + isErr: false, + }, + } { + c.mock() + responder, err := oauth2.NewPushedAuthorizeResponse(ctx, ar, new(DefaultSession)) + assert.Equal(t, c.isErr, err != nil, "%d: %s", k, err) + if err != nil { + assert.Equal(t, c.expectErr, err, "%d: %s", k, err) + assert.Nil(t, responder, "%d", k) + } else { + assert.NotNil(t, responder, "%d", k) + } + t.Logf("Passed test case %d", k) + } +} diff --git a/storage.go b/storage.go index ddec91dec..efd98e784 100644 --- a/storage.go +++ b/storage.go @@ -21,7 +21,19 @@ package fosite +import "context" + // Storage defines fosite's minimal storage interface. type Storage interface { ClientManager } + +// PARStorage holds information needed to store and retrieve PAR context. +type PARStorage interface { + // CreatePARSession stores the pushed authorization request context. The requestURI is used to derive the key. + CreatePARSession(ctx context.Context, requestURI string, request AuthorizeRequester) error + // GetPARSession gets the push authorization request context. The caller is expected to merge the AuthorizeRequest. + GetPARSession(ctx context.Context, requestURI string) (AuthorizeRequester, error) + // DeletePARSession deletes the context. + DeletePARSession(ctx context.Context, requestURI string) (err error) +} diff --git a/storage/memory.go b/storage/memory.go index 3705df313..98f7f2083 100644 --- a/storage/memory.go +++ b/storage/memory.go @@ -65,6 +65,7 @@ type MemoryStore struct { RefreshTokenRequestIDs map[string]string // Public keys to check signature in auth grant jwt assertion. IssuerPublicKeys map[string]IssuerPublicKeys + PARSessions map[string]fosite.AuthorizeRequester clientsMutex sync.RWMutex authorizeCodesMutex sync.RWMutex @@ -77,6 +78,7 @@ type MemoryStore struct { accessTokenRequestIDsMutex sync.RWMutex refreshTokenRequestIDsMutex sync.RWMutex issuerPublicKeysMutex sync.RWMutex + parSessionsMutex sync.RWMutex } func NewMemoryStore() *MemoryStore { @@ -92,6 +94,7 @@ func NewMemoryStore() *MemoryStore { RefreshTokenRequestIDs: make(map[string]string), BlacklistedJTIs: make(map[string]time.Time), IssuerPublicKeys: make(map[string]IssuerPublicKeys), + PARSessions: make(map[string]fosite.AuthorizeRequester), } } @@ -454,3 +457,35 @@ func (s *MemoryStore) IsJWTUsed(ctx context.Context, jti string) (bool, error) { func (s *MemoryStore) MarkJWTUsedForTime(ctx context.Context, jti string, exp time.Time) error { return s.SetClientAssertionJWT(ctx, jti, exp) } + +// CreatePARSession stores the pushed authorization request context. The requestURI is used to derive the key. +func (s *MemoryStore) CreatePARSession(ctx context.Context, requestURI string, request fosite.AuthorizeRequester) error { + s.parSessionsMutex.Lock() + defer s.parSessionsMutex.Unlock() + + s.PARSessions[requestURI] = request + return nil +} + +// GetPARSession gets the push authorization request context. If the request is nil, a new request object +// is created. Otherwise, the same object is updated. +func (s *MemoryStore) GetPARSession(ctx context.Context, requestURI string) (fosite.AuthorizeRequester, error) { + s.parSessionsMutex.RLock() + defer s.parSessionsMutex.RUnlock() + + r, ok := s.PARSessions[requestURI] + if !ok { + return nil, fosite.ErrNotFound + } + + return r, nil +} + +// DeletePARSession deletes the context. +func (s *MemoryStore) DeletePARSession(ctx context.Context, requestURI string) (err error) { + s.parSessionsMutex.Lock() + defer s.parSessionsMutex.Unlock() + + delete(s.PARSessions, requestURI) + return nil +}