consent/strategy_default.go
// Copyright © 2022 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package consent
import (
"context"
stderrs "errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gorilla/sessions"
"github.com/hashicorp/go-retryablehttp"
"github.com/pborman/uuid"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/trace"
"github.com/ory/hydra/v2/flow"
"github.com/ory/hydra/v2/oauth2/flowctx"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
"github.com/ory/hydra/v2/client"
"github.com/ory/hydra/v2/driver/config"
"github.com/ory/hydra/v2/x"
"github.com/ory/x/errorsx"
"github.com/ory/x/mapx"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
"github.com/ory/x/stringslice"
"github.com/ory/x/stringsx"
"github.com/ory/x/urlx"
)
const (
CookieAuthenticationSIDName = "sid"
)
type DefaultStrategy struct {
c *config.DefaultProvider
r InternalRegistry
}
func NewStrategy(
r InternalRegistry,
c *config.DefaultProvider,
) *DefaultStrategy {
return &DefaultStrategy{
c: c,
r: r,
}
}
var ErrAbortOAuth2Request = stderrs.New("the OAuth 2.0 Authorization request must be aborted")
var ErrNoPreviousConsentFound = stderrs.New("no previous OAuth 2.0 Consent could be found for this access request")
var ErrNoAuthenticationSessionFound = stderrs.New("no previous login session was found")
var ErrHintDoesNotMatchAuthentication = stderrs.New("subject from hint does not match subject from session")
func (s *DefaultStrategy) matchesValueFromSession(ctx context.Context, c fosite.Client, hintSubject string, sessionSubject string) error {
obfuscatedUserID, err := s.ObfuscateSubjectIdentifier(ctx, c, sessionSubject, "")
if err != nil {
return err
}
var forcedObfuscatedUserID string
if s, err := s.r.ConsentManager().GetForcedObfuscatedLoginSession(ctx, c.GetID(), hintSubject); errors.Is(err, x.ErrNotFound) {
// do nothing
} else if err != nil {
return err
} else {
forcedObfuscatedUserID = s.SubjectObfuscated
}
if hintSubject != sessionSubject && hintSubject != obfuscatedUserID && hintSubject != forcedObfuscatedUserID {
return ErrHintDoesNotMatchAuthentication
}
return nil
}
func (s *DefaultStrategy) authenticationSession(ctx context.Context, _ http.ResponseWriter, r *http.Request) (*flow.LoginSession, error) {
store, err := s.r.CookieStore(ctx)
if err != nil {
return nil, err
}
// We try to open the session cookie. If it does not exist (indicated by the error), we must authenticate the user.
cookie, err := store.Get(r, s.c.SessionCookieName(ctx))
if err != nil {
s.r.Logger().
WithRequest(r).
WithError(err).Debug("User logout skipped because cookie store returned an error.")
return nil, errorsx.WithStack(ErrNoAuthenticationSessionFound)
}
sessionID := mapx.GetStringDefault(cookie.Values, CookieAuthenticationSIDName, "")
if sessionID == "" {
s.r.Logger().
WithRequest(r).
Debug("User logout skipped because cookie exists but session value is empty.")
return nil, errorsx.WithStack(ErrNoAuthenticationSessionFound)
}
session, err := s.r.ConsentManager().GetRememberedLoginSession(r.Context(), nil, sessionID)
if errors.Is(err, x.ErrNotFound) {
s.r.Logger().WithRequest(r).WithError(err).
Debug("User logout skipped because cookie exists and session value exist but are not remembered any more.")
return nil, errorsx.WithStack(ErrNoAuthenticationSessionFound)
} else if err != nil {
return nil, err
}
return session, nil
}
func (s *DefaultStrategy) requestAuthentication(ctx context.Context, w http.ResponseWriter, r *http.Request, ar fosite.AuthorizeRequester) (err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("").Start(ctx, "DefaultStrategy.requestAuthentication")
defer otelx.End(span, &err)
prompt := stringsx.Splitx(ar.GetRequestForm().Get("prompt"), " ")
if stringslice.Has(prompt, "login") {
return s.forwardAuthenticationRequest(ctx, w, r, ar, "", time.Time{}, nil)
}
session, err := s.authenticationSession(ctx, w, r)
if errors.Is(err, ErrNoAuthenticationSessionFound) {
return s.forwardAuthenticationRequest(ctx, w, r, ar, "", time.Time{}, nil)
} else if err != nil {
return err
}
maxAge := int64(-1)
if ma := ar.GetRequestForm().Get("max_age"); len(ma) > 0 {
var err error
maxAge, err = strconv.ParseInt(ma, 10, 64)
if err != nil {
return err
}
}
if maxAge > -1 && time.Time(session.AuthenticatedAt).UTC().Add(time.Second*time.Duration(maxAge)).Before(time.Now().UTC()) {
if stringslice.Has(prompt, "none") {
return errorsx.WithStack(fosite.ErrLoginRequired.WithHint("Request failed because prompt is set to 'none' and authentication time reached 'max_age'."))
}
return s.forwardAuthenticationRequest(ctx, w, r, ar, "", time.Time{}, nil)
}
idTokenHint := ar.GetRequestForm().Get("id_token_hint")
if idTokenHint == "" {
return s.forwardAuthenticationRequest(ctx, w, r, ar, session.Subject, time.Time(session.AuthenticatedAt), session)
}
hintSub, err := s.getSubjectFromIDTokenHint(r.Context(), idTokenHint)
if err != nil {
return err
}
if err := s.matchesValueFromSession(r.Context(), ar.GetClient(), hintSub, session.Subject); errors.Is(err, ErrHintDoesNotMatchAuthentication) {
return errorsx.WithStack(fosite.ErrLoginRequired.WithHint("Request failed because subject claim from id_token_hint does not match subject from authentication session."))
}
return s.forwardAuthenticationRequest(ctx, w, r, ar, session.Subject, time.Time(session.AuthenticatedAt), session)
}
func (s *DefaultStrategy) getIDTokenHintClaims(ctx context.Context, idTokenHint string) (jwt.MapClaims, error) {
token, err := s.r.OpenIDJWTStrategy().Decode(ctx, idTokenHint)
if ve := new(jwt.ValidationError); errors.As(err, &ve) && ve.Errors == jwt.ValidationErrorExpired {
// Expired is ok
} else if err != nil {
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint(err.Error()))
}
return token.Claims, nil
}
func (s *DefaultStrategy) getSubjectFromIDTokenHint(ctx context.Context, idTokenHint string) (string, error) {
claims, err := s.getIDTokenHintClaims(ctx, idTokenHint)
if err != nil {
return "", err
}
sub, _ := claims["sub"].(string)
if sub == "" {
return "", errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Failed to validate OpenID Connect request because provided id token from id_token_hint does not have a subject."))
}
return sub, nil
}
func (s *DefaultStrategy) forwardAuthenticationRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, ar fosite.AuthorizeRequester, subject string, authenticatedAt time.Time, session *flow.LoginSession) error {
if (subject != "" && authenticatedAt.IsZero()) || (subject == "" && !authenticatedAt.IsZero()) {
return errorsx.WithStack(fosite.ErrServerError.WithHint("Consent strategy returned a non-empty subject with an empty auth date, or an empty subject with a non-empty auth date."))
}
skip := false
if subject != "" {
skip = true
}
// Let's validate that prompt is actually not "none" if we can't skip authentication
prompt := stringsx.Splitx(ar.GetRequestForm().Get("prompt"), " ")
if stringslice.Has(prompt, "none") && !skip {
return errorsx.WithStack(fosite.ErrLoginRequired.WithHint(`Prompt 'none' was requested, but no existing login session was found.`))
}
// Set up csrf/challenge/verifier values
verifier := strings.Replace(uuid.New(), "-", "", -1)
challenge := strings.Replace(uuid.New(), "-", "", -1)
csrf := strings.Replace(uuid.New(), "-", "", -1)
// Generate the request URL
iu := s.c.OAuth2AuthURL(ctx)
iu.RawQuery = r.URL.RawQuery
var idTokenHintClaims jwt.MapClaims
if idTokenHint := ar.GetRequestForm().Get("id_token_hint"); len(idTokenHint) > 0 {
claims, err := s.getIDTokenHintClaims(r.Context(), idTokenHint)
if err != nil {
return err
}
idTokenHintClaims = claims
}
sessionID := uuid.New()
if session != nil {
sessionID = session.ID
}
// Set the session
cl := sanitizeClientFromRequest(ar)
loginRequest := &flow.LoginRequest{
ID: challenge,
Verifier: verifier,
CSRF: csrf,
Skip: skip,
RequestedScope: []string(ar.GetRequestedScopes()),
RequestedAudience: []string(ar.GetRequestedAudience()),
Subject: subject,
Client: cl,
RequestURL: iu.String(),
AuthenticatedAt: sqlxx.NullTime(authenticatedAt),
RequestedAt: time.Now().Truncate(time.Second).UTC(),
SessionID: sqlxx.NullString(sessionID),
OpenIDConnectContext: &flow.OAuth2ConsentRequestOpenIDConnectContext{
IDTokenHintClaims: idTokenHintClaims,
ACRValues: stringsx.Splitx(ar.GetRequestForm().Get("acr_values"), " "),
UILocales: stringsx.Splitx(ar.GetRequestForm().Get("ui_locales"), " "),
Display: ar.GetRequestForm().Get("display"),
LoginHint: ar.GetRequestForm().Get("login_hint"),
},
}
f, err := s.r.ConsentManager().CreateLoginRequest(
ctx,
loginRequest,
)
if err != nil {
return errorsx.WithStack(err)
}
store, err := s.r.CookieStore(ctx)
if err != nil {
return err
}
clientSpecificCookieNameLoginCSRF := fmt.Sprintf("%s_%s", s.r.Config().CookieNameLoginCSRF(ctx), cl.CookieSuffix())
if err := createCsrfSession(w, r, s.r.Config(), store, clientSpecificCookieNameLoginCSRF, csrf, s.c.ConsentRequestMaxAge(ctx)); err != nil {
return errorsx.WithStack(err)
}
encodedFlow, err := f.ToLoginChallenge(ctx, s.r)
if err != nil {
return err
}
var baseURL *url.URL
if stringslice.Has(prompt, "registration") {
baseURL = s.c.RegistrationURL(ctx)
} else {
baseURL = s.c.LoginURL(ctx)
}
http.Redirect(w, r, urlx.SetQuery(baseURL, url.Values{"login_challenge": {encodedFlow}}).String(), http.StatusFound)
// generate the verifier
return errorsx.WithStack(ErrAbortOAuth2Request)
}
func (s *DefaultStrategy) revokeAuthenticationSession(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
store, err := s.r.CookieStore(ctx)
if err != nil {
return err
}
sid, err := s.revokeAuthenticationCookie(w, r, store)
if err != nil {
return err
}
if sid == "" {
return nil
}
_, err = s.r.ConsentManager().DeleteLoginSession(r.Context(), sid)
return err
}
func (s *DefaultStrategy) revokeAuthenticationCookie(w http.ResponseWriter, r *http.Request, ss sessions.Store) (string, error) {
ctx := r.Context()
cookie, _ := ss.Get(r, s.c.SessionCookieName(ctx))
sid, _ := mapx.GetString(cookie.Values, CookieAuthenticationSIDName)
cookie.Values[CookieAuthenticationSIDName] = ""
cookie.Options.HttpOnly = true
cookie.Options.Path = s.c.SessionCookiePath(ctx)
cookie.Options.SameSite = s.c.CookieSameSiteMode(ctx)
cookie.Options.Secure = s.c.CookieSecure(ctx)
cookie.Options.Domain = s.c.CookieDomain(ctx)
cookie.Options.MaxAge = -1
if err := cookie.Save(r, w); err != nil {
return "", errorsx.WithStack(err)
}
return sid, nil
}
func (s *DefaultStrategy) verifyAuthentication(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
req fosite.AuthorizeRequester,
verifier string,
) (_ *flow.Flow, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("").Start(ctx, "DefaultStrategy.verifyAuthentication")
defer otelx.End(span, &err)
// We decode the flow from the cookie again because VerifyAndInvalidateLoginRequest does not return the flow
f, err := flowctx.Decode[flow.Flow](ctx, s.r.FlowCipher(), verifier, flowctx.AsLoginVerifier)
if err != nil {
return nil, errorsx.WithStack(fosite.ErrAccessDenied.WithHint("The login verifier is invalid."))
}
session, err := s.r.ConsentManager().VerifyAndInvalidateLoginRequest(ctx, verifier)
if errors.Is(err, sqlcon.ErrNoRows) {
return nil, errorsx.WithStack(fosite.ErrAccessDenied.WithHint("The login verifier has already been used, has not been granted, or is invalid."))
} else if err != nil {
return nil, err
}
if session.HasError() {
session.Error.SetDefaults(flow.LoginRequestDeniedErrorName)
return nil, errorsx.WithStack(session.Error.ToRFCError())
}
if session.RequestedAt.Add(s.c.ConsentRequestMaxAge(ctx)).Before(time.Now()) {
return nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.WithHint("The login request has expired. Please try again."))
}
store, err := s.r.CookieStore(ctx)
if err != nil {
return nil, err
}
clientSpecificCookieNameLoginCSRF := fmt.Sprintf("%s_%s", s.r.Config().CookieNameLoginCSRF(ctx), session.LoginRequest.Client.CookieSuffix())
if err := ValidateCsrfSession(r, s.r.Config(), store, clientSpecificCookieNameLoginCSRF, session.LoginRequest.CSRF, f); err != nil {
return nil, err
}
if session.LoginRequest.Skip && !session.Remember {
return nil, errorsx.WithStack(fosite.ErrServerError.WithHint("The login request was previously remembered and can only be forgotten using the reject feature."))
}
if session.LoginRequest.Skip && session.Subject != session.LoginRequest.Subject {
// Revoke the session because there's clearly a mix up wrt the subject that's being authenticated
if err := s.revokeAuthenticationSession(ctx, w, r); err != nil {
return nil, err
}
return nil, errorsx.WithStack(fosite.ErrServerError.WithHint("The login request is marked as remember, but the subject from the login confirmation does not match the original subject from the cookie."))
}
subjectIdentifier, err := s.ObfuscateSubjectIdentifier(ctx, req.GetClient(), session.Subject, session.ForceSubjectIdentifier)
if err != nil {
return nil, err
}
sessionID := session.LoginRequest.SessionID.String()
if err := s.r.OpenIDConnectRequestValidator().ValidatePrompt(ctx, &fosite.AuthorizeRequest{
ResponseTypes: req.GetResponseTypes(),
RedirectURI: req.GetRedirectURI(),
State: req.GetState(),
// HandledResponseTypes, this can be safely ignored because it's not being used by validation
Request: fosite.Request{
ID: req.GetID(),
RequestedAt: req.GetRequestedAt(),
Client: req.GetClient(),
RequestedAudience: req.GetRequestedAudience(),
GrantedAudience: req.GetGrantedAudience(),
RequestedScope: req.GetRequestedScopes(),
GrantedScope: req.GetGrantedScopes(),
Form: req.GetRequestForm(),
Session: &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: subjectIdentifier,
IssuedAt: time.Now().UTC(), // doesn't matter
ExpiresAt: time.Now().Add(time.Hour).UTC(), // doesn't matter
AuthTime: time.Time(session.AuthenticatedAt),
RequestedAt: session.RequestedAt,
},
Headers: &jwt.Headers{},
Subject: session.Subject,
},
},
}); errors.Is(err, fosite.ErrLoginRequired) {
// This indicates that something went wrong with checking the subject id - let's destroy the session to be safe
if err := s.revokeAuthenticationSession(ctx, w, r); err != nil {
return nil, err
}
return nil, err
} else if err != nil {
return nil, err
}
if session.ForceSubjectIdentifier != "" {
if err := s.r.ConsentManager().CreateForcedObfuscatedLoginSession(r.Context(), &ForcedObfuscatedLoginSession{
Subject: session.Subject,
ClientID: req.GetClient().GetID(),
SubjectObfuscated: session.ForceSubjectIdentifier,
}); err != nil {
return nil, err
}
}
if !session.LoginRequest.Skip {
if time.Time(session.AuthenticatedAt).IsZero() {
return nil, errorsx.WithStack(fosite.ErrServerError.WithHint(
"Expected the handled login request to contain a valid authenticated_at value but it was zero. " +
"This is a bug which should be reported to https://github.com/ory/hydra."))
}
if err := s.r.ConsentManager().ConfirmLoginSession(ctx, &flow.LoginSession{
ID: sessionID,
AuthenticatedAt: session.AuthenticatedAt,
Subject: session.Subject,
IdentityProviderSessionID: sqlxx.NullString(session.IdentityProviderSessionID),
Remember: session.Remember,
}); err != nil {
if errors.Is(err, sqlcon.ErrUniqueViolation) {
return nil, errorsx.WithStack(fosite.ErrAccessDenied.WithHint("The login verifier has already been used."))
}
return nil, err
}
}
if !session.Remember && !session.LoginRequest.Skip {
// If the session should not be remembered (and we're actually not skipping), than the user clearly don't
// wants us to store a cookie. So let's bust the authentication session (if one exists).
if err := s.revokeAuthenticationSession(ctx, w, r); err != nil {
return nil, err
}
}
if !session.Remember || session.LoginRequest.Skip && !session.ExtendSessionLifespan {
// If the user doesn't want to remember the session, we do not store a cookie.
// If login was skipped, it means an authentication cookie was present and
// we don't want to touch it (in order to preserve its original expiry date)
return f, nil
}
// Not a skipped login and the user asked to remember its session, store a cookie
cookie, _ := store.Get(r, s.c.SessionCookieName(ctx))
cookie.Values[CookieAuthenticationSIDName] = sessionID
if session.RememberFor >= 0 {
cookie.Options.MaxAge = session.RememberFor
}
cookie.Options.HttpOnly = true
cookie.Options.Path = s.c.SessionCookiePath(ctx)
cookie.Options.SameSite = s.c.CookieSameSiteMode(ctx)
cookie.Options.Secure = s.c.CookieSecure(ctx)
if err := cookie.Save(r, w); err != nil {
return nil, errorsx.WithStack(err)
}
s.r.Logger().WithRequest(r).
WithFields(logrus.Fields{
"cookie_name": s.c.SessionCookieName(ctx),
"cookie_http_only": true,
"cookie_same_site": s.c.CookieSameSiteMode(ctx),
"cookie_secure": s.c.CookieSecure(ctx),
}).Debug("Authentication session cookie was set.")
return f, nil
}
func (s *DefaultStrategy) requestConsent(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
ar fosite.AuthorizeRequester,
f *flow.Flow,
) (err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("").Start(ctx, "DefaultStrategy.requestConsent")
defer otelx.End(span, &err)
prompt := stringsx.Splitx(ar.GetRequestForm().Get("prompt"), " ")
if stringslice.Has(prompt, "consent") {
return s.forwardConsentRequest(ctx, w, r, ar, f, nil)
}
// https://tools.ietf.org/html/rfc6749
//
// As stated in Section 10.2 of OAuth 2.0 [RFC6749], the authorization
// server SHOULD NOT process authorization requests automatically
// without user consent or interaction, except when the identity of the
// client can be assured. This includes the case where the user has
// previously approved an authorization request for a given client id --
// unless the identity of the client can be proven, the request SHOULD
// be processed as if no previous request had been approved.
//
// Measures such as claimed "https" scheme redirects MAY be accepted by
// authorization servers as identity proof. Some operating systems may
// offer alternative platform-specific identity features that MAY be
// accepted, as appropriate.
if ar.GetClient().IsPublic() {
// The OpenID Connect Test Tool fails if this returns `consent_required` when `prompt=none` is used.
// According to the quote above, it should be ok to allow https to skip consent.
//
// This is tracked as issue: https://github.com/ory/hydra/issues/866
// This is also tracked as upstream issue: https://github.com/openid-certification/oidctest/issues/97
if !(ar.GetRedirectURI().Scheme == "https" || (fosite.IsLocalhost(ar.GetRedirectURI()) && ar.GetRedirectURI().Scheme == "http")) {
return s.forwardConsentRequest(ctx, w, r, ar, f, nil)
}
}
// This breaks OIDC Conformity Tests and is probably a bit paranoid.
//
// if ar.GetResponseTypes().Has("token") {
// // We're probably requesting the implicit or hybrid flow in which case we MUST authenticate and authorize the request
// return s.forwardConsentRequest(w, r, ar, authenticationSession, nil)
// }
consentSessions, err := s.r.ConsentManager().FindGrantedAndRememberedConsentRequests(ctx, ar.GetClient().GetID(), f.Subject)
if errors.Is(err, ErrNoPreviousConsentFound) {
return s.forwardConsentRequest(ctx, w, r, ar, f, nil)
} else if err != nil {
return err
}
if found := matchScopes(s.r.Config().GetScopeStrategy(ctx), consentSessions, ar.GetRequestedScopes()); found != nil {
return s.forwardConsentRequest(ctx, w, r, ar, f, found)
}
return s.forwardConsentRequest(ctx, w, r, ar, f, nil)
}
func (s *DefaultStrategy) forwardConsentRequest(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
ar fosite.AuthorizeRequester,
f *flow.Flow,
previousConsent *flow.AcceptOAuth2ConsentRequest,
) error {
as := f.GetHandledLoginRequest()
skip := false
if previousConsent != nil {
skip = true
}
prompt := stringsx.Splitx(ar.GetRequestForm().Get("prompt"), " ")
if stringslice.Has(prompt, "none") && !skip {
return errorsx.WithStack(fosite.ErrConsentRequired.WithHint(`Prompt 'none' was requested, but no previous consent was found.`))
}
// Set up csrf/challenge/verifier values
verifier := strings.Replace(uuid.New(), "-", "", -1)
challenge := strings.Replace(uuid.New(), "-", "", -1)
csrf := strings.Replace(uuid.New(), "-", "", -1)
cl := sanitizeClientFromRequest(ar)
consentRequest := &flow.OAuth2ConsentRequest{
ID: challenge,
ACR: as.ACR,
AMR: as.AMR,
Verifier: verifier,
CSRF: csrf,
Skip: skip,
RequestedScope: []string(ar.GetRequestedScopes()),
RequestedAudience: []string(ar.GetRequestedAudience()),
Subject: as.Subject,
Client: cl,
RequestURL: as.LoginRequest.RequestURL,
AuthenticatedAt: as.AuthenticatedAt,
RequestedAt: as.RequestedAt,
ForceSubjectIdentifier: as.ForceSubjectIdentifier,
OpenIDConnectContext: as.LoginRequest.OpenIDConnectContext,
LoginSessionID: as.LoginRequest.SessionID,
LoginChallenge: sqlxx.NullString(as.LoginRequest.ID),
Context: as.Context,
}
err := s.r.ConsentManager().CreateConsentRequest(ctx, f, consentRequest)
if err != nil {
return errorsx.WithStack(err)
}
consentChallenge, err := f.ToConsentChallenge(ctx, s.r)
if err != nil {
return err
}
store, err := s.r.CookieStore(ctx)
if err != nil {
return err
}
if f.Client.GetID() != cl.GetID() {
return errorsx.WithStack(fosite.ErrInvalidClient.WithHint("The flow client id does not match the authorize request client id."))
}
clientSpecificCookieNameConsentCSRF := fmt.Sprintf("%s_%s", s.r.Config().CookieNameConsentCSRF(ctx), cl.CookieSuffix())
if err := createCsrfSession(w, r, s.r.Config(), store, clientSpecificCookieNameConsentCSRF, csrf, s.c.ConsentRequestMaxAge(ctx)); err != nil {
return errorsx.WithStack(err)
}
http.Redirect(
w, r,
urlx.SetQuery(s.c.ConsentURL(ctx), url.Values{"consent_challenge": {consentChallenge}}).String(),
http.StatusFound,
)
// generate the verifier
return errorsx.WithStack(ErrAbortOAuth2Request)
}
func (s *DefaultStrategy) verifyConsent(ctx context.Context, _ http.ResponseWriter, r *http.Request, verifier string) (_ *flow.AcceptOAuth2ConsentRequest, _ *flow.Flow, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("").Start(ctx, "DefaultStrategy.verifyConsent")
defer otelx.End(span, &err)
// We decode the flow here once again because VerifyAndInvalidateConsentRequest does not return the flow
f, err := flowctx.Decode[flow.Flow](ctx, s.r.FlowCipher(), verifier, flowctx.AsConsentVerifier)
if err != nil {
return nil, nil, errorsx.WithStack(fosite.ErrAccessDenied.WithHint("The consent verifier has already been used, has not been granted, or is invalid."))
}
if f.Client.GetID() != r.URL.Query().Get("client_id") {
return nil, nil, errorsx.WithStack(fosite.ErrInvalidClient.WithHint("The flow client id does not match the authorize request client id."))
}
session, err := s.r.ConsentManager().VerifyAndInvalidateConsentRequest(ctx, verifier)
if errors.Is(err, sqlcon.ErrUniqueViolation) {
return nil, nil, errorsx.WithStack(fosite.ErrAccessDenied.WithHint("The consent verifier has already been used."))
} else if errors.Is(err, sqlcon.ErrNoRows) {
return nil, nil, errorsx.WithStack(fosite.ErrAccessDenied.WithHint("The consent verifier has already been used, has not been granted, or is invalid."))
} else if err != nil {
return nil, nil, err
}
if session.RequestedAt.Add(s.c.ConsentRequestMaxAge(ctx)).Before(time.Now()) {
return nil, nil, errorsx.WithStack(fosite.ErrRequestUnauthorized.WithHint("The consent request has expired, please try again."))
}
if session.HasError() {
session.Error.SetDefaults(flow.ConsentRequestDeniedErrorName)
return nil, nil, errorsx.WithStack(session.Error.ToRFCError())
}
if time.Time(session.ConsentRequest.AuthenticatedAt).IsZero() {
return nil, nil, errorsx.WithStack(fosite.ErrServerError.WithHint("The authenticatedAt value was not set."))
}
store, err := s.r.CookieStore(ctx)
if err != nil {
return nil, nil, err
}
clientSpecificCookieNameConsentCSRF := fmt.Sprintf("%s_%s", s.r.Config().CookieNameConsentCSRF(ctx), session.ConsentRequest.Client.CookieSuffix())
if err := ValidateCsrfSession(r, s.r.Config(), store, clientSpecificCookieNameConsentCSRF, session.ConsentRequest.CSRF, f); err != nil {
return nil, nil, err
}
if session.Session == nil {
session.Session = flow.NewConsentRequestSessionData()
}
if session.Session.AccessToken == nil {
session.Session.AccessToken = map[string]interface{}{}
}
if session.Session.IDToken == nil {
session.Session.IDToken = map[string]interface{}{}
}
session.AuthenticatedAt = session.ConsentRequest.AuthenticatedAt
return session, f, nil
}
func (s *DefaultStrategy) generateFrontChannelLogoutURLs(ctx context.Context, subject, sid string) ([]string, error) {
clients, err := s.r.ConsentManager().ListUserAuthenticatedClientsWithFrontChannelLogout(ctx, subject, sid)
if err != nil {
return nil, err
}
var urls []string
for _, c := range clients {
u, err := url.Parse(c.FrontChannelLogoutURI)
if err != nil {
return nil, errorsx.WithStack(fosite.ErrServerError.WithHintf("Unable to parse frontchannel_logout_uri because %s.", c.FrontChannelLogoutURI).WithDebug(err.Error()))
}
urls = append(urls, urlx.SetQuery(u, url.Values{
"iss": {s.c.IssuerURL(ctx).String()},
"sid": {sid},
}).String())
}
return urls, nil
}
func (s *DefaultStrategy) executeBackChannelLogout(r *http.Request, subject, sid string) error {
ctx := r.Context()
clients, err := s.r.ConsentManager().ListUserAuthenticatedClientsWithBackChannelLogout(ctx, subject, sid)
if err != nil {
return err
}
openIDKeyID, err := s.r.OpenIDJWTStrategy().GetPublicKeyID(ctx)
if err != nil {
return err
}
type task struct {
url string
token string
clientID string
}
var tasks []task
for _, c := range clients {
// Getting the forced obfuscated login session is tricky because the user id could be obfuscated with a new
// ID every time the algorithm is used. Thus, we would only get the most recent version. It therefore makes
// sense to just use the sid.
//
// s.r.ConsentManager().GetForcedObfuscatedLoginSession(context.Background(), subject, <missing>)
// sub := s.obfuscateSubjectIdentifier(c, subject, )
t, _, err := s.r.OpenIDJWTStrategy().Generate(ctx, jwt.MapClaims{
"iss": s.c.IssuerURL(ctx).String(),
"aud": []string{c.ID},
"iat": time.Now().UTC().Unix(),
"jti": uuid.New(),
"events": map[string]struct{}{"http://schemas.openid.net/event/backchannel-logout": {}},
"sid": sid,
}, &jwt.Headers{
Extra: map[string]interface{}{"kid": openIDKeyID},
})
if err != nil {
return err
}
tasks = append(tasks, task{url: c.BackChannelLogoutURI, clientID: c.GetID(), token: t})
}
span := trace.SpanFromContext(ctx)
cl := s.r.HTTPClient(ctx)
execute := func(t task) {
log := s.r.Logger().WithRequest(r).
WithField("client_id", t.clientID).
WithField("backchannel_logout_url", t.url)
body := url.Values{"logout_token": {t.token}}.Encode()
req, err := retryablehttp.NewRequestWithContext(trace.ContextWithSpan(context.Background(), span), "POST", t.url, []byte(body))
if err != nil {
log.WithError(err).Error("Unable to construct OpenID Connect Back-Channel Logout Request")
return
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err := cl.Do(req)
if err != nil {
log.WithError(err).Error("Unable to execute OpenID Connect Back-Channel Logout Request")
return
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent {
log.WithError(errors.Errorf("expected HTTP status code %d or %d but got %d", http.StatusOK, http.StatusNoContent, res.StatusCode)).
Error("Unable to execute OpenID Connect Back-Channel Logout Request")
return
} else {
log.Info("Back-Channel Logout Request")
}
}
for _, t := range tasks {
go execute(t)
}
return nil
}
func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.ResponseWriter, r *http.Request) (*flow.LogoutResult, error) {
// There are two types of log out flows:
//
// - RP initiated logout
// - OP initiated logout
// Per default, we're redirecting to the global redirect URL. This is assuming that we're not an RP-initiated
// logout flow.
redir := s.c.LogoutRedirectURL(ctx).String()
if err := r.ParseForm(); err != nil {
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.
WithHintf("Logout failed because the '%s' request could not be parsed.", r.Method),
)
}
hint := r.Form.Get("id_token_hint")
state := r.Form.Get("state")
requestedRedir := r.Form.Get("post_logout_redirect_uri")
if len(hint) == 0 {
// hint is not set, so this is an OP initiated logout
if len(state) > 0 {
// state can only be set if it's an RP-initiated logout flow. If not, we should throw an error.
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Logout failed because query parameter state is set but id_token_hint is missing."))
}
if len(requestedRedir) > 0 {
// post_logout_redirect_uri can only be set if it's an RP-initiated logout flow. If not, we should throw an error.
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Logout failed because query parameter post_logout_redirect_uri is set but id_token_hint is missing."))
}
session, err := s.authenticationSession(ctx, w, r)
if errors.Is(err, ErrNoAuthenticationSessionFound) {
// OP initiated log out but no session was found. Since we can not identify the user we can not call
// any RPs.
s.r.AuditLogger().
WithRequest(r).
Info("User logout skipped because no authentication session exists.")
http.Redirect(w, r, redir, http.StatusFound)
return nil, errorsx.WithStack(ErrAbortOAuth2Request)
} else if err != nil {
return nil, err
}
challenge := uuid.New()
if err := s.r.ConsentManager().CreateLogoutRequest(r.Context(), &flow.LogoutRequest{
RequestURL: r.URL.String(),
ID: challenge,
Subject: session.Subject,
SessionID: session.ID,
Verifier: uuid.New(),
RPInitiated: false,
// PostLogoutRedirectURI is set to the value from config.Provider().LogoutRedirectURL()
PostLogoutRedirectURI: redir,
}); err != nil {
return nil, err
}
s.r.AuditLogger().
WithRequest(r).
Info("User logout requires user confirmation, redirecting to Logout UI.")
http.Redirect(w, r, urlx.SetQuery(s.c.LogoutURL(ctx), url.Values{"logout_challenge": {challenge}}).String(), http.StatusFound)
return nil, errorsx.WithStack(ErrAbortOAuth2Request)
}
claims, err := s.getIDTokenHintClaims(r.Context(), hint)
if err != nil {
return nil, err
}
mksi := mapx.KeyStringToInterface(claims)
if !claims.VerifyIssuer(s.c.IssuerURL(ctx).String(), true) {
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.
WithHintf(
`Logout failed because issuer claim value '%s' from query parameter id_token_hint does not match with issuer value from configuration '%s'.`,
mapx.GetStringDefault(mksi, "iss", ""),
s.c.IssuerURL(ctx).String(),
),
)
}
now := time.Now().UTC().Unix()
if !claims.VerifyIssuedAt(now, true) {
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.
WithHintf(
`Logout failed because iat claim value '%.0f' from query parameter id_token_hint is before now ('%d').`,
mapx.GetFloat64Default(mksi, "iat", float64(0)),
now,
),
)
}
hintSid := mapx.GetStringDefault(mksi, "sid", "")
if len(hintSid) == 0 {
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Logout failed because query parameter id_token_hint is missing sid claim."))
}
// It doesn't really make sense to use the subject value from the ID Token because it might be obfuscated.
if hintSub := mapx.GetStringDefault(mksi, "sub", ""); len(hintSub) == 0 {
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("Logout failed because query parameter id_token_hint is missing sub claim."))
}
// Let's find the client by cycling through the audiences. Typically, we only have one audience
var cl *client.Client
for _, aud := range mapx.GetStringSliceDefault(
mksi,
"aud",
[]string{
mapx.GetStringDefault(mksi, "aud", ""),
},
) {
c, err := s.r.ClientManager().GetConcreteClient(r.Context(), aud)
if errors.Is(err, x.ErrNotFound) {
continue
} else if err != nil {
return nil, err
}
cl = c
break
}
if cl == nil {
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.
WithHint("Logout failed because none of the listed audiences is a registered OAuth 2.0 Client."))
}
if len(requestedRedir) > 0 {
var f *url.URL
for _, w := range cl.PostLogoutRedirectURIs {
if w == requestedRedir {
u, err := url.Parse(w)
if err != nil {
return nil, errorsx.WithStack(fosite.ErrServerError.WithHintf("Unable to parse post_logout_redirect_uri '%s'.", w).WithDebug(err.Error()))
}
f = u
}
}
if f == nil {
return nil, errorsx.WithStack(fosite.ErrInvalidRequest.
WithHint("Logout failed because query parameter post_logout_redirect_uri is not a whitelisted as a post_logout_redirect_uri for the client."),
)
}
params := url.Values{}
if state != "" {
params.Add("state", state)
}
redir = urlx.SetQuery(f, params).String()
}
// We do not really want to verify if the user (from id token hint) has a session here because it doesn't really matter.
// Instead, we'll check this when we're actually revoking the cookie!
session, err := s.r.ConsentManager().GetRememberedLoginSession(r.Context(), nil, hintSid)
if errors.Is(err, x.ErrNotFound) {
// Such a session does not exist - maybe it has already been revoked? In any case, we can't do much except
// leaning back and redirecting back.
http.Redirect(w, r, redir, http.StatusFound)
return nil, errorsx.WithStack(ErrAbortOAuth2Request)
} else if err != nil {
return nil, err
}
challenge := uuid.New()
if err := s.r.ConsentManager().CreateLogoutRequest(r.Context(), &flow.LogoutRequest{
RequestURL: r.URL.String(),
ID: challenge,
SessionID: hintSid,
Subject: session.Subject,
Verifier: uuid.New(),
Client: cl,
RPInitiated: true,
// PostLogoutRedirectURI is set to the value from config.Provider().LogoutRedirectURL()
PostLogoutRedirectURI: redir,
}); err != nil {
return nil, err
}
http.Redirect(w, r, urlx.SetQuery(s.c.LogoutURL(ctx), url.Values{"logout_challenge": {challenge}}).String(), http.StatusFound)
return nil, errorsx.WithStack(ErrAbortOAuth2Request)
}
func (s *DefaultStrategy) performBackChannelLogoutAndDeleteSession(r *http.Request, subject string, sid string) error {
ctx := r.Context()
if err := s.executeBackChannelLogout(r, subject, sid); err != nil {
return err
}
// We delete the session after back channel log out has worked as the session is otherwise removed
// from the store which will break the query for finding all the channels.
//
// executeBackChannelLogout only fails on system errors so not on URL errors, so this should be fine
// even if an upstream URL fails!
if session, err := s.r.ConsentManager().DeleteLoginSession(ctx, sid); errors.Is(err, sqlcon.ErrNoRows) {
// This is ok (session probably already revoked), do nothing!
} else if err != nil {
return err
} else {
innerErr := s.r.Kratos().DisableSession(ctx, session.IdentityProviderSessionID.String())
if innerErr != nil {
s.r.Logger().WithError(innerErr).WithField("sid", sid).Error("Unable to revoke session in ORY Kratos.")
}
// We don't return the error here because we don't want to break the logout flow if Kratos is down.
}
return nil
}
func (s *DefaultStrategy) completeLogout(ctx context.Context, w http.ResponseWriter, r *http.Request) (*flow.LogoutResult, error) {
verifier := r.URL.Query().Get("logout_verifier")
lr, err := s.r.ConsentManager().VerifyAndInvalidateLogoutRequest(r.Context(), verifier)
if err != nil {
return nil, err
}
if !lr.RPInitiated {
// If this is true it means that no id_token_hint was given, so the session id and subject id
// came from an original cookie.
session, err := s.authenticationSession(ctx, w, r)
if errors.Is(err, ErrNoAuthenticationSessionFound) {
// If we end up here it means that the cookie was revoked between the initial logout request
// and ending up here - possibly due to a duplicate submit. In that case, we really have nothing to
// do because the logout was already completed, apparently!
// We also won't call any front- or back-channel logouts because that would mean we had called them twice!
// OP initiated log out but no session was found. So let's just redirect back...
http.Redirect(w, r, lr.PostLogoutRedirectURI, http.StatusFound)
return nil, errorsx.WithStack(ErrAbortOAuth2Request)
} else if err != nil {
return nil, err
}
if session.Subject != lr.Subject {
// If we end up here it means that the authentication cookie changed between the initial logout request
// and landing here. That could happen because the user signed in in another browser window. In that
// case there isn't really a lot to do because we don't want to sign out a different ID, so let's just
// go to the post redirect uri without actually doing anything!
http.Redirect(w, r, lr.PostLogoutRedirectURI, http.StatusFound)
return nil, errorsx.WithStack(ErrAbortOAuth2Request)
}
}
store, err := s.r.CookieStore(ctx)
if err != nil {
return nil, err
}
_, _ = s.revokeAuthenticationCookie(w, r, store) // Cookie removal is optional
urls, err := s.generateFrontChannelLogoutURLs(r.Context(), lr.Subject, lr.SessionID)
if err != nil {
return nil, err
}
if err := s.performBackChannelLogoutAndDeleteSession(r, lr.Subject, lr.SessionID); err != nil {
return nil, err
}
s.r.AuditLogger().
WithRequest(r).
WithField("subject", lr.Subject).
Info("User logout completed!")
return &flow.LogoutResult{
RedirectTo: lr.PostLogoutRedirectURI,
FrontChannelLogoutURLs: urls,
}, nil
}
func (s *DefaultStrategy) HandleOpenIDConnectLogout(ctx context.Context, w http.ResponseWriter, r *http.Request) (*flow.LogoutResult, error) {
verifier := r.URL.Query().Get("logout_verifier")
if verifier == "" {
return s.issueLogoutVerifier(ctx, w, r)
}
return s.completeLogout(ctx, w, r)
}
func (s *DefaultStrategy) HandleHeadlessLogout(ctx context.Context, _ http.ResponseWriter, r *http.Request, sid string) error {
loginSession, lsErr := s.r.ConsentManager().GetRememberedLoginSession(ctx, nil, sid)
if errors.Is(lsErr, x.ErrNotFound) {
// This is ok (session probably already revoked), do nothing!
// Not triggering the back-channel logout because subject is not available
// See https://github.com/ory/hydra/pull/3450#discussion_r1127798485
return nil
} else if lsErr != nil {
return lsErr
}
if err := s.performBackChannelLogoutAndDeleteSession(r, loginSession.Subject, sid); err != nil {
return err
}
s.r.AuditLogger().
WithRequest(r).
WithField("subject", loginSession.Subject).
WithField("sid", sid).
Info("User logout completed via headless flow!")
return nil
}
func (s *DefaultStrategy) HandleOAuth2AuthorizationRequest(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
req fosite.AuthorizeRequester,
) (_ *flow.AcceptOAuth2ConsentRequest, _ *flow.Flow, err error) {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("").Start(ctx, "DefaultStrategy.HandleOAuth2AuthorizationRequest")
defer otelx.End(span, &err)
loginVerifier := strings.TrimSpace(req.GetRequestForm().Get("login_verifier"))
consentVerifier := strings.TrimSpace(req.GetRequestForm().Get("consent_verifier"))
if loginVerifier == "" && consentVerifier == "" {
// ok, we need to process this request and redirect to auth endpoint
return nil, nil, s.requestAuthentication(ctx, w, r, req)
} else if loginVerifier != "" {
f, err := s.verifyAuthentication(ctx, w, r, req, loginVerifier)
if err != nil {
return nil, nil, err
}
// ok, we need to process this request and redirect to auth endpoint
return nil, f, s.requestConsent(ctx, w, r, req, f)
}
consentSession, f, err := s.verifyConsent(ctx, w, r, consentVerifier)
if err != nil {
return nil, nil, err
}
return consentSession, f, nil
}
func (s *DefaultStrategy) ObfuscateSubjectIdentifier(ctx context.Context, cl fosite.Client, subject, forcedIdentifier string) (string, error) {
if c, ok := cl.(*client.Client); ok && c.SubjectType == "pairwise" {
algorithm, ok := s.r.SubjectIdentifierAlgorithm(ctx)[c.SubjectType]
if !ok {
return "", errorsx.WithStack(fosite.ErrInvalidRequest.WithHintf(`Subject Identifier Algorithm '%s' was requested by OAuth 2.0 Client '%s' but is not configured.`, c.SubjectType, c.GetID()))
}
if len(forcedIdentifier) > 0 {
return forcedIdentifier, nil
}
return algorithm.Obfuscate(subject, c)
} else if !ok {
return "", errors.New("Unable to type assert OAuth 2.0 Client to *client.Client")
}
return subject, nil
}
func (s *DefaultStrategy) loginSessionFromCookie(r *http.Request) *flow.LoginSession {
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
return nil
}
ls, _ := flowctx.FromCookie[flow.LoginSession](r.Context(), r, s.r.FlowCipher(), flowctx.LoginSessionCookie(flowctx.SuffixFromStatic(clientID)))
return ls
}