diff --git a/cmd/idp.go b/cmd/idp.go index 8e9d4a0..5319b08 100644 --- a/cmd/idp.go +++ b/cmd/idp.go @@ -1,18 +1,18 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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 main @@ -23,7 +23,6 @@ import ( "crypto/x509" "encoding/base64" "fmt" - "io/ioutil" "net/http" "net/url" "os" @@ -31,24 +30,33 @@ import ( "sync/atomic" "time" - "git.cacert.org/oidc_idp/ui" "github.com/go-openapi/runtime/client" "github.com/gorilla/csrf" "github.com/knadh/koanf" hydra "github.com/ory/hydra-client-go/client" log "github.com/sirupsen/logrus" + "git.cacert.org/oidc_idp/ui" + "git.cacert.org/oidc_idp/handlers" "git.cacert.org/oidc_idp/services" ) +const ( + TimeoutThirty = 30 * time.Second + TimeoutTwenty = 20 * time.Second + DefaultCSRFMaxAge = 600 + DefaultServerPort = 3000 +) + func main() { logger := log.New() + config, err := services.ConfigureApplication( logger, "IDP", map[string]interface{}{ - "server.port": 3000, + "server.port": DefaultServerPort, "server.name": "login.cacert.localhost", "server.key": "certs/idp.cacert.localhost.key", "server.certificate": "certs/idp.cacert.localhost.crt.pem", @@ -61,23 +69,28 @@ func main() { } logger.Infoln("Server is starting") - ctx := context.Background() + bundle, catalog := services.InitI18n(logger, config.Strings("i18n.languages")) - ctx = services.InitI18n(ctx, logger, config.Strings("i18n.languages")) - services.AddMessages(ctx) + if err = services.AddMessages(catalog); err != nil { + logger.Fatalf("could not add messages for i18n: %v", err) + } adminURL, err := url.Parse(config.MustString("admin.url")) if err != nil { logger.Fatalf("error parsing admin URL: %v", err) } + tlsClientConfig := &tls.Config{MinVersion: tls.VersionTLS12} + if config.Exists("api-client.rootCAs") { rootCAFile := config.MustString("api-client.rootCAs") caCertPool := x509.NewCertPool() - pemBytes, err := ioutil.ReadFile(rootCAFile) + + pemBytes, err := os.ReadFile(rootCAFile) if err != nil { log.Fatalf("could not read CA certificate file: %v", err) } + caCertPool.AppendCertsFromPEM(pemBytes) tlsClientConfig.RootCAs = caCertPool } @@ -92,16 +105,10 @@ func main() { ) adminClient := hydra.New(clientTransport, nil) - handlerContext := context.WithValue(ctx, handlers.CtxAdminClient, adminClient.Admin) - loginHandler, err := handlers.NewLoginHandler(handlerContext, logger) - if err != nil { - logger.Fatalf("error initializing login handler: %v", err) - } - consentHandler, err := handlers.NewConsentHandler(handlerContext, logger) - if err != nil { - logger.Fatalf("error initializing consent handler: %v", err) - } - logoutHandler := handlers.NewLogoutHandler(handlerContext, logger) + loginHandler := handlers.NewLoginHandler(logger, bundle, catalog, adminClient.Admin) + consentHandler := handlers.NewConsentHandler(logger, bundle, catalog, adminClient.Admin) + logoutHandler := handlers.NewLogoutHandler(logger, adminClient.Admin) + logoutSuccessHandler := handlers.NewLogoutSuccessHandler() errorHandler := handlers.NewErrorHandler() staticFiles := http.FileServer(http.FS(ui.Static)) @@ -126,20 +133,21 @@ func main() { logger.Fatalf("could not parse CSRF key bytes: %v", err) } - nextRequestId := func() string { + nextRequestID := func() string { return fmt.Sprintf("%d", time.Now().UnixNano()) } - tracing := handlers.Tracing(nextRequestId) + tracing := handlers.Tracing(nextRequestID) logging := handlers.Logging(logger) hsts := handlers.EnableHSTS() csrfProtect := csrf.Protect( csrfKey, csrf.Secure(true), csrf.SameSite(csrf.SameSiteStrictMode), - csrf.MaxAge(600)) + csrf.MaxAge(DefaultCSRFMaxAge)) + errorMiddleware, err := handlers.ErrorHandling( - ctx, + context.Background(), logger, ui.Templates, ) @@ -149,7 +157,7 @@ func main() { handlerChain := tracing(logging(hsts(errorMiddleware(csrfProtect(router))))) - startServer(ctx, handlerChain, logger, config) + startServer(context.Background(), handlerChain, logger, config) } func startServer(ctx context.Context, handlerChain http.Handler, logger *log.Logger, config *koanf.Koanf) { @@ -158,10 +166,12 @@ func startServer(ctx context.Context, handlerChain http.Handler, logger *log.Log serverPort := config.Int("server.port") clientCertPool := x509.NewCertPool() - pemBytes, err := ioutil.ReadFile(clientCertificateCAFile) + + pemBytes, err := os.ReadFile(clientCertificateCAFile) if err != nil { logger.Fatalf("could not load client CA certificates: %v", err) } + clientCertPool.AppendCertsFromPEM(pemBytes) tlsConfig := &tls.Config{ @@ -173,9 +183,9 @@ func startServer(ctx context.Context, handlerChain http.Handler, logger *log.Log server := &http.Server{ Addr: fmt.Sprintf("%s:%d", serverName, serverPort), Handler: handlerChain, - ReadTimeout: 20 * time.Second, - WriteTimeout: 20 * time.Second, - IdleTimeout: 30 * time.Second, + ReadTimeout: TimeoutTwenty, + WriteTimeout: TimeoutTwenty, + IdleTimeout: TimeoutThirty, TLSConfig: tlsConfig, } @@ -188,18 +198,21 @@ func startServer(ctx context.Context, handlerChain http.Handler, logger *log.Log logger.Infoln("Server is shutting down...") atomic.StoreInt32(&handlers.Healthy, 0) - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + ctx, cancel := context.WithTimeout(ctx, TimeoutThirty) defer cancel() server.SetKeepAlivesEnabled(false) + if err := server.Shutdown(ctx); err != nil { logger.Fatalf("Could not gracefully shutdown the server: %v\n", err) } + close(done) }() logger.Infof("Server is ready to handle requests at https://%s/", server.Addr) atomic.StoreInt32(&handlers.Healthy, 1) + if err := server.ListenAndServeTLS( config.String("server.certificate"), config.String("server.key"), ); err != nil && err != http.ErrServerClosed { diff --git a/handlers/common.go b/handlers/common.go deleted file mode 100644 index aae852e..0000000 --- a/handlers/common.go +++ /dev/null @@ -1,24 +0,0 @@ -/* - 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 - -type handlerContextKey int - -const ( - CtxAdminClient handlerContextKey = iota -) diff --git a/handlers/consent.go b/handlers/consent.go index 2182340..6c381ab 100644 --- a/handlers/consent.go +++ b/handlers/consent.go @@ -1,33 +1,32 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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" + "errors" + "fmt" "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" @@ -36,14 +35,16 @@ import ( "github.com/ory/hydra-client-go/models" log "github.com/sirupsen/logrus" + commonModels "git.cacert.org/oidc_idp/models" + "git.cacert.org/oidc_idp/ui" + "git.cacert.org/oidc_idp/services" ) -type consentHandler struct { - adminClient *admin.Client +type ConsentHandler struct { + adminClient admin.ClientService bundle *i18n.Bundle consentTemplate *template.Template - context context.Context logger *log.Logger messageCatalog *services.MessageCatalog } @@ -70,6 +71,8 @@ const ( ScopeEmail = "email" ) +const OneDayInSeconds = 86400 + func init() { supportedScopes = make(map[string]*i18n.Message) supportedScopes[ScopeOpenID] = &i18n.Message{ @@ -107,9 +110,11 @@ func (i *UserInfo) GetFullName() string { return i.CommonName } -func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +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) @@ -122,8 +127,12 @@ func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - h.renderConsentForm(w, r, consentData, requestedClaims, err, localizer) - break + if err := h.renderConsentForm(w, r, consentData, requestedClaims, localizer); err != nil { + h.logger.Error(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } case http.MethodPost: var consentInfo ConsentInformation @@ -131,11 +140,8 @@ func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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, - ) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } @@ -144,6 +150,7 @@ func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err != nil { h.logger.Errorf("could not get session data: %v", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } @@ -154,34 +161,38 @@ func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { GrantScope: consentInfo.GrantedScopes, HandledAt: models.NullTime(time.Now()), Remember: true, - RememberFor: 86400, + RememberFor: OneDayInSeconds, Session: sessionData, - }).WithTimeout(time.Second * 10)) + }).WithTimeout(TimeoutTen)) 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) + + return } + + 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) ( +func (h *ConsentHandler) getRequestedConsentInformation(challenge string, r *http.Request) ( *admin.GetConsentRequestOK, *commonModels.OIDCClaimsRequest, error, @@ -190,20 +201,26 @@ func (h *consentHandler) getRequestedConsentInformation(challenge string, r *htt 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)}, + + if errorBucket := GetErrorBucket(r); errorBucket != nil { + errorDetails := &ErrorDetails{ + ErrorMessage: "could not get consent details", + ErrorDetails: []string{http.StatusText(http.StatusInternalServerError)}, + } + + errorBucket.AddError(errorDetails) } - GetErrorBucket(r).AddError(errorDetails) - return nil, nil, err + + return nil, nil, fmt.Errorf("error getting consent information: %w", err) } + var requestedClaims commonModels.OIDCClaimsRequest - requestUrl, err := url.Parse(consentData.Payload.RequestURL) + + 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") + claimsParameter := requestURL.Query().Get("claims") if claimsParameter != "" { decoder := json.NewDecoder(strings.NewReader(claimsParameter)) err := decoder.Decode(&requestedClaims) @@ -216,27 +233,28 @@ func (h *consentHandler) getRequestedConsentInformation(challenge string, r *htt } } } + return consentData, &requestedClaims, nil } -func (h *consentHandler) renderConsentForm( +func (h *ConsentHandler) renderConsentForm( w http.ResponseWriter, r *http.Request, consentData *admin.GetConsentRequestOK, claims *commonModels.OIDCClaimsRequest, - err error, 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{}{ + err := h.consentTemplate.Lookup("base").Execute(w, map[string]interface{}{ "Title": trans("TitleRequestConsent"), csrf.TemplateTag: csrf.TemplateField(r), "errors": map[string]string{}, @@ -245,15 +263,24 @@ func (h *consentHandler) renderConsentForm( "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, - })), + "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 { @@ -261,13 +288,19 @@ type scopeWithLabel struct { Label string } -func (h *consentHandler) mapRequestedScope(scope models.StringSlicePipeDelimiter, localizer *i18n.Localizer) []*scopeWithLabel { +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], }) @@ -275,8 +308,10 @@ func (h *consentHandler) mapRequestedScope(scope models.StringSlicePipeDelimiter 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 } @@ -286,7 +321,10 @@ type claimWithLabel struct { Essential bool } -func (h *consentHandler) mapRequestedClaims(claims *commonModels.OIDCClaimsRequest, localizer *i18n.Localizer) []*claimWithLabel { +func (h *ConsentHandler) mapRequestedClaims( + claims *commonModels.OIDCClaimsRequest, + localizer *i18n.Localizer, +) []*claimWithLabel { result := make([]*claimWithLabel, 0) known := make(map[string]bool) @@ -295,8 +333,10 @@ func (h *consentHandler) mapRequestedClaims(claims *commonModels.OIDCClaimsReque 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], }) @@ -304,6 +344,7 @@ func (h *consentHandler) mapRequestedClaims(claims *commonModels.OIDCClaimsReque h.logger.Warnf("could not localize label for claim %s: %v", k, err) label = k } + if !known[k] { result = append(result, &claimWithLabel{ Name: k, @@ -315,10 +356,11 @@ func (h *consentHandler) mapRequestedClaims(claims *commonModels.OIDCClaimsReque } } } + return result } -func (h *consentHandler) getSessionData( +func (h *ConsentHandler) getSessionData( r *http.Request, info ConsentInformation, claims *commonModels.OIDCClaimsRequest, @@ -329,32 +371,42 @@ func (h *consentHandler) getSessionData( userInfo := h.GetUserInfoFromClientCertificate(r, payload.Subject) - h.fillTokenData(accessTokenData, payload.RequestedScope, claims, info, userInfo) - h.fillTokenData(idTokenData, payload.RequestedScope, claims, info, userInfo) + 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( +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 @@ -362,7 +414,6 @@ func (h *consentHandler) fillTokenData( // 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 @@ -371,53 +422,88 @@ func (h *consentHandler) fillTokenData( // 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, - ) - } + err := h.parseUserInfoClaims(m, userInfoClaims, consentInformation) + if err != nil { + return err } } + + return nil } -func (h *consentHandler) GetUserInfoFromClientCertificate(r *http.Request, subject string) *UserInfo { +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.Warnf( + "handling for essential claim name %s not implemented", + claimName, + ) + } else { + h.logger.Warnf( + "handling for claim name %s not implemented", + claimName, + ) + } + } + + 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.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) + h.logger.Warnf( + "authentication attempt with a wrong certificate that did not contain the requested address %s", + subject, + ) + return nil } @@ -427,10 +513,16 @@ func (h *consentHandler) GetUserInfoFromClientCertificate(r *http.Request, subje CommonName: firstCert.Subject.CommonName, } } + return nil } -func NewConsentHandler(ctx context.Context, logger *log.Logger) (*consentHandler, error) { +func NewConsentHandler( + logger *log.Logger, + bundle *i18n.Bundle, + messageCatalog *services.MessageCatalog, + adminClient admin.ClientService, +) *ConsentHandler { consentTemplate := template.Must( template.ParseFS( ui.Templates, @@ -438,12 +530,11 @@ func NewConsentHandler(ctx context.Context, logger *log.Logger) (*consentHandler "templates/consent.gohtml", )) - return &consentHandler{ - adminClient: ctx.Value(CtxAdminClient).(*admin.Client), - bundle: services.GetI18nBundle(ctx), + return &ConsentHandler{ + adminClient: adminClient, + bundle: bundle, consentTemplate: consentTemplate, - context: ctx, logger: logger, - messageCatalog: services.GetMessageCatalog(ctx), - }, nil + messageCatalog: messageCatalog, + } } diff --git a/handlers/doc.go b/handlers/doc.go new file mode 100644 index 0000000..0003bca --- /dev/null +++ b/handlers/doc.go @@ -0,0 +1,19 @@ +/* +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 + + 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 provides request handlers. +package handlers diff --git a/handlers/error.go b/handlers/error.go index 49a1243..1d003e0 100644 --- a/handlers/error.go +++ b/handlers/error.go @@ -1,18 +1,18 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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 @@ -24,9 +24,10 @@ import ( "io/fs" "net/http" - "git.cacert.org/oidc_idp/services" "github.com/nicksnyder/go-i18n/v2/i18n" log "github.com/sirupsen/logrus" + + "git.cacert.org/oidc_idp/services" ) type errorKey int @@ -62,6 +63,7 @@ func (b *ErrorBucket) serveHTTP(w http.ResponseWriter, r *http.Request) { ), "details": b.errorDetails, }) + if err != nil { log.Errorf("error rendering error template: %v", err) http.Error( @@ -74,92 +76,110 @@ func (b *ErrorBucket) serveHTTP(w http.ResponseWriter, r *http.Request) { } func GetErrorBucket(r *http.Request) *ErrorBucket { - return r.Context().Value(errorBucketKey).(*ErrorBucket) + if bucket, ok := r.Context().Value(errorBucketKey).(*ErrorBucket); ok { + return bucket + } + + return nil } -// call this from your application's handler +// AddError can be called to add error details from your application's handler. func (b *ErrorBucket) AddError(details *ErrorDetails) { b.errorDetails = details } type errorResponseWriter struct { http.ResponseWriter - ctx context.Context - statusCode int + errorBucket *ErrorBucket + statusCode int } func (w *errorResponseWriter) WriteHeader(code int) { w.statusCode = code - if code >= 400 { + + if code >= http.StatusBadRequest { w.Header().Set("Content-Type", "text/html; charset=utf-8") - errorBucket := w.ctx.Value(errorBucketKey).(*ErrorBucket) - if errorBucket != nil && errorBucket.errorDetails == nil { - errorBucket.AddError(&ErrorDetails{ - ErrorMessage: http.StatusText(code), - }) - } + + w.errorBucket.AddError(&ErrorDetails{ + ErrorMessage: http.StatusText(code), + }) } + w.ResponseWriter.WriteHeader(code) } func (w *errorResponseWriter) Write(content []byte) (int, error) { - if w.statusCode > 400 { - errorBucket := w.ctx.Value(errorBucketKey).(*ErrorBucket) - if errorBucket != nil { - if errorBucket.errorDetails.ErrorDetails == nil { - errorBucket.errorDetails.ErrorDetails = make([]string, 0) - } - errorBucket.errorDetails.ErrorDetails = append( - errorBucket.errorDetails.ErrorDetails, string(content), - ) - return len(content), nil + if w.statusCode >= http.StatusBadRequest { + if w.errorBucket.errorDetails.ErrorDetails == nil { + w.errorBucket.errorDetails.ErrorDetails = make([]string, 0) } + + w.errorBucket.errorDetails.ErrorDetails = append( + w.errorBucket.errorDetails.ErrorDetails, string(content), + ) + + return len(content), nil } - return w.ResponseWriter.Write(content) + + code, err := w.ResponseWriter.Write(content) + if err != nil { + return code, fmt.Errorf("error writing response: %w", err) + } + + return code, nil } -func ErrorHandling(handlerContext context.Context, logger *log.Logger, templateFS fs.FS) (func(http.Handler) http.Handler, error) { +func ErrorHandling( + handlerContext context.Context, + logger *log.Logger, + templateFS fs.FS, +) (func(http.Handler) http.Handler, error) { errorTemplates, err := template.ParseFS( templateFS, "templates/base.gohtml", "templates/errors.gohtml", ) if err != nil { - return nil, err + return nil, fmt.Errorf("could not parse templates: %w", err) } + + bundle, err := services.GetI18nBundle(handlerContext) + if err != nil { + return nil, fmt.Errorf("could not get i18n bundle: %w", err) + } + + messageCatalog, err := services.GetMessageCatalog(handlerContext) + if err != nil { + return nil, fmt.Errorf("could not get message catalog: %w", err) + } + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { errorBucket := &ErrorBucket{ templates: errorTemplates, logger: logger, - bundle: services.GetI18nBundle(handlerContext), - messageCatalog: services.GetMessageCatalog(handlerContext), - } - ctx := context.WithValue(r.Context(), errorBucketKey, errorBucket) - interCeptingResponseWriter := &errorResponseWriter{ - w, - ctx, - http.StatusOK, + bundle: bundle, + messageCatalog: messageCatalog, } next.ServeHTTP( - interCeptingResponseWriter, - r.WithContext(ctx), + &errorResponseWriter{w, errorBucket, http.StatusOK}, + r.WithContext(context.WithValue(r.Context(), errorBucketKey, errorBucket)), ) errorBucket.serveHTTP(w, r) }) }, nil } -type errorHandler struct { +type ErrorHandler struct { } -func (e *errorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (e *ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = fmt.Fprintf(w, ` didumm %#v `, r.URL.Query()) } -func NewErrorHandler() *errorHandler { - return &errorHandler{} +func NewErrorHandler() *ErrorHandler { + return &ErrorHandler{} } diff --git a/handlers/login.go b/handlers/login.go index 0beb221..f6c2909 100644 --- a/handlers/login.go +++ b/handlers/login.go @@ -1,25 +1,25 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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 ( "bytes" - "context" + "crypto/x509" "errors" "fmt" "html/template" @@ -27,13 +27,14 @@ import ( "strconv" "time" - "git.cacert.org/oidc_idp/ui" "github.com/gorilla/csrf" "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/ui" + "git.cacert.org/oidc_idp/services" ) @@ -49,22 +50,30 @@ type templateName string const ( CertificateLogin templateName = "cert" - NoEmailsInClientCertificate = "no_emails" + NoEmailsInClientCertificate templateName = "no_emails" ) -type loginHandler struct { - adminClient *admin.Client +const TimeoutTen = 10 * time.Second + +type LoginHandler struct { + adminClient admin.ClientService bundle *i18n.Bundle - context context.Context logger *log.Logger templates map[templateName]*template.Template messageCatalog *services.MessageCatalog } -func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - var err error +func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + + return + } + challenge := r.URL.Query().Get("login_challenge") + h.logger.Debugf("received login challenge %s\n", challenge) + accept := r.Header.Get("Accept-Language") localizer := i18n.NewLocalizer(h.bundle, accept) @@ -72,110 +81,173 @@ func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if certEmails == nil { h.renderNoEmailsInClientCertificate(w, localizer) + return } - switch r.Method { - case http.MethodGet: - loginRequest, err := h.adminClient.GetLoginRequest(admin.NewGetLoginRequestParams().WithLoginChallenge(challenge)) - if err != nil { - h.logger.Warnf("could not get login request for challenge %s: %v", challenge, err) - - var e *admin.GetLoginRequestGone - if errors.As(err, &e) { - w.Header().Set("Location", *e.GetPayload().RedirectTo) - w.WriteHeader(http.StatusGone) - return - } - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - h.renderRequestForClientCert(w, r, certEmails, localizer, loginRequest) - break - case http.MethodPost: - if r.FormValue("use-identity") != "accept" { - h.rejectLogin(w, challenge, localizer) - return - } - - var userId string - // perform certificate auth - h.logger.Infof("would perform certificate authentication with: %+v", certEmails) - userId, err = h.performCertificateLogin(certEmails, r) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - // finish login and redirect to target - loginRequest, err := h.adminClient.AcceptLoginRequest( - admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody( - &models.AcceptLoginRequest{ - Acr: string(ClientCertificate), - Remember: true, - RememberFor: 0, - Subject: &userId, - }).WithTimeout(time.Second * 10)) - if err != nil { - h.logger.Errorf("error getting login request: %#v", err) - var errorDetails *ErrorDetails - switch v := err.(type) { - case *admin.AcceptLoginRequestNotFound: - payload := v.GetPayload() - errorDetails = &ErrorDetails{ - ErrorMessage: payload.Error, - ErrorDetails: []string{payload.ErrorDescription}, - } - if v.Payload.StatusCode != 0 { - errorDetails.ErrorCode = strconv.Itoa(int(payload.StatusCode)) - } - break - default: - errorDetails = &ErrorDetails{ - ErrorMessage: "could not accept login", - ErrorDetails: []string{err.Error()}, - } - } - GetErrorBucket(r).AddError(errorDetails) - return - } - w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo) - w.WriteHeader(http.StatusFound) - default: - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return + if r.Method == http.MethodGet { + h.handleGet(w, r, challenge, certEmails, localizer) + } else { + h.handlePost(w, r, challenge, certEmails, localizer) } } -func (h *loginHandler) rejectLogin(w http.ResponseWriter, challenge string, localizer *i18n.Localizer) { - rejectLoginRequest, err := h.adminClient.RejectLoginRequest(admin.NewRejectLoginRequestParams().WithLoginChallenge(challenge).WithBody( - &models.RejectRequest{ - ErrorDescription: h.messageCatalog.LookupMessage("LoginDeniedByUser", nil, localizer), - ErrorHint: h.messageCatalog.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer), - StatusCode: http.StatusForbidden, - }, - ).WithTimeout(time.Second * 10)) +func (h *LoginHandler) handleGet( + w http.ResponseWriter, + r *http.Request, + challenge string, + certEmails []string, + localizer *i18n.Localizer, +) { + loginRequest, err := h.adminClient.GetLoginRequest(admin.NewGetLoginRequestParams().WithLoginChallenge(challenge)) + if err != nil { + h.logger.Warnf("could not get login request for challenge %s: %v", challenge, err) + + var e *admin.GetLoginRequestGone + if errors.As(err, &e) { + w.Header().Set("Location", *e.GetPayload().RedirectTo) + w.WriteHeader(http.StatusGone) + + return + } + + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + h.renderRequestForClientCert(w, r, certEmails, localizer, loginRequest) +} + +func (h *LoginHandler) handlePost( + w http.ResponseWriter, + r *http.Request, + challenge string, + certEmails []string, + localizer *i18n.Localizer, +) { + if r.FormValue("use-identity") != "accept" { + h.rejectLogin(w, challenge, localizer) + + return + } + + // perform certificate auth + h.logger.Infof("would perform certificate authentication with: %+v", certEmails) + + userID, err := h.performCertificateLogin(certEmails, r) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + // finish login and redirect to target + loginRequest, err := h.adminClient.AcceptLoginRequest( + admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody( + &models.AcceptLoginRequest{ + Acr: string(ClientCertificate), + Remember: true, + RememberFor: 0, + Subject: &userID, + }).WithTimeout(TimeoutTen)) + if err != nil { + h.logger.Errorf("error getting login request: %#v", err) + + h.fillAcceptLoginRequestErrorBucket(r, err) + + return + } + + w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo) + w.WriteHeader(http.StatusFound) +} + +func (h *LoginHandler) fillAcceptLoginRequestErrorBucket(r *http.Request, err error) { + if errorBucket := GetErrorBucket(r); errorBucket != nil { + var ( + errorDetails *ErrorDetails + acceptLoginRequestNotFound *admin.AcceptLoginRequestNotFound + ) + + if errors.As(err, &acceptLoginRequestNotFound) { + payload := acceptLoginRequestNotFound.GetPayload() + errorDetails = &ErrorDetails{ + ErrorMessage: payload.Error, + ErrorDetails: []string{payload.ErrorDescription}, + } + + if acceptLoginRequestNotFound.Payload.StatusCode != 0 { + errorDetails.ErrorCode = strconv.Itoa(int(payload.StatusCode)) + } + } else { + errorDetails = &ErrorDetails{ + ErrorMessage: "could not accept login", + ErrorDetails: []string{err.Error()}, + } + } + + errorBucket.AddError(errorDetails) + } +} + +func (h *LoginHandler) rejectLogin(w http.ResponseWriter, challenge string, localizer *i18n.Localizer) { + const Ten = 10 * time.Second + + rejectLoginRequest, err := h.adminClient.RejectLoginRequest( + admin.NewRejectLoginRequestParams().WithLoginChallenge(challenge).WithBody( + &models.RejectRequest{ + ErrorDescription: h.messageCatalog.LookupMessage("LoginDeniedByUser", nil, localizer), + ErrorHint: h.messageCatalog.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer), + StatusCode: http.StatusForbidden, + }, + ).WithTimeout(Ten)) if err != nil { h.logger.Errorf("error getting reject login request: %#v", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } + w.Header().Set("Location", *rejectLoginRequest.GetPayload().RedirectTo) w.WriteHeader(http.StatusFound) } -func (h *loginHandler) getEmailAddressesFromClientCertificate(r *http.Request) []string { +func (h *LoginHandler) getEmailAddressesFromClientCertificate(r *http.Request) []string { if r.TLS != nil && r.TLS.PeerCertificates != nil && len(r.TLS.PeerCertificates) > 0 { firstCert := r.TLS.PeerCertificates[0] + + if !isClientCertificate(firstCert) { + return nil + } + for _, email := range firstCert.EmailAddresses { h.logger.Infof("authenticated with a client certificate for email address %s", email) } + return firstCert.EmailAddresses } + return nil } -func (h *loginHandler) renderRequestForClientCert(w http.ResponseWriter, r *http.Request, emails []string, localizer *i18n.Localizer, loginRequest *admin.GetLoginRequestOK) { +func isClientCertificate(cert *x509.Certificate) bool { + for _, ext := range cert.ExtKeyUsage { + if ext == x509.ExtKeyUsageClientAuth { + return true + } + } + + return false +} + +func (h *LoginHandler) renderRequestForClientCert( + w http.ResponseWriter, + r *http.Request, + emails []string, + localizer *i18n.Localizer, + loginRequest *admin.GetLoginRequestOK, +) { trans := func(label string) string { return h.messageCatalog.LookupMessage(label, nil, localizer) } @@ -184,7 +256,7 @@ func (h *loginHandler) renderRequestForClientCert(w http.ResponseWriter, r *http err := h.templates[CertificateLogin].Lookup("base").Execute(rendered, map[string]interface{}{ "Title": trans("LoginTitle"), csrf.TemplateTag: csrf.TemplateField(r), - "IntroText": template.HTML(h.messageCatalog.LookupMessage( + "IntroText": template.HTML(h.messageCatalog.LookupMessage( //nolint:gosec "CertLoginIntroText", map[string]interface{}{"ClientName": loginRequest.GetPayload().Client.ClientName}, localizer, @@ -195,27 +267,31 @@ func (h *loginHandler) renderRequestForClientCert(w http.ResponseWriter, r *http "AcceptLabel": trans("LabelAcceptCertLogin"), "RejectLabel": trans("LabelRejectCertLogin"), }) + if err != nil { h.logger.Error(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } + w.Header().Add("Pragma", "no-cache") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") _, _ = w.Write(rendered.Bytes()) } -func (h *loginHandler) performCertificateLogin(emails []string, r *http.Request) (string, error) { +func (h *LoginHandler) performCertificateLogin(emails []string, r *http.Request) (string, error) { requestedEmail := r.PostFormValue("email") for _, email := range emails { if email == requestedEmail { return email, nil } } + return "", fmt.Errorf("no user found") } -func (h *loginHandler) renderNoEmailsInClientCertificate(w http.ResponseWriter, localizer *i18n.Localizer) { +func (h *LoginHandler) renderNoEmailsInClientCertificate(w http.ResponseWriter, localizer *i18n.Localizer) { trans := func(label string) string { return h.messageCatalog.LookupMessage(label, nil, localizer) } @@ -227,15 +303,20 @@ func (h *loginHandler) renderNoEmailsInClientCertificate(w http.ResponseWriter, if err != nil { h.logger.Error(err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } } -func NewLoginHandler(ctx context.Context, logger *log.Logger) (*loginHandler, error) { - return &loginHandler{ - adminClient: ctx.Value(CtxAdminClient).(*admin.Client), - bundle: services.GetI18nBundle(ctx), - context: ctx, +func NewLoginHandler( + logger *log.Logger, + bundle *i18n.Bundle, + messageCatalog *services.MessageCatalog, + adminClient admin.ClientService, +) *LoginHandler { + return &LoginHandler{ + adminClient: adminClient, + bundle: bundle, logger: logger, templates: map[templateName]*template.Template{ CertificateLogin: template.Must(template.ParseFS( @@ -249,6 +330,6 @@ func NewLoginHandler(ctx context.Context, logger *log.Logger) (*loginHandler, er "templates/no_email_in_client_certificate.gohtml", )), }, - messageCatalog: services.GetMessageCatalog(ctx), - }, nil + messageCatalog: messageCatalog, + } } diff --git a/handlers/logout.go b/handlers/logout.go index fe092d6..e3fd7ae 100644 --- a/handlers/logout.go +++ b/handlers/logout.go @@ -1,24 +1,23 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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" "net/http" "time" @@ -26,20 +25,23 @@ import ( log "github.com/sirupsen/logrus" ) -type logoutHandler struct { - adminClient *admin.Client +type LogoutHandler struct { + adminClient admin.ClientService logger *log.Logger } -func (h *logoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + const Ten = 10 * time.Second + challenge := r.URL.Query().Get("logout_challenge") h.logger.Debugf("received challenge %s\n", challenge) logoutRequest, err := h.adminClient.GetLogoutRequest( - admin.NewGetLogoutRequestParams().WithLogoutChallenge(challenge).WithTimeout(time.Second * 10)) + admin.NewGetLogoutRequestParams().WithLogoutChallenge(challenge).WithTimeout(Ten)) if err != nil { h.logger.Errorf("error getting logout requests: %v", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return } @@ -56,20 +58,20 @@ func (h *logoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusFound) } -func NewLogoutHandler(ctx context.Context, logger *log.Logger) *logoutHandler { - return &logoutHandler{ +func NewLogoutHandler(logger *log.Logger, adminClient admin.ClientService) *LogoutHandler { + return &LogoutHandler{ logger: logger, - adminClient: ctx.Value(CtxAdminClient).(*admin.Client), + adminClient: adminClient, } } -type logoutSuccessHandler struct { +type LogoutSuccessHandler struct { } -func (l *logoutSuccessHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (l *LogoutSuccessHandler) ServeHTTP(_ http.ResponseWriter, _ *http.Request) { panic("implement me") } -func NewLogoutSuccessHandler() *logoutSuccessHandler { - return &logoutSuccessHandler{} +func NewLogoutSuccessHandler() *LogoutSuccessHandler { + return &LogoutSuccessHandler{} } diff --git a/handlers/observability.go b/handlers/observability.go index e5e6a9f..4bb1bf7 100644 --- a/handlers/observability.go +++ b/handlers/observability.go @@ -1,24 +1,25 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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" + "fmt" "net/http" "sync/atomic" @@ -28,7 +29,7 @@ import ( type key int const ( - requestIdKey key = iota + requestIDKey key = iota ) type statusCodeInterceptor struct { @@ -45,7 +46,12 @@ func (sci *statusCodeInterceptor) WriteHeader(code int) { func (sci *statusCodeInterceptor) Write(content []byte) (int, error) { count, err := sci.ResponseWriter.Write(content) sci.count += count - return count, err + + if err != nil { + return count, fmt.Errorf("could not write response: %w", err) + } + + return count, nil } func Logging(logger *log.Logger) func(http.Handler) http.Handler { @@ -53,13 +59,13 @@ func Logging(logger *log.Logger) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { interceptor := &statusCodeInterceptor{w, http.StatusOK, 0} defer func() { - requestId, ok := r.Context().Value(requestIdKey).(string) + requestID, ok := r.Context().Value(requestIDKey).(string) if !ok { - requestId = "unknown" + requestID = "unknown" } logger.Infof( "%s %s \"%s %s\" %d %d \"%s\"", - requestId, + requestID, r.RemoteAddr, r.Method, r.URL.Path, @@ -73,15 +79,15 @@ func Logging(logger *log.Logger) func(http.Handler) http.Handler { } } -func Tracing(nextRequestId func() string) func(http.Handler) http.Handler { +func Tracing(nextRequestID func() string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestId := r.Header.Get("X-Request-Id") - if requestId == "" { - requestId = nextRequestId() + requestID := r.Header.Get("X-Request-Id") + if requestID == "" { + requestID = nextRequestID() } - ctx := context.WithValue(r.Context(), requestIdKey, requestId) - w.Header().Set("X-Request-Id", requestId) + ctx := context.WithValue(r.Context(), requestIDKey, requestID) + w.Header().Set("X-Request-Id", requestID) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -93,8 +99,10 @@ func NewHealthHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if atomic.LoadInt32(&Healthy) == 1 { w.WriteHeader(http.StatusNoContent) + return } + w.WriteHeader(http.StatusServiceUnavailable) }) } diff --git a/handlers/security.go b/handlers/security.go index c0c99a2..afd9d62 100644 --- a/handlers/security.go +++ b/handlers/security.go @@ -1,18 +1,18 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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 @@ -26,7 +26,8 @@ import ( func EnableHSTS() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Strict-Transport-Security", fmt.Sprintf("max-age=%d", int((time.Hour*24*180).Seconds()))) + const Days180 = 180 + w.Header().Set("Strict-Transport-Security", fmt.Sprintf("max-age=%d", int((time.Hour*24*Days180).Seconds()))) next.ServeHTTP(w, r) }) } diff --git a/models/oidc.go b/models/oidc.go index 750ad28..02ee4e5 100644 --- a/models/oidc.go +++ b/models/oidc.go @@ -1,45 +1,47 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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. */ -/* -This package contains data models. -*/ +// Package models contains data models package models -// An individual claim request. +import "errors" + +var ErrNoValue = errors.New("value not found") + +// IndividualClaimsRequest represents an individual claim request. // -// Specification +// # Specification // // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests -type IndividualClaimRequest map[string]interface{} +type IndividualClaimsRequest map[string]interface{} // ClaimElement represents a claim element -type ClaimElement map[string]*IndividualClaimRequest +type ClaimElement map[string]*IndividualClaimsRequest // OIDCClaimsRequest the claims request parameter sent with the authorization request. // -// Specification +// # Specification // // https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter type OIDCClaimsRequest map[string]ClaimElement // GetUserInfo extracts the userinfo claim element from the request. // -// Specification +// # Specification // // https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims // @@ -56,12 +58,13 @@ func (r OIDCClaimsRequest) GetUserInfo() *ClaimElement { if userInfo, ok := r["userinfo"]; ok { return &userInfo } + return nil } // GetIDToken extracts the id_token claim element from the request. // -// Specification +// # Specification // // https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims // @@ -75,12 +78,13 @@ func (r OIDCClaimsRequest) GetIDToken() *ClaimElement { if idToken, ok := r["id_token"]; ok { return &idToken } + return nil } -// Checks whether the individual claim is an essential claim. +// IsEssential checks whether the individual claim is an essential claim. // -// Specification +// # Specification // // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests // @@ -88,7 +92,7 @@ func (r OIDCClaimsRequest) GetIDToken() *ClaimElement { // value is true, this indicates that the Claim is an Essential Claim. For // instance, the Claim request: // -// "auth_time": {"essential": true} +// "auth_time": {"essential": true} // // can be used to specify that it is Essential to return an auth_time Claim // Value. If the value is false, it indicates that it is a Voluntary Claim. @@ -99,54 +103,59 @@ func (r OIDCClaimsRequest) GetIDToken() *ClaimElement { // specific task requested by the End-User. // // Note that even if the Claims are not available because the End-User did not -// authorize their release or they are not present, the Authorization Server +// authorize their release, or they are not present, the Authorization Server // MUST NOT generate an error when Claims are not returned, whether they are // Essential or Voluntary, unless otherwise specified in the description of // the specific claim. -func (i IndividualClaimRequest) IsEssential() bool { +func (i IndividualClaimsRequest) IsEssential() bool { if essential, ok := i["essential"]; ok { - return essential.(bool) + if e, ok := essential.(bool); ok { + return e + } } + return false } -// Returns the wanted value for an individual claim request. +// WantedValue returns the wanted value for an individual claim request. // -// Specification +// # Specification // // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests // // Requests that the Claim be returned with a particular value. For instance // the Claim request: // -// "sub": {"value": "248289761001"} +// "sub": {"value": "248289761001"} // // can be used to specify that the request apply to the End-User with Subject // Identifier 248289761001. The value of the value member MUST be a valid // value for the Claim being requested. Definitions of individual Claims can // include requirements on how and whether the value qualifier is to be used // when requesting that Claim. -func (i IndividualClaimRequest) WantedValue() *string { +func (i IndividualClaimsRequest) WantedValue() (string, error) { if value, ok := i["value"]; ok { - valueString := value.(string) - return &valueString + if valueString, ok := value.(string); ok { + return valueString, nil + } } - return nil + + return "", ErrNoValue } -// Get the allowed values for an individual claim request that specifies +// AllowedValues gets the allowed values for an individual claim request that specifies // a values field. // -// Specification +// # Specification // // https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests // // Requests that the Claim be returned with one of a set of values, with the // values appearing in order of preference. For instance the Claim request: // -// "acr": {"essential": true, -// "values": ["urn:mace:incommon:iap:silver", -// "urn:mace:incommon:iap:bronze"]} +// "acr": {"essential": true, +// "values": ["urn:mace:incommon:iap:silver", +// "urn:mace:incommon:iap:bronze"]} // // specifies that it is Essential that the acr Claim be returned with either // the value urn:mace:incommon:iap:silver or urn:mace:incommon:iap:bronze. @@ -154,17 +163,20 @@ func (i IndividualClaimRequest) WantedValue() *string { // being requested. Definitions of individual Claims can include requirements // on how and whether the values qualifier is to be used when requesting that // Claim. -func (i IndividualClaimRequest) AllowedValues() []string { +func (i IndividualClaimsRequest) AllowedValues() []string { if values, ok := i["values"]; ok { - return values.([]string) + if v, ok := values.([]string); ok { + return v + } } + return nil } // OpenIDConfiguration contains the parts of the OpenID discovery information // that are relevant for us. // -// Specifications +// # Specifications // // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata // @@ -174,7 +186,7 @@ type OpenIDConfiguration struct { AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` UserInfoEndpoint string `json:"userinfo_endpoint"` - JwksUri string `json:"jwks_uri"` + JwksURI string `json:"jwks_uri"` RegistrationEndpoint string `json:"registration_endpoint"` ScopesSupported []string `json:"scopes_supported"` EndSessionEndpoint string `json:"end_session_endpoint"` diff --git a/services/configuration.go b/services/configuration.go index e61e555..5d63b67 100644 --- a/services/configuration.go +++ b/services/configuration.go @@ -1,18 +1,18 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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 services @@ -39,44 +39,48 @@ func ConfigureApplication( ) (*koanf.Koanf, error) { f := pflag.NewFlagSet("config", pflag.ContinueOnError) f.Usage = func() { - fmt.Println(f.FlagUsages()) + fmt.Println(f.FlagUsages()) //nolint:forbidigo os.Exit(0) } + f.StringSlice( "conf", []string{fmt.Sprintf("%s.toml", strings.ToLower(appName))}, "path to one or more .toml files", ) - var err error - if err = f.Parse(os.Args[1:]); err != nil { + if err := f.Parse(os.Args[1:]); err != nil { logger.Fatal(err) } config := koanf.New(".") _ = config.Load(confmap.Provider(defaultConfig, "."), nil) + cFiles, _ := f.GetStringSlice("conf") for _, c := range cFiles { if err := config.Load(file.Provider(c), toml.Parser()); err != nil { logger.Fatalf("error loading config file: %s", err) } } + if err := config.Load(posflag.Provider(f, ".", config), nil); err != nil { logger.Fatalf("error loading configuration: %s", err) } + if err := config.Load( file.Provider("resource_app.toml"), toml.Parser(), ); err != nil && !os.IsNotExist(err) { - logrus.Fatalf("error loading config: %v", err) + logger.Fatalf("error loading config: %v", err) } + prefix := fmt.Sprintf("%s_", strings.ToUpper(appName)) if err := config.Load(env.Provider(prefix, ".", func(s string) string { - return strings.Replace(strings.ToLower( - strings.TrimPrefix(s, prefix)), "_", ".", -1) + return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(s, prefix)), "_", ".") }), nil); err != nil { - logrus.Fatalf("error loading config: %v", err) + logger.Fatalf("error loading config: %v", err) } - return config, err + + return config, nil } diff --git a/services/i18n.go b/services/i18n.go index a551d00..2e88470 100644 --- a/services/i18n.go +++ b/services/i18n.go @@ -1,24 +1,25 @@ /* - Copyright 2020, 2021 Jan Dittberner +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 - 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 - 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. +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 services import ( "context" + "errors" "fmt" log "github.com/sirupsen/logrus" @@ -28,7 +29,7 @@ import ( "golang.org/x/text/language" ) -func AddMessages(ctx context.Context) { +func AddMessages(catalog *MessageCatalog) error { messages := make(map[string]*i18n.Message) messages["unknown"] = &i18n.Message{ ID: "ErrorUnknown", @@ -47,12 +48,14 @@ func AddMessages(ctx context.Context) { Other: "I hereby agree that the application may get the requested permissions.", } messages["IntroConsentRequested"] = &i18n.Message{ - ID: "IntroConsentRequested", - Other: "The {{ .client }} application requested your consent for the following set of permissions:", + ID: "IntroConsentRequested", + Other: "The {{ .client }} application requested your consent for the following set of " + + "permissions:", } messages["IntroConsentMoreInformation"] = &i18n.Message{ - ID: "IntroConsentMoreInformation", - Other: "You can find more information about {{ .client }} at its description page.", + ID: "IntroConsentMoreInformation", + Other: "You can find more information about {{ .client }} at " + + "its description page.", } messages["ClaimsInformation"] = &i18n.Message{ ID: "ClaimsInformation", @@ -67,12 +70,13 @@ func AddMessages(ctx context.Context) { Other: "The application {{ .ClientName }} requests a login.", } messages["EmailChoiceText"] = &i18n.Message{ - ID: "EmailChoiceText", - One: "You have presented a valid client certificate for the following email address:", - Other: "You have presented a valid client certificate for multiple email addresses. Please choose which one you want to present to the application:", + ID: "EmailChoiceText", + One: "You have presented a valid client certificate for the following email address:", + Other: "You have presented a valid client certificate for multiple email addresses. " + + "Please choose which one you want to present to the application:", } messages["LoginTitle"] = &i18n.Message{ - ID: "LoginTitle", + ID: "LoginTitle", Other: "Authenticate with a client certificate", } messages["CertLoginRequestText"] = &i18n.Message{ @@ -97,7 +101,10 @@ func AddMessages(ctx context.Context) { ID: "HintChooseAnIdentityForAuthentication", Other: "Choose an identity for authentication.", } - GetMessageCatalog(ctx).AddMessages(messages) + + catalog.AddMessages(messages) + + return nil } type contextKey int @@ -118,17 +125,21 @@ func (m *MessageCatalog) AddMessages(messages map[string]*i18n.Message) { } } -func (m *MessageCatalog) LookupErrorMessage(tag string, field string, value interface{}, localizer *i18n.Localizer) string { +func (m *MessageCatalog) LookupErrorMessage(tag, field string, value interface{}, localizer *i18n.Localizer) string { var message *i18n.Message + message, ok := m.messages[fmt.Sprintf("%s-%s", field, tag)] if !ok { m.logger.Infof("no specific error message %s-%s", field, tag) + message, ok = m.messages[tag] if !ok { m.logger.Infof("no specific error message %s", tag) + message, ok = m.messages["unknown"] if !ok { m.logger.Warnf("no default translation found") + return tag } } @@ -142,38 +153,41 @@ func (m *MessageCatalog) LookupErrorMessage(tag string, field string, value inte }) if err != nil { m.logger.Error(err) + return tag } + return translation } -func (m *MessageCatalog) LookupMessage(id string, templateData map[string]interface{}, localizer *i18n.Localizer) string { +func (m *MessageCatalog) LookupMessage( + id string, + templateData map[string]interface{}, + localizer *i18n.Localizer, +) string { if message, ok := m.messages[id]; ok { translation, err := localizer.Localize(&i18n.LocalizeConfig{ DefaultMessage: message, TemplateData: templateData, }) if err != nil { - switch err.(type) { - case *i18n.MessageNotFoundErr: - m.logger.Warnf("message %s not found: %v", id, err) - if translation != "" { - return translation - } - break - default: - m.logger.Error(err) - } - return id + return m.handleLocalizeError(id, translation, err) } + return translation - } else { - m.logger.Warnf("no translation found for %s", id) - return id } + + m.logger.Warnf("no translation found for %s", id) + + return id } -func (m *MessageCatalog) LookupMessagePlural(id string, templateData map[string]interface{}, localizer *i18n.Localizer, count int) string { +func (m *MessageCatalog) LookupMessagePlural( + id string, + templateData map[string]interface{}, + localizer *i18n.Localizer, + count int, +) string { if message, ok := m.messages[id]; ok { translation, err := localizer.Localize(&i18n.LocalizeConfig{ DefaultMessage: message, @@ -181,38 +195,47 @@ func (m *MessageCatalog) LookupMessagePlural(id string, templateData map[string] PluralCount: count, }) if err != nil { - switch err.(type) { - case *i18n.MessageNotFoundErr: - m.logger.Warnf("message %s not found: %v", id, err) - if translation != "" { - return translation - } - break - default: - m.logger.Error(err) - } - return id + return m.handleLocalizeError(id, translation, err) } + return translation - } else { - m.logger.Warnf("no translation found for %s", id) - return id } + + m.logger.Warnf("no translation found for %s", id) + + return id } -func InitI18n(ctx context.Context, logger *log.Logger, languages []string) context.Context { +func (m *MessageCatalog) handleLocalizeError(id string, translation string, err error) string { + var messageNotFound *i18n.MessageNotFoundErr + + if errors.As(err, &messageNotFound) { + m.logger.Warnf("message %s not found: %v", id, err) + + if translation != "" { + return translation + } + } else { + m.logger.Error(err) + } + + return id +} + +func InitI18n(logger *log.Logger, languages []string) (*i18n.Bundle, *MessageCatalog) { bundle := i18n.NewBundle(language.English) bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + for _, lang := range languages { _, err := bundle.LoadMessageFile(fmt.Sprintf("active.%s.toml", lang)) if err != nil { - logger.Warnln("message bundle de.toml not found") + logger.Warnf("message bundle %s.toml not found", lang) } } + catalog := initMessageCatalog(logger) - ctx = context.WithValue(ctx, ctxI18nBundle, bundle) - ctx = context.WithValue(ctx, ctxI18nCatalog, catalog) - return ctx + + return bundle, catalog } func initMessageCatalog(logger *log.Logger) *MessageCatalog { @@ -221,13 +244,22 @@ func initMessageCatalog(logger *log.Logger) *MessageCatalog { ID: "ErrorTitle", Other: "An error has occurred", } + return &MessageCatalog{messages: messages, logger: logger} } -func GetI18nBundle(ctx context.Context) *i18n.Bundle { - return ctx.Value(ctxI18nBundle).(*i18n.Bundle) +func GetI18nBundle(ctx context.Context) (*i18n.Bundle, error) { + if b, ok := ctx.Value(ctxI18nBundle).(*i18n.Bundle); ok { + return b, nil + } + + return nil, errors.New("context value is not a Bundle") } -func GetMessageCatalog(ctx context.Context) *MessageCatalog { - return ctx.Value(ctxI18nCatalog).(*MessageCatalog) +func GetMessageCatalog(ctx context.Context) (*MessageCatalog, error) { + if c, ok := ctx.Value(ctxI18nCatalog).(*MessageCatalog); ok { + return c, nil + } + + return nil, errors.New("context value is not a MessageCatalog") }