oidc-idp/internal/handlers/consent.go

605 lines
16 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/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/internal/services"
)
type ConsentHandler struct {
logger *log.Logger
trans *services.I18NService
adminClient client.OAuth2API
templates TemplateCache
}
type ConsentInformation struct {
GrantedScopes []string `form:"scope"`
SelectedClaims []string `form:"claims"`
ConsentAction string `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")
localizer := getLocalizer(h.trans, r)
// 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
}
h.renderConsentForm(w, r, consentData, requestedClaims, localizer)
}
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.ConsentAction == "consent" {
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,
) {
trans := h.trans.LookupMessage
transMarkdown := func(id string, params map[string]interface{}, localizer *i18n.Localizer) template.HTML {
return template.HTML( //nolint:gosec
h.trans.LookupMarkdownMessage(id, params, localizer),
)
}
// render consent form
oAuth2Client := consentRequest.Client
clientLogoURI := oAuth2Client.GetLogoUri()
clientName := template.HTMLEscaper(oAuth2Client.GetClientName())
clientURI := oAuth2Client.GetClientUri()
h.templates.render(h.logger, w, Consent, map[string]interface{}{
"Title": trans("TitleRequestConsent", nil, localizer),
csrf.TemplateTag: csrf.TemplateField(r),
"errors": map[string]string{},
"LogoURI": clientLogoURI,
"ClientName": clientName,
"requestedScope": h.mapRequestedScope(consentRequest.RequestedScope, localizer),
"requestedClaims": h.mapRequestedClaims(claims, localizer),
"ButtonTitleConsent": trans("ButtonTitleConsent", nil, localizer),
"ButtonTitleDeny": trans("ButtonTitleDeny", nil, localizer),
"HasMoreInformation": clientURI != "",
"IntroMoreInformation": transMarkdown(
"IntroConsentMoreInformation", map[string]interface{}{
"client": clientName,
"clientLink": clientURI,
}, localizer),
"LabelConsent": transMarkdown("LabelConsent", nil, localizer),
"ClaimsInformation": transMarkdown(
"ClaimsInformation", nil, localizer),
"IntroConsentRequested": transMarkdown(
"IntroConsentRequested", map[string]interface{}{
"client": clientName,
}, localizer),
})
}
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, templateCache TemplateCache, trans *services.I18NService, adminClient client.OAuth2API,
) *ConsentHandler {
return &ConsentHandler{
logger: logger,
trans: trans,
adminClient: adminClient,
templates: templateCache,
}
}