611 lines
16 KiB
Go
611 lines
16 KiB
Go
/*
|
|
Copyright 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"
|
|
"log/slog"
|
|
"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"
|
|
|
|
"code.cacert.org/cacert/oidc-idp/internal/models"
|
|
"code.cacert.org/cacert/oidc-idp/internal/services"
|
|
)
|
|
|
|
type ConsentHandler struct {
|
|
logger *slog.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.Debug("received consent challenge", "consent_challenge", 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.Error("could get session for request", "error", err)
|
|
|
|
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.Error("could not handle existing consent", "error", err)
|
|
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
err = h.acceptConsent(w, r, challenge, consentRequest)
|
|
if err != nil {
|
|
h.logger.Error("could not accept consent", "error", err)
|
|
|
|
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.Error("could not decode consent form", "error", err)
|
|
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.Error("could not accept consent", "error", err)
|
|
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
|
|
err = h.acceptConsent(w, r, challenge, consentRequest)
|
|
if err != nil {
|
|
h.logger.Error("could not accept consent", "error", err)
|
|
|
|
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.Error("reject consent request failed", "error", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
h.logger.Debug(
|
|
"received response for RejectOAuth2ConsentRequest",
|
|
"response", response.Status, "reject_consent_request", consentRequest,
|
|
)
|
|
|
|
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.Debug(
|
|
"received response for AcceptOAuth2ConsentRequest",
|
|
"response", response.Status, "redirect_to", oAuth2RedirectTo,
|
|
)
|
|
|
|
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.Error("error getting consent information", "error", err)
|
|
|
|
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.Debug(
|
|
"response for GetOAuth2ConsentRequest",
|
|
"response", response.Status, "consent_request", consentRequest,
|
|
)
|
|
|
|
var requestedClaims models.OIDCClaimsRequest
|
|
|
|
requestURLStr := consentRequest.GetRequestUrl()
|
|
|
|
requestURL, err := url.Parse(requestURLStr)
|
|
if err != nil {
|
|
h.logger.Warn(
|
|
"could not parse original request URL",
|
|
"error", err, "request_url", requestURLStr,
|
|
)
|
|
} else {
|
|
claimsParameter := requestURL.Query().Get("claims")
|
|
if claimsParameter != "" {
|
|
decoder := json.NewDecoder(strings.NewReader(claimsParameter))
|
|
|
|
err := decoder.Decode(&requestedClaims)
|
|
if err != nil {
|
|
h.logger.Warn(
|
|
"ignoring claims request parameter that could not be decoded",
|
|
"error", err, "claims_parameter", claimsParameter,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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.Warn("ignoring unsupported scope", "scope", scopeName)
|
|
|
|
continue
|
|
}
|
|
|
|
label, err := localizer.Localize(&i18n.LocalizeConfig{
|
|
DefaultMessage: supportedScopes[scopeName],
|
|
})
|
|
if err != nil {
|
|
h.logger.Warn("could not localize scope label", "error", err, "scope", scopeName)
|
|
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.Warn("ignoring unsupported claim", "claim", k)
|
|
|
|
continue
|
|
}
|
|
|
|
label, err := localizer.Localize(&i18n.LocalizeConfig{
|
|
DefaultMessage: supportedClaims[k],
|
|
})
|
|
if err != nil {
|
|
h.logger.Warn("could not localize claim label", "error", err, "claim", k)
|
|
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.Warn("handling for essential claim not implemented", "claim", claimName)
|
|
} else {
|
|
h.logger.Warn("handling for claim not implemented", "claim", claimName)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func NewConsentHandler(
|
|
logger *slog.Logger, templateCache TemplateCache, trans *services.I18NService, adminClient client.OAuth2API,
|
|
) *ConsentHandler {
|
|
return &ConsentHandler{
|
|
logger: logger,
|
|
trans: trans,
|
|
adminClient: adminClient,
|
|
templates: templateCache,
|
|
}
|
|
}
|