/* Copyright 2020, 2021 Jan Dittberner 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 http://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 ( "context" "encoding/json" "html/template" "net/http" "net/url" "strings" "time" commonModels "git.cacert.org/oidc_idp/models" "git.cacert.org/oidc_idp/ui" "github.com/go-playground/form/v4" "github.com/gorilla/csrf" "github.com/lestrrat-go/jwx/jwt/openid" "github.com/nicksnyder/go-i18n/v2/i18n" "github.com/ory/hydra-client-go/client/admin" "github.com/ory/hydra-client-go/models" log "github.com/sirupsen/logrus" "git.cacert.org/oidc_idp/services" ) type consentHandler struct { adminClient *admin.Client bundle *i18n.Bundle consentTemplate *template.Template context context.Context 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.Debugf("received consent challenge %s", 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 } switch r.Method { case http.MethodGet: h.renderConsentForm(w, r, consentData, requestedClaims, err, localizer) break case http.MethodPost: var consentInfo ConsentInformation // validate input decoder := form.NewDecoder() if err := decoder.Decode(&consentInfo, r.Form); err != nil { h.logger.Error(err) http.Error( w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError, ) return } if consentInfo.ConsentChecked { sessionData, err := h.getSessionData(r, consentInfo, requestedClaims, consentData.Payload) if err != nil { h.logger.Errorf("could not get session data: %v", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } consentRequest, err := h.adminClient.AcceptConsentRequest( admin.NewAcceptConsentRequestParams().WithConsentChallenge(challenge).WithBody( &models.AcceptConsentRequest{ GrantAccessTokenAudience: nil, GrantScope: consentInfo.GrantedScopes, HandledAt: models.NullTime(time.Now()), Remember: true, RememberFor: 86400, Session: sessionData, }).WithTimeout(time.Second * 10)) if err != nil { h.logger.Error(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } w.Header().Add("Location", *consentRequest.GetPayload().RedirectTo) w.WriteHeader(http.StatusFound) return } else { consentRequest, err := h.adminClient.RejectConsentRequest( admin.NewRejectConsentRequestParams().WithConsentChallenge(challenge).WithBody( &models.RejectRequest{})) if err != nil { h.logger.Error(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } w.Header().Add("Location", *consentRequest.GetPayload().RedirectTo) w.WriteHeader(http.StatusFound) } } } func (h *consentHandler) getRequestedConsentInformation(challenge string, r *http.Request) ( *admin.GetConsentRequestOK, *commonModels.OIDCClaimsRequest, error, ) { consentData, err := h.adminClient.GetConsentRequest( admin.NewGetConsentRequestParams().WithConsentChallenge(challenge)) if err != nil { h.logger.Errorf("error getting consent information: %v", err) var errorDetails *ErrorDetails errorDetails = &ErrorDetails{ ErrorMessage: "could not get consent details", ErrorDetails: []string{http.StatusText(http.StatusInternalServerError)}, } GetErrorBucket(r).AddError(errorDetails) return nil, nil, err } var requestedClaims commonModels.OIDCClaimsRequest requestUrl, err := url.Parse(consentData.Payload.RequestURL) if err != nil { h.logger.Warnf("could not parse original request URL %s: %v", consentData.Payload.RequestURL, err) } else { claimsParameter := requestUrl.Query().Get("claims") if claimsParameter != "" { decoder := json.NewDecoder(strings.NewReader(claimsParameter)) err := decoder.Decode(&requestedClaims) if err != nil { h.logger.Warnf( "ignoring claims request parameter %s that could not be decoded: %v", claimsParameter, err, ) } } } return consentData, &requestedClaims, nil } func (h *consentHandler) renderConsentForm( w http.ResponseWriter, r *http.Request, consentData *admin.GetConsentRequestOK, claims *commonModels.OIDCClaimsRequest, err error, localizer *i18n.Localizer, ) { 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 client := consentData.GetPayload().Client err = h.consentTemplate.Lookup("base").Execute(w, map[string]interface{}{ "Title": trans("TitleRequestConsent"), csrf.TemplateTag: csrf.TemplateField(r), "errors": map[string]string{}, "client": client, "requestedScope": h.mapRequestedScope(consentData.GetPayload().RequestedScope, localizer), "requestedClaims": h.mapRequestedClaims(claims, localizer), "LabelSubmit": trans("LabelSubmit"), "LabelConsent": trans("LabelConsent"), "IntroMoreInformation": template.HTML(trans("IntroConsentMoreInformation", map[string]interface{}{ "client": client.ClientName, "clientLink": client.ClientURI, })), "ClaimsInformation": template.HTML(trans("ClaimsInformation", nil)), "IntroConsentRequested": template.HTML(trans("IntroConsentRequested", map[string]interface{}{ "client": client.ClientName, })), }) } type scopeWithLabel struct { Name string Label string } func (h *consentHandler) mapRequestedScope(scope models.StringSlicePipeDelimiter, localizer *i18n.Localizer) []*scopeWithLabel { result := make([]*scopeWithLabel, 0) for _, scopeName := range scope { if _, ok := supportedScopes[scopeName]; !ok { h.logger.Warnf("unsupported scope %s ignored", scopeName) continue } label, err := localizer.Localize(&i18n.LocalizeConfig{ DefaultMessage: supportedScopes[scopeName], }) if err != nil { h.logger.Warnf("could not localize label for scope %s: %v", scopeName, err) 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 *commonModels.OIDCClaimsRequest, localizer *i18n.Localizer) []*claimWithLabel { result := make([]*claimWithLabel, 0) known := make(map[string]bool) for _, claimElement := range []*commonModels.ClaimElement{claims.GetUserInfo(), claims.GetIDToken()} { if claimElement != nil { for k, v := range *claimElement { if _, ok := supportedClaims[k]; !ok { h.logger.Warnf("unsupported claim %s ignored", k) continue } label, err := localizer.Localize(&i18n.LocalizeConfig{ DefaultMessage: supportedClaims[k], }) if err != nil { h.logger.Warnf("could not localize label for claim %s: %v", k, err) 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( r *http.Request, info ConsentInformation, claims *commonModels.OIDCClaimsRequest, payload *models.ConsentRequest, ) (*models.ConsentRequestSession, error) { idTokenData := make(map[string]interface{}, 0) accessTokenData := make(map[string]interface{}, 0) userInfo := h.GetUserInfoFromClientCertificate(r, payload.Subject) h.fillTokenData(accessTokenData, payload.RequestedScope, claims, info, userInfo) h.fillTokenData(idTokenData, payload.RequestedScope, claims, info, userInfo) return &models.ConsentRequestSession{ AccessToken: accessTokenData, IDToken: idTokenData, }, nil } func (h *consentHandler) fillTokenData( m map[string]interface{}, requestedScope models.StringSlicePipeDelimiter, claimsRequest *commonModels.OIDCClaimsRequest, consentInformation ConsentInformation, userInfo *UserInfo, ) { for _, scope := range requestedScope { granted := false for _, k := range consentInformation.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] = userInfo.Email m[openid.EmailVerifiedKey] = userInfo.EmailVerified break 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] = userInfo.GetFullName() break } } if userInfoClaims := claimsRequest.GetUserInfo(); userInfoClaims != nil { for claimName, claim := range *userInfoClaims { granted := false for _, k := range consentInformation.SelectedClaims { if k == claimName { granted = true break } } if !granted { continue } if claim.WantedValue() != nil { m[claimName] = *claim.WantedValue() continue } if claim.IsEssential() { h.logger.Warnf( "handling for essential claim name %s not implemented", claimName, ) } else { h.logger.Warnf( "handling for claim name %s not implemented", claimName, ) } } } } func (h *consentHandler) GetUserInfoFromClientCertificate(r *http.Request, subject string) *UserInfo { if r.TLS != nil && r.TLS.PeerCertificates != nil && len(r.TLS.PeerCertificates) > 0 { firstCert := r.TLS.PeerCertificates[0] var verified bool for _, email := range firstCert.EmailAddresses { h.logger.Infof("authenticated with a client certificate for email address %s", email) if subject == email { verified = true } } if !verified { h.logger.Warnf("authentication attempt with a wrong certificate that did not contain the requested address %s", subject) return nil } return &UserInfo{ Email: subject, EmailVerified: verified, CommonName: firstCert.Subject.CommonName, } } return nil } func NewConsentHandler(ctx context.Context, logger *log.Logger) (*consentHandler, error) { consentTemplate := template.Must( template.ParseFS( ui.Templates, "templates/base.gohtml", "templates/consent.gohtml", )) return &consentHandler{ adminClient: ctx.Value(CtxAdminClient).(*admin.Client), bundle: services.GetI18nBundle(ctx), consentTemplate: consentTemplate, context: ctx, logger: logger, messageCatalog: services.GetMessageCatalog(ctx), }, nil }