diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 1acbb55d44..0867a1d5d3 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -267,6 +267,7 @@ Console: LongCache: MaxAge: 12h SharedMaxAge: 168h #7d + InstanceManagementURL: "" Notification: Repository: diff --git a/cmd/start/start.go b/cmd/start/start.go index 6f248c1083..4ae1e15b08 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -5,6 +5,7 @@ import ( "crypto/tls" _ "embed" "fmt" + "math" "net" "net/http" "os" @@ -301,8 +302,14 @@ func startAPIs( if accessSvc.Enabled() { logging.Warn("access logs are currently in beta") } - accessInterceptor := middleware.NewAccessInterceptor(accessSvc, config.Quotas.Access) - apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc) + exhaustedCookieHandler := http_util.NewCookieHandler( + http_util.WithUnsecure(), + http_util.WithNonHttpOnly(), + http_util.WithMaxAge(int(math.Floor(config.Quotas.Access.ExhaustedCookieMaxAge.Seconds()))), + ) + limitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, exhaustedCookieHandler, config.Quotas.Access, false) + nonLimitingAccessInterceptor := middleware.NewAccessInterceptor(accessSvc, nil, config.Quotas.Access, true) + apis, err := api.New(ctx, config.Port, router, queries, verifier, config.InternalAuthZ, tlsConfig, config.HTTP2HostHeader, config.HTTP1HostHeader, accessSvc, exhaustedCookieHandler, config.Quotas.Access) if err != nil { return fmt.Errorf("error creating api %w", err) } @@ -334,7 +341,7 @@ func startAPIs( } instanceInterceptor := middleware.InstanceInterceptor(queries, config.HTTP1HostHeader, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) - apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, accessInterceptor.Handle)) + apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources) if err != nil { @@ -355,25 +362,25 @@ func startAPIs( } apis.RegisterHandlerOnPrefix(openapi.HandlerPrefix, openAPIHandler) - oidcProvider, err := oidc.NewProvider(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, accessInterceptor.Handle) + oidcProvider, err := oidc.NewProvider(config.OIDC, login.DefaultLoggedOutPath, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.OIDCKey, eventstore, dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor.Handle) if err != nil { return fmt.Errorf("unable to start oidc provider: %w", err) } apis.RegisterHandlerPrefixes(oidcProvider.HttpHandler(), "/.well-known/openid-configuration", "/oidc/v1", "/oauth/v2") - samlProvider, err := saml.NewProvider(config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, accessInterceptor.Handle) + samlProvider, err := saml.NewProvider(config.SAML, config.ExternalSecure, commands, queries, authRepo, keys.OIDC, keys.SAML, eventstore, dbClient, instanceInterceptor.Handler, userAgentInterceptor, limitingAccessInterceptor.Handle) if err != nil { return fmt.Errorf("unable to start saml provider: %w", err) } apis.RegisterHandlerOnPrefix(saml.HandlerPrefix, samlProvider.HttpHandler()) - c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, accessInterceptor.Handle, config.CustomerPortal) + c, err := console.Start(config.Console, config.ExternalSecure, oidcProvider.IssuerFromRequest, middleware.CallDurationHandler, instanceInterceptor.Handler, nonLimitingAccessInterceptor.Handle, config.CustomerPortal) if err != nil { return fmt.Errorf("unable to start console: %w", err) } apis.RegisterHandlerOnPrefix(console.HandlerPrefix, c) - l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), provider.AuthCallbackURL(samlProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, accessInterceptor.Handle, keys.User, keys.IDPConfig, keys.CSRFCookieKey) + l, err := login.CreateLogin(config.Login, commands, queries, authRepo, store, console.HandlerPrefix+"/", op.AuthCallbackURL(oidcProvider), provider.AuthCallbackURL(samlProvider), config.ExternalSecure, userAgentInterceptor, op.NewIssuerInterceptor(oidcProvider.IssuerFromRequest).Handler, provider.NewIssuerInterceptor(samlProvider.IssuerFromRequest).Handler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle, keys.User, keys.IDPConfig, keys.CSRFCookieKey) if err != nil { return fmt.Errorf("unable to start login: %w", err) } diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml index 45d941f7b2..21a365354a 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml @@ -4,7 +4,7 @@ services: traefik: networks: - 'zitadel' - image: "traefik:v2.7" + image: "traefik:v2.10.1" ports: - "80:80" - "443:443" diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml index 618d089c8b..4b4f5a35ca 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml @@ -1,3 +1,8 @@ +log: + level: DEBUG + +accessLog: {} + entrypoints: web: address: ":80" @@ -7,7 +12,7 @@ entrypoints: tls: stores: - default: + default: # generates self-signed certificates defaultCertificate: diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml index 2e1afc71cd..6c45449cf5 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml @@ -23,3 +23,8 @@ Database: RootCert: "/crdb-certs/ca.crt" Cert: "/crdb-certs/client.root.crt" Key: "/crdb-certs/client.root.key" + +LogStore: + Access: + Stdout: + Enabled: true diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx index 933404c3f2..e4a668b0fb 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx @@ -70,3 +70,8 @@ This is the IAM admin users login according to your configuration in the [exampl - **password**: *RootPassword1!* Read more about [the login process](/guides/integrate/login-users). + +## Troubleshooting + +You can connect to cockroach like this: `docker exec -it loadbalancing-example-my-cockroach-db-1 cockroach sql --host my-cockroach-db --certs-dir /cockroach/certs/` +For example, to show all login names: `docker exec -it loadbalancing-example-my-cockroach-db-1 cockroach sql --database zitadel --host my-cockroach-db --certs-dir /cockroach/certs/ --execute "select * from projections.login_names2"` diff --git a/e2e/cypress/e2e/quotas/quotas.cy.ts b/e2e/cypress/e2e/quotas/quotas.cy.ts index 14e5c50da9..805779fb7c 100644 --- a/e2e/cypress/e2e/quotas/quotas.cy.ts +++ b/e2e/cypress/e2e/quotas/quotas.cy.ts @@ -107,14 +107,9 @@ describe('quotas', () => { }, }); }); + expectCookieDoesntExist(); const expiresMax = new Date(); expiresMax.setMinutes(expiresMax.getMinutes() + 2); - cy.getCookie('zitadel.quota.limiting').then((cookie) => { - expect(cookie.value).to.equal('false'); - const cookieExpiry = new Date(); - cookieExpiry.setTime(cookie.expiry * 1000); - expect(cookieExpiry).to.be.within(start, expiresMax); - }); cy.request({ url: urls[0], method: 'GET', @@ -127,12 +122,16 @@ describe('quotas', () => { }); cy.getCookie('zitadel.quota.limiting').then((cookie) => { expect(cookie.value).to.equal('true'); + const cookieExpiry = new Date(); + cookieExpiry.setTime(cookie.expiry * 1000); + expect(cookieExpiry).to.be.within(start, expiresMax); }); createHumanUser(ctx.api, testUserName, false).then((res) => { expect(res.status).to.equal(429); }); ensureQuotaIsRemoved(ctx, Unit.AuthenticatedRequests); createHumanUser(ctx.api, testUserName); + expectCookieDoesntExist(); }); }); }); @@ -301,3 +300,9 @@ describe('quotas', () => { }); }); }); + +function expectCookieDoesntExist() { + cy.getCookie('zitadel.quota.limiting').then((cookie) => { + expect(cookie).to.be.null; + }); +} diff --git a/internal/api/api.go b/internal/api/api.go index 4efdb322a0..768f0f1a2e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -34,6 +34,9 @@ type API struct { http1HostName string grpcGateway *server.Gateway healthServer *health.Server + cookieHandler *http_util.CookieHandler + cookieConfig *http_mw.AccessConfig + queries *query.Queries } type healthCheck interface { @@ -49,6 +52,8 @@ func New( authZ internal_authz.Config, tlsConfig *tls.Config, http2HostName, http1HostName string, accessSvc *logstore.Service, + cookieHandler *http_util.CookieHandler, + cookieConfig *http_mw.AccessConfig, ) (_ *API, err error) { api := &API{ port: port, @@ -56,10 +61,13 @@ func New( health: queries, router: router, http1HostName: http1HostName, + cookieConfig: cookieConfig, + cookieHandler: cookieHandler, + queries: queries, } api.grpcServer = server.CreateServer(api.verifier, authZ, queries, http2HostName, tlsConfig, accessSvc) - api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName) + api.grpcGateway, err = server.CreateGateway(ctx, port, http1HostName, cookieHandler, cookieConfig) if err != nil { return nil, err } @@ -77,7 +85,15 @@ func New( // used for v1 api (system, admin, mgmt, auth) func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayPrefix) error { grpcServer.RegisterServer(a.grpcServer) - handler, prefix, err := server.CreateGatewayWithPrefix(ctx, grpcServer, a.port, a.http1HostName) + handler, prefix, err := server.CreateGatewayWithPrefix( + ctx, + grpcServer, + a.port, + a.http1HostName, + a.cookieHandler, + a.cookieConfig, + a.queries, + ) if err != nil { return err } diff --git a/internal/api/grpc/server/gateway.go b/internal/api/grpc/server/gateway.go index faba435940..9798e5dbd0 100644 --- a/internal/api/grpc/server/gateway.go +++ b/internal/api/grpc/server/gateway.go @@ -16,7 +16,9 @@ import ( client_middleware "github.com/zitadel/zitadel/internal/api/grpc/client/middleware" "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" + http_utils "github.com/zitadel/zitadel/internal/api/http" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" + "github.com/zitadel/zitadel/internal/query" ) const ( @@ -67,10 +69,13 @@ type Gateway struct { mux *runtime.ServeMux http1HostName string connection *grpc.ClientConn + cookieHandler *http_utils.CookieHandler + cookieConfig *http_mw.AccessConfig + queries *query.Queries } func (g *Gateway) Handler() http.Handler { - return addInterceptors(g.mux, g.http1HostName) + return addInterceptors(g.mux, g.http1HostName, g.cookieHandler, g.cookieConfig, g.queries) } type CustomHTTPResponse interface { @@ -79,7 +84,15 @@ type CustomHTTPResponse interface { type RegisterGatewayFunc func(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error -func CreateGatewayWithPrefix(ctx context.Context, g WithGatewayPrefix, port uint16, http1HostName string) (http.Handler, string, error) { +func CreateGatewayWithPrefix( + ctx context.Context, + g WithGatewayPrefix, + port uint16, + http1HostName string, + cookieHandler *http_utils.CookieHandler, + cookieConfig *http_mw.AccessConfig, + queries *query.Queries, +) (http.Handler, string, error) { runtimeMux := runtime.NewServeMux(serveMuxOptions...) opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), @@ -93,10 +106,10 @@ func CreateGatewayWithPrefix(ctx context.Context, g WithGatewayPrefix, port uint if err != nil { return nil, "", fmt.Errorf("failed to register grpc gateway: %w", err) } - return addInterceptors(runtimeMux, http1HostName), g.GatewayPathPrefix(), nil + return addInterceptors(runtimeMux, http1HostName, cookieHandler, cookieConfig, queries), g.GatewayPathPrefix(), nil } -func CreateGateway(ctx context.Context, port uint16, http1HostName string) (*Gateway, error) { +func CreateGateway(ctx context.Context, port uint16, http1HostName string, cookieHandler *http_utils.CookieHandler, cookieConfig *http_mw.AccessConfig) (*Gateway, error) { connection, err := dial(ctx, port, []grpc.DialOption{ @@ -111,6 +124,8 @@ func CreateGateway(ctx context.Context, port uint16, http1HostName string) (*Gat mux: runtimeMux, http1HostName: http1HostName, connection: connection, + cookieHandler: cookieHandler, + cookieConfig: cookieConfig, }, nil } @@ -145,13 +160,23 @@ func dial(ctx context.Context, port uint16, opts []grpc.DialOption) (*grpc.Clien return conn, nil } -func addInterceptors(handler http.Handler, http1HostName string) http.Handler { +func addInterceptors( + handler http.Handler, + http1HostName string, + cookieHandler *http_utils.CookieHandler, + cookieConfig *http_mw.AccessConfig, + queries *query.Queries, +) http.Handler { handler = http_mw.CallDurationHandler(handler) handler = http1Host(handler, http1HostName) handler = http_mw.CORSInterceptor(handler) handler = http_mw.RobotsTagHandler(handler) handler = http_mw.DefaultTelemetryHandler(handler) - return http_mw.DefaultMetricsHandler(handler) + // For some non-obvious reason, the exhaustedCookieInterceptor sends the SetCookie header + // only if it follows the http_mw.DefaultTelemetryHandler + handler = exhaustedCookieInterceptor(handler, cookieHandler, cookieConfig, queries) + handler = http_mw.DefaultMetricsHandler(handler) + return handler } func http1Host(next http.Handler, http1HostName string) http.Handler { @@ -165,3 +190,38 @@ func http1Host(next http.Handler, http1HostName string) http.Handler { next.ServeHTTP(w, r) }) } + +func exhaustedCookieInterceptor( + next http.Handler, + cookieHandler *http_utils.CookieHandler, + cookieConfig *http_mw.AccessConfig, + queries *query.Queries, +) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + next.ServeHTTP(&cookieResponseWriter{ + ResponseWriter: writer, + cookieHandler: cookieHandler, + cookieConfig: cookieConfig, + request: request, + queries: queries, + }, request) + }) +} + +type cookieResponseWriter struct { + http.ResponseWriter + cookieHandler *http_utils.CookieHandler + cookieConfig *http_mw.AccessConfig + request *http.Request + queries *query.Queries +} + +func (r *cookieResponseWriter) WriteHeader(status int) { + if status >= 200 && status < 300 { + http_mw.DeleteExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.request, r.cookieConfig) + } + if status == http.StatusTooManyRequests { + http_mw.SetExhaustedCookie(r.cookieHandler, r.ResponseWriter, r.cookieConfig, r.request) + } + r.ResponseWriter.WriteHeader(status) +} diff --git a/internal/api/http/middleware/access_interceptor.go b/internal/api/http/middleware/access_interceptor.go index 4e95a83f6f..cf52a597d6 100644 --- a/internal/api/http/middleware/access_interceptor.go +++ b/internal/api/http/middleware/access_interceptor.go @@ -1,15 +1,16 @@ package middleware import ( - "math" + "net" "net/http" "net/url" - "strconv" + "strings" "time" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" http_utils "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/logstore/emitters/access" @@ -20,6 +21,7 @@ type AccessInterceptor struct { svc *logstore.Service cookieHandler *http_utils.CookieHandler limitConfig *AccessConfig + storeOnly bool } type AccessConfig struct { @@ -27,14 +29,15 @@ type AccessConfig struct { ExhaustedCookieMaxAge time.Duration } -func NewAccessInterceptor(svc *logstore.Service, cookieConfig *AccessConfig) *AccessInterceptor { +// NewAccessInterceptor intercepts all requests and stores them to the logstore. +// If storeOnly is false, it also checks if requests are exhausted. +// If requests are exhausted, it also returns http.StatusTooManyRequests and sets a cookie +func NewAccessInterceptor(svc *logstore.Service, cookieHandler *http_utils.CookieHandler, cookieConfig *AccessConfig, storeOnly bool) *AccessInterceptor { return &AccessInterceptor{ - svc: svc, - cookieHandler: http_utils.NewCookieHandler( - http_utils.WithUnsecure(), - http_utils.WithMaxAge(int(math.Floor(cookieConfig.ExhaustedCookieMaxAge.Seconds()))), - ), - limitConfig: cookieConfig, + svc: svc, + cookieHandler: cookieHandler, + limitConfig: cookieConfig, + storeOnly: storeOnly, } } @@ -44,36 +47,33 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { } return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { ctx := request.Context() - var err error - tracingCtx, checkSpan := tracing.NewNamedSpan(ctx, "checkAccess") - wrappedWriter := &statusRecorder{ResponseWriter: writer, status: 0} - instance := authz.GetInstance(ctx) - remaining := a.svc.Limit(tracingCtx, instance.InstanceID()) - limit := remaining != nil && *remaining == 0 - - a.cookieHandler.SetCookie(wrappedWriter, a.limitConfig.ExhaustedCookieKey, request.Host, strconv.FormatBool(limit)) - - if limit { - wrappedWriter.WriteHeader(http.StatusTooManyRequests) - wrappedWriter.ignoreWrites = true + limit := false + if !a.storeOnly { + remaining := a.svc.Limit(tracingCtx, instance.InstanceID()) + limit = remaining != nil && *remaining == 0 } - checkSpan.End() - - next.ServeHTTP(wrappedWriter, request) - + if limit { + // Limit can only be true when storeOnly is false, so set the cookie and the response code + SetExhaustedCookie(a.cookieHandler, wrappedWriter, a.limitConfig, request) + http.Error(wrappedWriter, "quota for authenticated requests is exhausted", http.StatusTooManyRequests) + } else { + if !a.storeOnly { + // If not limited and not storeOnly, ensure the cookie is deleted + DeleteExhaustedCookie(a.cookieHandler, wrappedWriter, request, a.limitConfig) + } + // Always serve if not limited + next.ServeHTTP(wrappedWriter, request) + } tracingCtx, writeSpan := tracing.NewNamedSpan(tracingCtx, "writeAccess") defer writeSpan.End() - requestURL := request.RequestURI unescapedURL, err := url.QueryUnescape(requestURL) if err != nil { logging.WithError(err).WithField("url", requestURL).Warning("failed to unescape request url") - // err = nil is effective because of deferred tracing span end - err = nil } a.svc.Handle(tracingCtx, &access.Record{ LogDate: time.Now(), @@ -90,6 +90,24 @@ func (a *AccessInterceptor) Handle(next http.Handler) http.Handler { }) } +func SetExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, cookieConfig *AccessConfig, request *http.Request) { + cookieValue := "true" + host := request.Header.Get(middleware.HTTP1Host) + domain := host + if strings.ContainsAny(host, ":") { + var err error + domain, _, err = net.SplitHostPort(host) + if err != nil { + logging.WithError(err).WithField("host", host).Warning("failed to extract cookie domain from request host") + } + } + cookieHandler.SetCookie(writer, cookieConfig.ExhaustedCookieKey, domain, cookieValue) +} + +func DeleteExhaustedCookie(cookieHandler *http_utils.CookieHandler, writer http.ResponseWriter, request *http.Request, cookieConfig *AccessConfig) { + cookieHandler.DeleteCookie(writer, request, cookieConfig.ExhaustedCookieKey) +} + type statusRecorder struct { http.ResponseWriter status int diff --git a/internal/api/ui/console/console.go b/internal/api/ui/console/console.go index ac11fcf706..45503ca0c1 100644 --- a/internal/api/ui/console/console.go +++ b/internal/api/ui/console/console.go @@ -1,9 +1,11 @@ package console import ( + "bytes" "embed" "encoding/json" "fmt" + "html/template" "io/fs" "net/http" "os" @@ -22,8 +24,9 @@ import ( ) type Config struct { - ShortCache middleware.CacheConfig - LongCache middleware.CacheConfig + ShortCache middleware.CacheConfig + LongCache middleware.CacheConfig + InstanceManagementURL string } type spaHandler struct { @@ -106,7 +109,13 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call handler.Use(callDurationInterceptor, instanceHandler, security, accessInterceptor) handler.Handle(envRequestPath, middleware.TelemetryHandler()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { url := http_util.BuildOrigin(r.Host, externalSecure) - environmentJSON, err := createEnvironmentJSON(url, issuer(r), authz.GetInstance(r.Context()).ConsoleClientID(), customerPortal) + instance := authz.GetInstance(r.Context()) + instanceMgmtURL, err := templateInstanceManagementURL(config.InstanceManagementURL, instance) + if err != nil { + http.Error(w, fmt.Sprintf("unable to template instance management url for console: %v", err), http.StatusInternalServerError) + return + } + environmentJSON, err := createEnvironmentJSON(url, issuer(r), instance.ConsoleClientID(), customerPortal, instanceMgmtURL) if err != nil { http.Error(w, fmt.Sprintf("unable to marshal env for console: %v", err), http.StatusInternalServerError) return @@ -118,6 +127,18 @@ func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, call return handler, nil } +func templateInstanceManagementURL(templateableCookieValue string, instance authz.Instance) (string, error) { + cookieValueTemplate, err := template.New("cookievalue").Parse(templateableCookieValue) + if err != nil { + return templateableCookieValue, err + } + cookieValue := new(bytes.Buffer) + if err = cookieValueTemplate.Execute(cookieValue, instance); err != nil { + return templateableCookieValue, err + } + return cookieValue.String(), nil +} + func csp() *middleware.CSP { csp := middleware.DefaultSCP csp.StyleSrc = csp.StyleSrc.AddInline() @@ -127,17 +148,19 @@ func csp() *middleware.CSP { return &csp } -func createEnvironmentJSON(api, issuer, clientID, customerPortal string) ([]byte, error) { +func createEnvironmentJSON(api, issuer, clientID, customerPortal, instanceMgmtUrl string) ([]byte, error) { environment := struct { - API string `json:"api,omitempty"` - Issuer string `json:"issuer,omitempty"` - ClientID string `json:"clientid,omitempty"` - CustomerPortal string `json:"customer_portal,omitempty"` + API string `json:"api,omitempty"` + Issuer string `json:"issuer,omitempty"` + ClientID string `json:"clientid,omitempty"` + CustomerPortal string `json:"customer_portal,omitempty"` + InstanceManagementURL string `json:"instance_management_url,omitempty"` }{ - API: api, - Issuer: issuer, - ClientID: clientID, - CustomerPortal: customerPortal, + API: api, + Issuer: issuer, + ClientID: clientID, + CustomerPortal: customerPortal, + InstanceManagementURL: instanceMgmtUrl, } return json.Marshal(environment) }