You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
oidc-idp/internal/handlers/consent.go

536 lines
14 KiB
Go

/*
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/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"
commonModels "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 admin.ClientService
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"
)
const OneDayInSeconds = 86400
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
}
switch r.Method {
case http.MethodGet:
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)
return
}
case http.MethodPost:
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 {
sessionData, err := h.getSessionData(r, consentInfo, requestedClaims, consentData.Payload)
if err != nil {
h.logger.WithError(err).Error("could not get session data")
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: OneDayInSeconds,
Session: sessionData,
}).WithTimeout(TimeoutTen))
if err != nil {
h.logger.WithError(err).Error("accept consent request failed")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Header().Add("Location", *consentRequest.GetPayload().RedirectTo)
w.WriteHeader(http.StatusFound)
return
}
consentRequest, err := h.adminClient.RejectConsentRequest(
admin.NewRejectConsentRequestParams().WithConsentChallenge(challenge).WithBody(
&models.RejectRequest{}))
if err != nil {
h.logger.WithError(err).Error("reject consent request failed")
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.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)
}
var requestedClaims commonModels.OIDCClaimsRequest
requestURL, err := url.Parse(consentData.Payload.RequestURL)
if err != nil {
h.logger.WithError(err).WithField(
"request_url", consentData.Payload.RequestURL,
).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 consentData, &requestedClaims, nil
}
func (h *ConsentHandler) renderConsentForm(
w http.ResponseWriter,
r *http.Request,
consentData *admin.GetConsentRequestOK,
claims *commonModels.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
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( //nolint:gosec
trans("IntroConsentMoreInformation", map[string]interface{}{
"client": client.ClientName,
"clientLink": client.ClientURI,
})),
"ClaimsInformation": template.HTML( //nolint:gosec
trans("ClaimsInformation", nil)),
"IntroConsentRequested": template.HTML( //nolint:gosec
trans("IntroConsentRequested", map[string]interface{}{
"client": client.ClientName,
})),
})
if err != nil {
return fmt.Errorf("rendering failed: %w", err)
}
return nil
}
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.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 *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.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(
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)
if err := h.fillTokenData(accessTokenData, payload.RequestedScope, claims, info, userInfo); err != nil {
return nil, err
}
if err := h.fillTokenData(idTokenData, payload.RequestedScope, claims, info, userInfo); err != nil {
return nil, err
}
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,
) error {
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
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()
}
}
if userInfoClaims := claimsRequest.GetUserInfo(); userInfoClaims != nil {
err := h.parseUserInfoClaims(m, userInfoClaims, consentInformation)
if err != nil {
return err
}
}
return nil
}
func (h *ConsentHandler) parseUserInfoClaims(
m map[string]interface{},
userInfoClaims *commonModels.ClaimElement,
consentInformation ConsentInformation,
) error {
for claimName, claim := range *userInfoClaims {
granted := false
for _, k := range consentInformation.SelectedClaims {
if k == claimName {
granted = true
break
}
}
if !granted {
continue
}
wantedValue, err := claim.WantedValue()
if err != nil {
if !errors.Is(err, commonModels.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 (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.WithField(
"email", email,
).Info("authenticated with client certificate for email address")
if subject == email {
verified = true
}
}
if !verified {
h.logger.WithField(
"subject", subject,
).Warn("authentication attempt with a wrong certificate that did not contain the requested address")
return nil
}
return &UserInfo{
Email: subject,
EmailVerified: verified,
CommonName: firstCert.Subject.CommonName,
}
}
return nil
}
func NewConsentHandler(
logger *log.Logger,
bundle *i18n.Bundle,
messageCatalog *services.MessageCatalog,
adminClient admin.ClientService,
) *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,
}
}