Skip to content

Commit

Permalink
feat: [ory#628] PAR implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
vivshankar authored and aeneasr committed May 31, 2022
1 parent d9d0fed commit 1c6d3b8
Show file tree
Hide file tree
Showing 18 changed files with 1,432 additions and 29 deletions.
56 changes: 56 additions & 0 deletions authorize_request_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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()))
Expand Down
58 changes: 41 additions & 17 deletions compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
17 changes: 17 additions & 0 deletions compose/compose_par.go
Original file line number Diff line number Diff line change
@@ -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(),
}
}
10 changes: 10 additions & 0 deletions compose/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ const (
AccessResponseContextKey = ContextKey("accessResponse")
AuthorizeRequestContextKey = ContextKey("authorizeRequest")
AuthorizeResponseContextKey = ContextKey("authorizeResponse")
// PushedAuthorizeResponseContextKey is the response context
PushedAuthorizeResponseContextKey = ContextKey("pushedAuthorizeResponse")
)
48 changes: 37 additions & 11 deletions fosite.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"html/template"
"net/http"
"reflect"
"time"

"github.com/ory/fosite/i18n"
)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
88 changes: 88 additions & 0 deletions handler/par/flow_pushed_authorize.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 1c6d3b8

Please sign in to comment.