hydra/fosite/authorize_request_handler.go

433 lines
18 KiB
Go

// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package fosite
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-jose/go-jose/v3"
"go.opentelemetry.io/otel/trace"
"github.com/ory/hydra/v2/fosite/i18n"
"github.com/ory/hydra/v2/fosite/token/jwt"
"github.com/ory/x/errorsx"
"github.com/ory/x/otelx"
"github.com/pkg/errors"
"github.com/ory/go-convenience/stringslice"
)
func wrapSigningKeyFailure(outer *RFC6749Error, inner error) *RFC6749Error {
outer = outer.WithWrap(inner).WithDebug(inner.Error())
if e := new(RFC6749Error); errors.As(inner, &e) {
return outer.WithHintf("%s %s", outer.Reason(), e.Reason())
}
return outer
}
func (f *Fosite) authorizeRequestParametersFromOpenIDConnectRequest(ctx context.Context, request *AuthorizeRequest, isPARRequest bool) error {
var scope Arguments = RemoveEmpty(strings.Split(request.Form.Get("scope"), " "))
// Even if a scope parameter is present in the Request Object value, a scope parameter MUST always be passed using
// the OAuth 2.0 request syntax containing the openid scope value to indicate to the underlying OAuth 2.0 logic that this is an OpenID Connect request.
// Source: http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
if !scope.Has("openid") {
return nil
}
if len(request.Form.Get("request")+request.Form.Get("request_uri")) == 0 {
return nil
} else if len(request.Form.Get("request")) > 0 && len(request.Form.Get("request_uri")) > 0 {
return errorsx.WithStack(ErrInvalidRequest.WithHint("OpenID Connect parameters 'request' and 'request_uri' were both given, but you can use at most one."))
}
oidcClient, ok := request.Client.(OpenIDConnectClient)
if !ok {
if len(request.Form.Get("request_uri")) > 0 {
return errorsx.WithStack(ErrRequestURINotSupported.WithHint("OpenID Connect 'request_uri' context was given, but the OAuth 2.0 Client does not implement advanced OpenID Connect capabilities."))
}
return errorsx.WithStack(ErrRequestNotSupported.WithHint("OpenID Connect 'request' context was given, but the OAuth 2.0 Client does not implement advanced OpenID Connect capabilities."))
}
if oidcClient.GetJSONWebKeys() == nil && len(oidcClient.GetJSONWebKeysURI()) == 0 {
return errorsx.WithStack(ErrInvalidRequest.WithHint("OpenID Connect 'request' or 'request_uri' context was given, but the OAuth 2.0 Client does not have any JSON Web Keys registered."))
}
assertion := request.Form.Get("request")
if location := request.Form.Get("request_uri"); len(location) > 0 {
if !stringslice.Has(oidcClient.GetRequestURIs(), location) {
return errorsx.WithStack(ErrInvalidRequestURI.WithHintf("Request URI '%s' is not whitelisted by the OAuth 2.0 Client.", location))
}
hc := f.Config.GetHTTPClient(ctx)
response, err := hc.Get(location)
if err != nil {
return errorsx.WithStack(ErrInvalidRequestURI.WithHintf("Unable to fetch OpenID Connect request parameters from 'request_uri' because: %s.", err.Error()).WithWrap(err).WithDebug(err.Error()))
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return errorsx.WithStack(ErrInvalidRequestURI.WithHintf("Unable to fetch OpenID Connect request parameters from 'request_uri' because status code '%d' was expected, but got '%d'.", http.StatusOK, response.StatusCode))
}
body, err := io.ReadAll(response.Body)
if err != nil {
return errorsx.WithStack(ErrInvalidRequestURI.WithHintf("Unable to fetch OpenID Connect request parameters from 'request_uri' because body parsing failed with: %s.", err).WithWrap(err).WithDebug(err.Error()))
}
assertion = string(body)
}
token, err := jwt.ParseWithClaims(assertion, jwt.MapClaims{}, func(t *jwt.Token) (interface{}, error) {
// request_object_signing_alg - OPTIONAL.
// JWS [JWS] alg algorithm [JWA] that MUST be used for signing Request Objects sent to the OP. All Request Objects from this Client MUST be rejected,
// if not signed with this algorithm. Request Objects are described in Section 6.1 of OpenID Connect Core 1.0 [OpenID.Core]. This algorithm MUST
// be used both when the Request Object is passed by value (using the request parameter) and when it is passed by reference (using the request_uri parameter).
// Servers SHOULD support RS256. The value none MAY be used. The default, if omitted, is that any algorithm supported by the OP and the RP MAY be used.
if oidcClient.GetRequestObjectSigningAlgorithm() != "" && oidcClient.GetRequestObjectSigningAlgorithm() != fmt.Sprintf("%s", t.Header["alg"]) {
return nil, errorsx.WithStack(ErrInvalidRequestObject.WithHintf("The request object uses signing algorithm '%s', but the requested OAuth 2.0 Client enforces signing algorithm '%s'.", t.Header["alg"], oidcClient.GetRequestObjectSigningAlgorithm()))
}
if t.Method == jwt.SigningMethodNone {
return jwt.UnsafeAllowNoneSignatureType, nil
}
switch t.Method {
case jose.RS256, jose.RS384, jose.RS512:
key, err := f.findClientPublicJWK(ctx, oidcClient, t, true)
if err != nil {
return nil, wrapSigningKeyFailure(
ErrInvalidRequestObject.WithHint("Unable to retrieve RSA signing key from OAuth 2.0 Client."), err)
}
return key, nil
case jose.ES256, jose.ES384, jose.ES512:
key, err := f.findClientPublicJWK(ctx, oidcClient, t, false)
if err != nil {
return nil, wrapSigningKeyFailure(
ErrInvalidRequestObject.WithHint("Unable to retrieve ECDSA signing key from OAuth 2.0 Client."), err)
}
return key, nil
case jose.PS256, jose.PS384, jose.PS512:
key, err := f.findClientPublicJWK(ctx, oidcClient, t, true)
if err != nil {
return nil, wrapSigningKeyFailure(
ErrInvalidRequestObject.WithHint("Unable to retrieve RSA signing key from OAuth 2.0 Client."), err)
}
return key, nil
default:
return nil, errorsx.WithStack(ErrInvalidRequestObject.WithHintf("This request object uses unsupported signing algorithm '%s'.", t.Header["alg"]))
}
})
if err != nil {
// Do not re-process already enhanced errors
var e *jwt.ValidationError
if errors.As(err, &e) {
if e.Inner != nil {
return e.Inner
}
return errorsx.WithStack(ErrInvalidRequestObject.WithHint("Unable to verify the request object's signature.").WithWrap(err).WithDebug(err.Error()))
}
return err
} else if err := token.Claims.Valid(); err != nil {
return errorsx.WithStack(ErrInvalidRequestObject.WithHint("Unable to verify the request object because its claims could not be validated, check if the expiry time is set correctly.").WithWrap(err).WithDebug(err.Error()))
}
claims := token.Claims
// Reject the request if the "request_uri" authorization request
// parameter is provided.
if requestURI, _ := claims["request_uri"].(string); isPARRequest && requestURI != "" {
return errorsx.WithStack(ErrInvalidRequestObject.WithHint("Pushed Authorization Requests can not contain the 'request_uri' parameter."))
}
for k, v := range claims {
request.Form.Set(k, fmt.Sprintf("%s", v))
}
claimScope := RemoveEmpty(strings.Split(request.Form.Get("scope"), " "))
for _, s := range scope {
if !stringslice.Has(claimScope, s) {
claimScope = append(claimScope, s)
}
}
request.State = request.Form.Get("state")
request.Form.Set("scope", strings.Join(claimScope, " "))
return nil
}
func (f *Fosite) validateAuthorizeRedirectURI(_ *http.Request, request *AuthorizeRequest) error {
// Fetch redirect URI from request
rawRedirURI := request.Form.Get("redirect_uri")
// This ensures that the 'redirect_uri' parameter is present for OpenID Connect 1.0 authorization requests as per:
//
// Authorization Code Flow - https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
// Implicit Flow - https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest
// Hybrid Flow - https://openid.net/specs/openid-connect-core-1_0.html#HybridAuthRequest
//
// Note: as per the Hybrid Flow documentation the Hybrid Flow has the same requirements as the Authorization Code Flow.
if len(rawRedirURI) == 0 && request.GetRequestedScopes().Has("openid") {
return errorsx.WithStack(ErrInvalidRequest.WithHint("The 'redirect_uri' parameter is required when using OpenID Connect 1.0."))
}
// Validate redirect uri
redirectURI, err := MatchRedirectURIWithClientRedirectURIs(rawRedirURI, request.Client)
if err != nil {
return err
} else if !IsValidRedirectURI(redirectURI) {
return errorsx.WithStack(ErrInvalidRequest.WithHintf("The redirect URI '%s' contains an illegal character (for example #) or is otherwise invalid.", redirectURI))
}
request.RedirectURI = redirectURI
return nil
}
func (f *Fosite) parseAuthorizeScope(_ *http.Request, request *AuthorizeRequest) error {
request.SetRequestedScopes(RemoveEmpty(strings.Split(request.Form.Get("scope"), " ")))
return nil
}
func (f *Fosite) validateAuthorizeScope(ctx context.Context, _ *http.Request, request *AuthorizeRequest) error {
for _, permission := range request.GetRequestedScopes() {
if !f.Config.GetScopeStrategy(ctx)(request.Client.GetScopes(), permission) {
return errorsx.WithStack(ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", permission))
}
}
return nil
}
func (f *Fosite) validateResponseTypes(r *http.Request, request *AuthorizeRequest) error {
// https://tools.ietf.org/html/rfc6749#section-3.1.1
// Extension response types MAY contain a space-delimited (%x20) list of
// values, where the order of values does not matter (e.g., response
// type "a b" is the same as "b a"). The meaning of such composite
// response types is defined by their respective specifications.
responseTypes := RemoveEmpty(strings.Split(r.Form.Get("response_type"), " "))
if len(responseTypes) == 0 {
return errorsx.WithStack(ErrUnsupportedResponseType.WithHint("`The request is missing the 'response_type' parameter."))
}
var found bool
for _, t := range request.GetClient().GetResponseTypes() {
if Arguments(responseTypes).Matches(RemoveEmpty(strings.Split(t, " "))...) {
found = true
break
}
}
if !found {
return errorsx.WithStack(ErrUnsupportedResponseType.WithHintf("The client is not allowed to request response_type '%s'.", r.Form.Get("response_type")))
}
request.ResponseTypes = responseTypes
return nil
}
func (f *Fosite) ParseResponseMode(ctx context.Context, r *http.Request, request *AuthorizeRequest) error {
switch responseMode := r.Form.Get("response_mode"); responseMode {
case string(ResponseModeDefault):
request.ResponseMode = ResponseModeDefault
case string(ResponseModeFragment):
request.ResponseMode = ResponseModeFragment
case string(ResponseModeQuery):
request.ResponseMode = ResponseModeQuery
case string(ResponseModeFormPost):
request.ResponseMode = ResponseModeFormPost
default:
rm := ResponseModeType(responseMode)
if f.ResponseModeHandler(ctx).ResponseModes().Has(rm) {
request.ResponseMode = rm
break
}
return errorsx.WithStack(ErrUnsupportedResponseMode.WithHintf("Request with unsupported response_mode \"%s\".", responseMode))
}
return nil
}
func (f *Fosite) validateResponseMode(r *http.Request, request *AuthorizeRequest) error {
if request.ResponseMode == ResponseModeDefault {
return nil
}
responseModeClient, ok := request.GetClient().(ResponseModeClient)
if !ok {
return errorsx.WithStack(ErrUnsupportedResponseMode.WithHintf("The request has response_mode \"%s\". set but registered OAuth 2.0 client doesn't support response_mode", r.Form.Get("response_mode")))
}
var found bool
for _, t := range responseModeClient.GetResponseModes() {
if request.ResponseMode == t {
found = true
break
}
}
if !found {
return errorsx.WithStack(ErrUnsupportedResponseMode.WithHintf("The client is not allowed to request response_mode '%s'.", r.Form.Get("response_mode")))
}
return nil
}
func (f *Fosite) authorizeRequestFromPAR(ctx context.Context, r *http.Request, request *AuthorizeRequest) (bool, error) {
configProvider, ok := f.Config.(PushedAuthorizeRequestConfigProvider)
if !ok {
// If the config provider is not implemented, PAR cannot be used.
return false, nil
}
requestURI := r.Form.Get("request_uri")
if requestURI == "" || !strings.HasPrefix(requestURI, configProvider.GetPushedAuthorizeRequestURIPrefix(ctx)) {
// nothing to do here
return false, nil
}
clientID := r.Form.Get("client_id")
storage, ok := f.Store.(PARStorageProvider)
if !ok {
return false, errorsx.WithStack(ErrServerError.WithHint(ErrorPARNotSupported).WithDebug(DebugPARStorageInvalid))
}
// hydrate the requester
var parRequest AuthorizeRequester
var err error
if parRequest, err = storage.PARStorage().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.PARStorage().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 one sent in the pushed authorization request."))
}
return true, nil
}
func (f *Fosite) NewAuthorizeRequest(ctx context.Context, r *http.Request) (_ AuthorizeRequester, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("github.com/ory/hydra/v2/fosite").Start(ctx, "Fosite.NewAuthorizeRequest")
defer otelx.End(span, &err)
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.Config.GetMessageCatalog(ctx), r)
ctx = context.WithValue(ctx, RequestContextKey, r)
ctx = context.WithValue(ctx, AuthorizeRequestContextKey, request)
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
// 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 configProvider, ok := f.Config.(PushedAuthorizeRequestConfigProvider); ok && configProvider.EnforcePushedAuthorize(ctx) {
return request, errorsx.WithStack(ErrInvalidRequest.WithHint("Pushed Authorization Requests are enforced but no such request was sent."))
}
}
client, err := f.Store.FositeClientManager().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()))
}
request.Client = client
// Now that the base fields (state and client) are populated, we extract all the information
// from the request object or request object uri, if one is set.
//
// All other parse methods should come afterwards so that we ensure that the data is taken
// from the request_object if set.
if err := f.authorizeRequestParametersFromOpenIDConnectRequest(ctx, request, isPARRequest); err != nil {
return request, err
}
// The request context is now fully available and we can start processing the individual
// fields.
if err := f.ParseResponseMode(ctx, r, request); err != nil {
return request, err
}
if err = f.parseAuthorizeScope(r, request); err != nil {
return request, err
}
if err = f.validateAuthorizeRedirectURI(r, request); err != nil {
return request, err
}
if err = f.validateAuthorizeScope(ctx, r, request); err != nil {
return request, err
}
if err = f.validateAudience(ctx, r, request); err != nil {
return request, err
}
if len(request.Form.Get("registration")) > 0 {
return request, errorsx.WithStack(ErrRegistrationNotSupported)
}
if err = f.validateResponseTypes(r, request); err != nil {
return request, err
}
if err = f.validateResponseMode(r, request); err != nil {
return request, err
}
// A fallback handler to set the default response mode in cases where we can not reach the Authorize Handlers
// but still need the e.g. correct error response mode.
if request.GetResponseMode() == ResponseModeDefault {
if request.ResponseTypes.ExactOne("code") {
request.SetDefaultResponseMode(ResponseModeQuery)
} else {
// If the response type is not `code` it is an implicit/hybrid (fragment) response mode.
request.SetDefaultResponseMode(ResponseModeFragment)
}
}
// rfc6819 4.4.1.8. Threat: CSRF Attack against redirect-uri
// The "state" parameter should be used to link the authorization
// request with the redirect URI used to deliver the access token (Section 5.3.5).
//
// https://tools.ietf.org/html/rfc6819#section-4.4.1.8
// The "state" parameter should not be guessable
if len(request.State) < f.GetMinParameterEntropy(ctx) {
// We're assuming that using less then, by default, 8 characters for the state can not be considered "unguessable"
return request, errorsx.WithStack(ErrInvalidState.WithHintf("Request parameter 'state' must be at least be %d characters long to ensure sufficient entropy.", f.GetMinParameterEntropy(ctx)))
}
return request, nil
}