/* Copyright 2020-2023 CAcert Inc. SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package handlers import ( "encoding/json" "errors" "fmt" "html/template" "net/http" "net/url" "strings" "time" "github.com/go-playground/form/v4" "github.com/gorilla/csrf" "github.com/gorilla/sessions" "github.com/lestrrat-go/jwx/jwt/openid" "github.com/nicksnyder/go-i18n/v2/i18n" client "github.com/ory/hydra-client-go/v2" log "github.com/sirupsen/logrus" "code.cacert.org/cacert/oidc-idp/internal/models" "code.cacert.org/cacert/oidc-idp/ui" "code.cacert.org/cacert/oidc-idp/internal/services" ) type ConsentHandler struct { adminClient client.OAuth2Api bundle *i18n.Bundle consentTemplate *template.Template logger *log.Logger messageCatalog *services.MessageCatalog } type ConsentInformation struct { GrantedScopes []string `form:"scope"` SelectedClaims []string `form:"claims"` ConsentChecked bool `form:"consent"` } type UserInfo struct { Email string EmailVerified bool CommonName string } var supportedScopes, supportedClaims map[string]*i18n.Message const ( ScopeOpenID = "openid" ScopeOffline = "offline" ScopeOfflineAccess = "offline_access" ScopeProfile = "profile" ScopeEmail = "email" ) func init() { supportedScopes = make(map[string]*i18n.Message) supportedScopes[ScopeOpenID] = &i18n.Message{ ID: "Scope-openid-Description", Other: "Request information about your identity.", } supportedScopes[ScopeOffline] = &i18n.Message{ ID: "Scope-offline-Description", Other: "Keep access to your information until you revoke the permission.", } supportedScopes[ScopeOfflineAccess] = supportedScopes[ScopeOffline] supportedScopes[ScopeProfile] = &i18n.Message{ ID: "Scope-profile-Description", Other: "Access your user profile information (your name).", } supportedScopes[ScopeEmail] = &i18n.Message{ ID: "Scope-email-Description", Other: "Access your email address.", } supportedClaims = make(map[string]*i18n.Message) supportedClaims[openid.SubjectKey] = nil supportedClaims[openid.EmailKey] = nil supportedClaims[openid.EmailVerifiedKey] = nil supportedClaims[openid.GivenNameKey] = nil supportedClaims[openid.FamilyNameKey] = nil supportedClaims[openid.MiddleNameKey] = nil supportedClaims[openid.NameKey] = nil supportedClaims[openid.BirthdateKey] = nil supportedClaims[openid.ZoneinfoKey] = nil supportedClaims[openid.LocaleKey] = nil } func (i *UserInfo) GetFullName() string { return i.CommonName } func (h *ConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { challenge := r.URL.Query().Get("consent_challenge") h.logger.WithField("consent_challenge", challenge).Debug("received consent challenge") accept := r.Header.Get("Accept-Language") localizer := i18n.NewLocalizer(h.bundle, accept) // retrieve consent information consentData, requestedClaims, err := h.getRequestedConsentInformation(challenge, r) if err != nil { // error is already handled in getRequestConsentInformation return } session, err := GetSession(r) if err != nil { h.logger.WithError(err).Error("could get session for request") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } switch r.Method { case http.MethodGet: h.handleGet(w, r, consentData, requestedClaims, session, challenge, localizer) case http.MethodPost: h.handlePost(w, r, consentData, requestedClaims, session, challenge) } } func (h *ConsentHandler) handleGet( w http.ResponseWriter, r *http.Request, consentData *client.OAuth2ConsentRequest, requestedClaims *models.OIDCClaimsRequest, session *sessions.Session, challenge string, localizer *i18n.Localizer, ) { // Hydra has a previous session for this user and client if consentData.GetSkip() { consentRequest, err := h.handleExistingConsent(consentData, requestedClaims, session) if err != nil { h.logger.WithError(err).Error("could not handle existing consent") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } err = h.acceptConsent(w, r, challenge, consentRequest) if err != nil { h.logger.WithError(err).Error("could not accept consent") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } if err := h.renderConsentForm(w, r, consentData, requestedClaims, localizer); err != nil { h.logger.WithError(err).Error("could not render consent form") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } func (h *ConsentHandler) handlePost( w http.ResponseWriter, r *http.Request, consentData *client.OAuth2ConsentRequest, requestedClaims *models.OIDCClaimsRequest, session *sessions.Session, challenge string, ) { var consentInfo ConsentInformation // validate input decoder := form.NewDecoder() if err := decoder.Decode(&consentInfo, r.Form); err != nil { h.logger.WithError(err).Error("could not decode consent form") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } if consentInfo.ConsentChecked { consentRequest, err := h.rememberNewConsent(consentData, consentInfo, requestedClaims, session) if err != nil { h.logger.WithError(err).Error("could not accept consent") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } err = h.acceptConsent(w, r, challenge, consentRequest) if err != nil { h.logger.WithError(err).Error("could not accept consent") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } consentRequest, response, err := h.adminClient.RejectOAuth2ConsentRequest( r.Context(), ).ConsentChallenge(challenge).Execute() if err != nil { h.logger.WithError(err).Error("reject consent request failed") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } defer func() { _ = response.Body.Close() }() h.logger.WithFields( log.Fields{"response": response.Status, "reject_consent_request": consentRequest}, ).Debug("received response for RejectOAuth2ConsentRequest") w.Header().Add("Location", consentRequest.GetRedirectTo()) w.WriteHeader(http.StatusFound) } func (h *ConsentHandler) rememberNewConsent( consentData *client.OAuth2ConsentRequest, consentInfo ConsentInformation, requestedClaims *models.OIDCClaimsRequest, session *sessions.Session, ) (*client.AcceptOAuth2ConsentRequest, error) { sessionData, err := h.getSessionData( consentData.GetRequestedScope(), consentInfo.GrantedScopes, consentInfo.SelectedClaims, requestedClaims, session, ) if err != nil { return nil, fmt.Errorf("could not get session data: %w", err) } request := client.NewAcceptOAuth2ConsentRequestWithDefaults() request.SetRemember(true) request.SetHandledAt(time.Now()) request.SetGrantAccessTokenAudience(consentData.RequestedAccessTokenAudience) request.SetGrantScope(consentInfo.GrantedScopes) request.SetRememberFor(0) request.SetSession(*sessionData) return request, nil } func (h *ConsentHandler) acceptConsent( w http.ResponseWriter, r *http.Request, challenge string, request *client.AcceptOAuth2ConsentRequest, ) error { oAuth2RedirectTo, response, err := h.adminClient.AcceptOAuth2ConsentRequest( r.Context(), ).ConsentChallenge(challenge).AcceptOAuth2ConsentRequest(*request).Execute() if err != nil { return fmt.Errorf("accept consent request failed: %w", err) } defer func() { _ = response.Body.Close() }() h.logger.WithFields( log.Fields{"response": response.Status, "redirect_to": oAuth2RedirectTo}, ).Debug("received response for AcceptOAuth2ConsentRequest") w.Header().Add("Location", oAuth2RedirectTo.GetRedirectTo()) w.WriteHeader(http.StatusFound) return nil } func (h *ConsentHandler) handleExistingConsent( data *client.OAuth2ConsentRequest, claims *models.OIDCClaimsRequest, session *sessions.Session, ) (*client.AcceptOAuth2ConsentRequest, error) { sessionData, err := h.getSessionData(data.GetRequestedScope(), data.GetRequestedScope(), []string{}, claims, session) if err != nil { return nil, fmt.Errorf("could not get session data: %w", err) } request := client.NewAcceptOAuth2ConsentRequestWithDefaults() request.SetGrantScope(data.RequestedScope) request.SetHandledAt(time.Now()) request.SetGrantAccessTokenAudience(data.RequestedAccessTokenAudience) request.SetSession(*sessionData) return request, nil } func (h *ConsentHandler) getRequestedConsentInformation(challenge string, r *http.Request) ( *client.OAuth2ConsentRequest, *models.OIDCClaimsRequest, error, ) { consentRequest, response, err := h.adminClient.GetOAuth2ConsentRequest( r.Context(), ).ConsentChallenge(challenge).Execute() if err != nil { h.logger.WithError(err).Error("error getting consent information") if errorBucket := GetErrorBucket(r); errorBucket != nil { errorDetails := &ErrorDetails{ ErrorMessage: "could not get consent details", ErrorDetails: []string{http.StatusText(http.StatusInternalServerError)}, } errorBucket.AddError(errorDetails) } return nil, nil, fmt.Errorf("error getting consent information: %w", err) } defer func() { _ = response.Body.Close() }() h.logger.WithFields( log.Fields{"response": response.Status, "consent_request": consentRequest}, ).Debug("response for GetOAuth2ConsentRequest") var requestedClaims models.OIDCClaimsRequest requestURLStr := consentRequest.GetRequestUrl() requestURL, err := url.Parse(requestURLStr) if err != nil { h.logger.WithError(err).WithField( "request_url", requestURLStr, ).Warn("could not parse original request URL") } else { claimsParameter := requestURL.Query().Get("claims") if claimsParameter != "" { decoder := json.NewDecoder(strings.NewReader(claimsParameter)) err := decoder.Decode(&requestedClaims) if err != nil { h.logger.WithError(err).WithField( "claims_parameter", claimsParameter, ).Warn("ignoring claims request parameter that could not be decoded") } } } return consentRequest, &requestedClaims, nil } func (h *ConsentHandler) renderConsentForm( w http.ResponseWriter, r *http.Request, consentRequest *client.OAuth2ConsentRequest, claims *models.OIDCClaimsRequest, localizer *i18n.Localizer, ) error { trans := func(id string, values ...map[string]interface{}) string { if len(values) > 0 { return h.messageCatalog.LookupMessage(id, values[0], localizer) } return h.messageCatalog.LookupMessage(id, nil, localizer) } // render consent form oAuth2Client := consentRequest.Client err := h.consentTemplate.Lookup("base").Execute(w, map[string]interface{}{ "Title": trans("TitleRequestConsent"), csrf.TemplateTag: csrf.TemplateField(r), "errors": map[string]string{}, "client": oAuth2Client, "requestedScope": h.mapRequestedScope(consentRequest.RequestedScope, localizer), "requestedClaims": h.mapRequestedClaims(claims, localizer), "LabelSubmit": trans("LabelSubmit"), "LabelConsent": trans("LabelConsent"), "IntroMoreInformation": template.HTML( //nolint:gosec trans("IntroConsentMoreInformation", map[string]interface{}{ "client": oAuth2Client.GetClientName(), "clientLink": oAuth2Client.GetClientUri(), })), "ClaimsInformation": template.HTML( //nolint:gosec trans("ClaimsInformation", nil)), "IntroConsentRequested": template.HTML( //nolint:gosec trans("IntroConsentRequested", map[string]interface{}{ "client": oAuth2Client.GetClientName(), })), }) if err != nil { return fmt.Errorf("rendering failed: %w", err) } return nil } type scopeWithLabel struct { Name string Label string } func (h *ConsentHandler) mapRequestedScope( scope []string, localizer *i18n.Localizer, ) []*scopeWithLabel { result := make([]*scopeWithLabel, 0) for _, scopeName := range scope { if _, ok := supportedScopes[scopeName]; !ok { h.logger.WithField("scope", scopeName).Warn("ignoring unsupported scope") continue } label, err := localizer.Localize(&i18n.LocalizeConfig{ DefaultMessage: supportedScopes[scopeName], }) if err != nil { h.logger.WithError(err).WithField("scope", scopeName).Warn("could not localize scope label") label = scopeName } result = append(result, &scopeWithLabel{Name: scopeName, Label: label}) } return result } type claimWithLabel struct { Name string Label string Essential bool } func (h *ConsentHandler) mapRequestedClaims( claims *models.OIDCClaimsRequest, localizer *i18n.Localizer, ) []*claimWithLabel { result := make([]*claimWithLabel, 0) known := make(map[string]bool) for _, claimElement := range []*models.ClaimElement{claims.GetUserInfo(), claims.GetIDToken()} { if claimElement != nil { for k, v := range *claimElement { if _, ok := supportedClaims[k]; !ok { h.logger.WithField("claim", k).Warn("ignoring unsupported claim") continue } label, err := localizer.Localize(&i18n.LocalizeConfig{ DefaultMessage: supportedClaims[k], }) if err != nil { h.logger.WithError(err).WithField("claim", k).Warn("could not localize claim label") label = k } if !known[k] { result = append(result, &claimWithLabel{ Name: k, Label: label, Essential: v.IsEssential(), }) known[k] = true } } } } return result } func (h *ConsentHandler) getSessionData( requestedScopes, grantedScopes, selectedClaims []string, claims *models.OIDCClaimsRequest, session *sessions.Session, ) (*client.AcceptOAuth2ConsentRequestSession, error) { idTokenData := make(map[string]interface{}) if err := h.fillTokenData( idTokenData, requestedScopes, grantedScopes, claims, selectedClaims, session.Values, ); err != nil { return nil, err } consentSession := client.NewAcceptOAuth2ConsentRequestSession() consentSession.SetIdToken(idTokenData) return consentSession, nil } func (h *ConsentHandler) fillTokenData( m map[string]interface{}, requestedScopes []string, grantedScopes []string, claimsRequest *models.OIDCClaimsRequest, selectedClaims []string, sessionData map[interface{}]interface{}, ) error { for _, scope := range requestedScopes { granted := false for _, k := range grantedScopes { if k == scope { granted = true break } } if !granted { continue } switch scope { case ScopeEmail: // email // OPTIONAL. This scope value requests access to the email and // email_verified Claims. m[openid.EmailKey] = sessionData[services.SessionEmail] m[openid.EmailVerifiedKey] = true case ScopeProfile: // profile // OPTIONAL. This scope value requests access to the // End-User's default profile Claims, which are: name, // family_name, given_name, middle_name, nickname, // preferred_username, profile, picture, website, gender, // birthdate, zoneinfo, locale, and updated_at. m[openid.NameKey] = sessionData[services.SessionFullName] } } if userInfoClaims := claimsRequest.GetUserInfo(); userInfoClaims != nil { err := h.parseUserInfoClaims(m, userInfoClaims, selectedClaims) if err != nil { return err } } return nil } func (h *ConsentHandler) parseUserInfoClaims( m map[string]interface{}, userInfoClaims *models.ClaimElement, selectedClaims []string, ) error { for claimName, claim := range *userInfoClaims { granted := false for _, k := range selectedClaims { if k == claimName { granted = true break } } if !granted { continue } wantedValue, err := claim.WantedValue() if err != nil { if !errors.Is(err, models.ErrNoValue) { return fmt.Errorf("error handling claim: %w", err) } } if wantedValue != "" { m[claimName] = wantedValue continue } if claim.IsEssential() { h.logger.WithField("claim", claimName).Warn("handling for essential claim not implemented") } else { h.logger.WithField("claim", claimName).Warn("handling for claim not implemented") } } return nil } func NewConsentHandler( logger *log.Logger, bundle *i18n.Bundle, messageCatalog *services.MessageCatalog, adminClient client.OAuth2Api, ) *ConsentHandler { consentTemplate := template.Must( template.ParseFS( ui.Templates, "templates/base.gohtml", "templates/consent.gohtml", )) return &ConsentHandler{ adminClient: adminClient, bundle: bundle, consentTemplate: consentTemplate, logger: logger, messageCatalog: messageCatalog, } }