diff --git a/.gitignore b/.gitignore index db3eaf3e..21ad3ba8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin dist _output +.idea diff --git a/Documentation/connectors/ldap.md b/Documentation/connectors/ldap.md index 20f0e406..4c1a8ba7 100644 --- a/Documentation/connectors/ldap.md +++ b/Documentation/connectors/ldap.md @@ -89,7 +89,7 @@ connectors: # server provides access for anonymous auth. # Please note that if the bind password contains a `$`, it has to be saved in an # environment variable which should be given as the value to `bindPW`. - bindDN: uid=seviceaccount,cn=users,dc=example,dc=com + bindDN: uid=serviceaccount,cn=users,dc=example,dc=com bindPW: password # The attribute to display in the provided password prompt. If unset, will diff --git a/Documentation/connectors/oidc.md b/Documentation/connectors/oidc.md index 55b7a96e..7db2926b 100644 --- a/Documentation/connectors/oidc.md +++ b/Documentation/connectors/oidc.md @@ -10,7 +10,7 @@ Prominent examples of OpenID Connect providers include Google Accounts, Salesfor This connector does not support the "groups" claim. Progress for this is tracked in [issue #1065][issue-1065]. -When using refresh tokens, changes to the upstream claims aren't propegated to the id_token returned by dex. If a user's email changes, the "email" claim returned by dex won't change unless the user logs in again. Progress for this is tracked in [issue #863][issue-863]. +When using refresh tokens, changes to the upstream claims aren't propagated to the id_token returned by dex. If a user's email changes, the "email" claim returned by dex won't change unless the user logs in again. Progress for this is tracked in [issue #863][issue-863]. ## Configuration @@ -36,7 +36,7 @@ connectors: # Some providers require passing client_secret via POST parameters instead # of basic auth, despite the OAuth2 RFC discouraging it. Many of these - # cases are caught internally, but some may need to uncommented the + # cases are caught internally, but some may need to uncomment the # following field. # # basicAuthUnsupported: true @@ -56,7 +56,7 @@ connectors: # - email # - groups - # Some providers return claims without "email_verified", when they had no usage of emails verification in enrollement process + # Some providers return claims without "email_verified", when they had no usage of emails verification in enrollment process # or if they are acting as a proxy for another IDP etc AWS Cognito with an upstream SAML IDP # This can be overridden with the below option # insecureSkipEmailVerified: true diff --git a/Documentation/proposals/upstream-refreshing.md b/Documentation/proposals/upstream-refreshing.md index 019bd9f8..414f0e29 100644 --- a/Documentation/proposals/upstream-refreshing.md +++ b/Documentation/proposals/upstream-refreshing.md @@ -11,7 +11,7 @@ in with GitHub. ## The problem -When dex is federaing to an upstream identity provider (IDP), we want to ensure +When dex is federating to an upstream identity provider (IDP), we want to ensure claims being passed onto clients remain fresh. This includes data such as Google accounts display names, LDAP group membership, account deactivations. Changes to these on an upstream IDP should always be reflected in the claims dex passes to diff --git a/server/handlers.go b/server/handlers.go index 9bff36ee..1391e589 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -101,7 +101,7 @@ func (h *healthChecker) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mu.RUnlock() if err != nil { - h.s.renderError(w, http.StatusInternalServerError, "Health check failed.") + h.s.renderError(r, w, http.StatusInternalServerError, "Health check failed.") return } fmt.Fprintf(w, "Health check passed in %s", t) @@ -112,13 +112,13 @@ func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) { keys, err := s.storage.GetKeys() if err != nil { s.logger.Errorf("failed to get keys: %v", err) - s.renderError(w, http.StatusInternalServerError, "Internal server error.") + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") return } if keys.SigningKeyPub == nil { s.logger.Errorf("No public keys found.") - s.renderError(w, http.StatusInternalServerError, "Internal server error.") + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") return } @@ -133,7 +133,7 @@ func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) { data, err := json.MarshalIndent(jwks, "", " ") if err != nil { s.logger.Errorf("failed to marshal discovery data: %v", err) - s.renderError(w, http.StatusInternalServerError, "Internal server error.") + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") return } maxAge := keys.NextRotation.Sub(s.now()) @@ -214,7 +214,7 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { status = err.Status() } - s.renderError(w, status, err.Error()) + s.renderError(r, w, status, err.Error()) return } @@ -226,14 +226,14 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { authReq.Expiry = s.now().Add(s.authRequestsValidFor) if err := s.storage.CreateAuthRequest(*authReq); err != nil { s.logger.Errorf("Failed to create authorization request: %v", err) - s.renderError(w, http.StatusInternalServerError, "Failed to connect to the database.") + s.renderError(r, w, http.StatusInternalServerError, "Failed to connect to the database.") return } connectors, err := s.storage.ListConnectors() if err != nil { s.logger.Errorf("Failed to get list of connectors: %v", err) - s.renderError(w, http.StatusInternalServerError, "Failed to retrieve connector list.") + s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve connector list.") return } @@ -271,7 +271,7 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { i++ } - if err := s.templates.login(w, connectorInfos); err != nil { + if err := s.templates.login(r, w, connectorInfos, r.URL.Path); err != nil { s.logger.Errorf("Server template error: %v", err) } } @@ -281,7 +281,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { conn, err := s.getConnector(connID) if err != nil { s.logger.Errorf("Failed to create authorization request: %v", err) - s.renderError(w, http.StatusBadRequest, "Requested resource does not exist") + s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist") return } @@ -291,9 +291,9 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { if err != nil { s.logger.Errorf("Failed to get auth request: %v", err) if err == storage.ErrNotFound { - s.renderError(w, http.StatusBadRequest, "Login session expired.") + s.renderError(r, w, http.StatusBadRequest, "Login session expired.") } else { - s.renderError(w, http.StatusInternalServerError, "Database error.") + s.renderError(r, w, http.StatusInternalServerError, "Database error.") } return } @@ -306,7 +306,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { } if err := s.storage.UpdateAuthRequest(authReqID, updater); err != nil { s.logger.Errorf("Failed to set connector ID on auth request: %v", err) - s.renderError(w, http.StatusInternalServerError, "Database error.") + s.renderError(r, w, http.StatusInternalServerError, "Database error.") return } } @@ -324,19 +324,19 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { callbackURL, err := conn.LoginURL(scopes, s.absURL("/callback"), authReqID) if err != nil { s.logger.Errorf("Connector %q returned error when creating callback: %v", connID, err) - s.renderError(w, http.StatusInternalServerError, "Login error.") + s.renderError(r, w, http.StatusInternalServerError, "Login error.") return } http.Redirect(w, r, callbackURL, http.StatusFound) case connector.PasswordConnector: - if err := s.templates.password(w, r.URL.String(), "", usernamePrompt(conn), false, showBacklink); err != nil { + if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(conn), false, showBacklink, r.URL.Path); err != nil { s.logger.Errorf("Server template error: %v", err) } case connector.SAMLConnector: action, value, err := conn.POSTData(scopes, authReqID) if err != nil { s.logger.Errorf("Creating SAML data: %v", err) - s.renderError(w, http.StatusInternalServerError, "Connector Login Error") + s.renderError(r, w, http.StatusInternalServerError, "Connector Login Error") return } @@ -358,12 +358,12 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { `, action, value, authReqID) default: - s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.") + s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") } case http.MethodPost: passwordConnector, ok := conn.Connector.(connector.PasswordConnector) if !ok { - s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.") + s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") return } @@ -373,11 +373,11 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { identity, ok, err := passwordConnector.Login(r.Context(), scopes, username, password) if err != nil { s.logger.Errorf("Failed to login user: %v", err) - s.renderError(w, http.StatusInternalServerError, fmt.Sprintf("Login error: %v", err)) + s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Login error: %v", err)) return } if !ok { - if err := s.templates.password(w, r.URL.String(), username, usernamePrompt(passwordConnector), true, showBacklink); err != nil { + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(passwordConnector), true, showBacklink, r.URL.Path); err != nil { s.logger.Errorf("Server template error: %v", err) } return @@ -385,13 +385,13 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { redirectURL, err := s.finalizeLogin(identity, authReq, conn.Connector) if err != nil { s.logger.Errorf("Failed to finalize login: %v", err) - s.renderError(w, http.StatusInternalServerError, "Login error.") + s.renderError(r, w, http.StatusInternalServerError, "Login error.") return } http.Redirect(w, r, redirectURL, http.StatusSeeOther) default: - s.renderError(w, http.StatusBadRequest, "Unsupported request method.") + s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") } } @@ -400,16 +400,16 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) switch r.Method { case http.MethodGet: // OAuth2 callback if authID = r.URL.Query().Get("state"); authID == "" { - s.renderError(w, http.StatusBadRequest, "User session error.") + s.renderError(r, w, http.StatusBadRequest, "User session error.") return } case http.MethodPost: // SAML POST binding if authID = r.PostFormValue("RelayState"); authID == "" { - s.renderError(w, http.StatusBadRequest, "User session error.") + s.renderError(r, w, http.StatusBadRequest, "User session error.") return } default: - s.renderError(w, http.StatusBadRequest, "Method not supported") + s.renderError(r, w, http.StatusBadRequest, "Method not supported") return } @@ -417,24 +417,24 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) if err != nil { if err == storage.ErrNotFound { s.logger.Errorf("Invalid 'state' parameter provided: %v", err) - s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.") + s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") return } s.logger.Errorf("Failed to get auth request: %v", err) - s.renderError(w, http.StatusInternalServerError, "Database error.") + s.renderError(r, w, http.StatusInternalServerError, "Database error.") return } if connID := mux.Vars(r)["connector"]; connID != "" && connID != authReq.ConnectorID { s.logger.Errorf("Connector mismatch: authentication started with id %q, but callback for id %q was triggered", authReq.ConnectorID, connID) - s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.") + s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") return } conn, err := s.getConnector(authReq.ConnectorID) if err != nil { s.logger.Errorf("Failed to get connector with id %q : %v", authReq.ConnectorID, err) - s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.") + s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") return } @@ -443,32 +443,32 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) case connector.CallbackConnector: if r.Method != http.MethodGet { s.logger.Errorf("SAML request mapped to OAuth2 connector") - s.renderError(w, http.StatusBadRequest, "Invalid request") + s.renderError(r, w, http.StatusBadRequest, "Invalid request") return } identity, err = conn.HandleCallback(parseScopes(authReq.Scopes), r) case connector.SAMLConnector: if r.Method != http.MethodPost { s.logger.Errorf("OAuth2 request mapped to SAML connector") - s.renderError(w, http.StatusBadRequest, "Invalid request") + s.renderError(r, w, http.StatusBadRequest, "Invalid request") return } identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"), authReq.ID) default: - s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.") + s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") return } if err != nil { s.logger.Errorf("Failed to authenticate: %v", err) - s.renderError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to authenticate: %v", err)) + s.renderError(r, w, http.StatusInternalServerError, fmt.Sprintf("Failed to authenticate: %v", err)) return } redirectURL, err := s.finalizeLogin(identity, authReq, conn.Connector) if err != nil { s.logger.Errorf("Failed to finalize login: %v", err) - s.renderError(w, http.StatusInternalServerError, "Login error.") + s.renderError(r, w, http.StatusInternalServerError, "Login error.") return } @@ -511,12 +511,12 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { authReq, err := s.storage.GetAuthRequest(r.FormValue("req")) if err != nil { s.logger.Errorf("Failed to get auth request: %v", err) - s.renderError(w, http.StatusInternalServerError, "Database error.") + s.renderError(r, w, http.StatusInternalServerError, "Database error.") return } if !authReq.LoggedIn { s.logger.Errorf("Auth request does not have an identity for approval") - s.renderError(w, http.StatusInternalServerError, "Login process not yet finalized.") + s.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.") return } @@ -529,15 +529,15 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { client, err := s.storage.GetClient(authReq.ClientID) if err != nil { s.logger.Errorf("Failed to get client %q: %v", authReq.ClientID, err) - s.renderError(w, http.StatusInternalServerError, "Failed to retrieve client.") + s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve client.") return } - if err := s.templates.approval(w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes); err != nil { + if err := s.templates.approval(r, w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes, r.URL.Path); err != nil { s.logger.Errorf("Server template error: %v", err) } case http.MethodPost: if r.FormValue("approval") != "approve" { - s.renderError(w, http.StatusInternalServerError, "Approval rejected.") + s.renderError(r, w, http.StatusInternalServerError, "Approval rejected.") return } s.sendCodeResponse(w, r, authReq) @@ -546,22 +546,22 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) { if s.now().After(authReq.Expiry) { - s.renderError(w, http.StatusBadRequest, "User session has expired.") + s.renderError(r, w, http.StatusBadRequest, "User session has expired.") return } if err := s.storage.DeleteAuthRequest(authReq.ID); err != nil { if err != storage.ErrNotFound { s.logger.Errorf("Failed to delete authorization request: %v", err) - s.renderError(w, http.StatusInternalServerError, "Internal server error.") + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") } else { - s.renderError(w, http.StatusBadRequest, "User session error.") + s.renderError(r, w, http.StatusBadRequest, "User session error.") } return } u, err := url.Parse(authReq.RedirectURI) if err != nil { - s.renderError(w, http.StatusInternalServerError, "Invalid redirect URI.") + s.renderError(r, w, http.StatusInternalServerError, "Invalid redirect URI.") return } @@ -598,14 +598,14 @@ func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authRe } if err := s.storage.CreateAuthCode(code); err != nil { s.logger.Errorf("Failed to create auth code: %v", err) - s.renderError(w, http.StatusInternalServerError, "Internal server error.") + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") return } // Implicit and hybrid flows that try to use the OOB redirect URI are // rejected earlier. If we got here we're using the code flow. if authReq.RedirectURI == redirectURIOOB { - if err := s.templates.oob(w, code.ID); err != nil { + if err := s.templates.oob(r, w, code.ID, r.URL.Path); err != nil { s.logger.Errorf("Server template error: %v", err) } return @@ -1119,8 +1119,8 @@ func (s *Server) writeAccessToken(w http.ResponseWriter, idToken, accessToken, r w.Write(data) } -func (s *Server) renderError(w http.ResponseWriter, status int, description string) { - if err := s.templates.err(w, status, description); err != nil { +func (s *Server) renderError(r *http.Request, w http.ResponseWriter, status int, description string) { + if err := s.templates.err(r, w, status, description); err != nil { s.logger.Errorf("Server template error: %v", err) } } diff --git a/server/templates.go b/server/templates.go index 89d41371..88aeace0 100644 --- a/server/templates.go +++ b/server/templates.go @@ -6,7 +6,9 @@ import ( "io" "io/ioutil" "net/http" + "net/url" "os" + "path" "path/filepath" "sort" "strings" @@ -94,7 +96,7 @@ func loadWebConfig(c webConfig) (static, theme http.Handler, templates *template c.dir = "./web" } if c.logoURL == "" { - c.logoURL = join(c.issuerURL, "theme/logo.png") + c.logoURL = "theme/logo.png" } if err := dirExists(c.dir); err != nil { @@ -136,10 +138,15 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { return nil, fmt.Errorf("no files in template dir %q", templatesDir) } + issuerURL, err := url.Parse(c.issuerURL) + if err != nil { + return nil, fmt.Errorf("error parsing issuerURL: %v", err) + } + funcs := map[string]interface{}{ "issuer": func() string { return c.issuer }, "logo": func() string { return c.logoURL }, - "url": func(s string) string { return join(c.issuerURL, s) }, + "url": func(reqPath, assetPath string) string { return relativeURL(issuerURL.Path, reqPath, assetPath) }, "lower": strings.ToLower, "extra": func(k string) string { return c.extra[k] }, } @@ -166,6 +173,69 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { }, nil } +// relativeURL returns the URL of the asset relative to the URL of the request path. +// The serverPath is consulted to trim any prefix due in case it is not listening +// to the root path. +// +// Algorithm: +// 1. Remove common prefix of serverPath and reqPath +// 2. Remove common prefix of assetPath and reqPath +// 3. For each part of reqPath remaining(minus one), go up one level (..) +// 4. For each part of assetPath remaining, append it to result +// +//eg +//server listens at localhost/dex so serverPath is dex +//reqPath is /dex/auth +//assetPath is static/main.css +//relativeURL("/dex", "/dex/auth", "static/main.css") = "../static/main.css" +func relativeURL(serverPath, reqPath, assetPath string) string { + + splitPath := func(p string) []string { + res := []string{} + parts := strings.Split(path.Clean(p), "/") + for _, part := range parts { + if part != "" { + res = append(res, part) + } + } + return res + } + + stripCommonParts := func(s1, s2 []string) ([]string, []string) { + min := len(s1) + if len(s2) < min { + min = len(s2) + } + + splitIndex := min + for i := 0; i < min; i++ { + if s1[i] != s2[i] { + splitIndex = i + break + } + } + return s1[splitIndex:], s2[splitIndex:] + } + + server, req, asset := splitPath(serverPath), splitPath(reqPath), splitPath(assetPath) + + // Remove common prefix of request path with server path + server, req = stripCommonParts(server, req) + + // Remove common prefix of request path with asset path + asset, req = stripCommonParts(asset, req) + + // For each part of the request remaining (minus one) -> go up one level (..) + // For each part of the asset remaining -> append it + var relativeURL string + for i := 0; i < len(req)-1; i++ { + relativeURL = path.Join("..", relativeURL) + } + relativeURL = path.Join(relativeURL, path.Join(asset...)) + + return relativeURL +} + var scopeDescriptions = map[string]string{ "offline_access": "Have offline access", "profile": "View basic profile information", @@ -184,26 +254,28 @@ func (n byName) Len() int { return len(n) } func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name } func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] } -func (t *templates) login(w http.ResponseWriter, connectors []connectorInfo) error { +func (t *templates) login(r *http.Request, w http.ResponseWriter, connectors []connectorInfo, reqPath string) error { sort.Sort(byName(connectors)) data := struct { Connectors []connectorInfo - }{connectors} + ReqPath string + }{connectors, r.URL.Path} return renderTemplate(w, t.loginTmpl, data) } -func (t *templates) password(w http.ResponseWriter, postURL, lastUsername, usernamePrompt string, lastWasInvalid, showBacklink bool) error { +func (t *templates) password(r *http.Request, w http.ResponseWriter, postURL, lastUsername, usernamePrompt string, lastWasInvalid, showBacklink bool, reqPath string) error { data := struct { PostURL string BackLink bool Username string UsernamePrompt string Invalid bool - }{postURL, showBacklink, lastUsername, usernamePrompt, lastWasInvalid} + ReqPath string + }{postURL, showBacklink, lastUsername, usernamePrompt, lastWasInvalid, r.URL.Path} return renderTemplate(w, t.passwordTmpl, data) } -func (t *templates) approval(w http.ResponseWriter, authReqID, username, clientName string, scopes []string) error { +func (t *templates) approval(r *http.Request, w http.ResponseWriter, authReqID, username, clientName string, scopes []string, reqPath string) error { accesses := []string{} for _, scope := range scopes { access, ok := scopeDescriptions[scope] @@ -217,23 +289,26 @@ func (t *templates) approval(w http.ResponseWriter, authReqID, username, clientN Client string AuthReqID string Scopes []string - }{username, clientName, authReqID, accesses} + ReqPath string + }{username, clientName, authReqID, accesses, r.URL.Path} return renderTemplate(w, t.approvalTmpl, data) } -func (t *templates) oob(w http.ResponseWriter, code string) error { +func (t *templates) oob(r *http.Request, w http.ResponseWriter, code string, reqPath string) error { data := struct { - Code string - }{code} + Code string + ReqPath string + }{code, r.URL.Path} return renderTemplate(w, t.oobTmpl, data) } -func (t *templates) err(w http.ResponseWriter, errCode int, errMsg string) error { +func (t *templates) err(r *http.Request, w http.ResponseWriter, errCode int, errMsg string) error { w.WriteHeader(errCode) data := struct { ErrType string ErrMsg string - }{http.StatusText(errCode), errMsg} + ReqPath string + }{http.StatusText(errCode), errMsg, r.URL.Path} if err := t.errorTmpl.Execute(w, data); err != nil { return fmt.Errorf("Error rendering template %s: %s", t.errorTmpl.Name(), err) } diff --git a/server/templates_test.go b/server/templates_test.go index abb4e431..5ead66e5 100644 --- a/server/templates_test.go +++ b/server/templates_test.go @@ -1 +1,44 @@ package server + +import "testing" + +func TestRelativeURL(t *testing.T) { + tests := []struct { + name string + serverPath string + reqPath string + assetPath string + expected string + }{ + { + name: "server-root-req-one-level-asset-two-level", + serverPath: "/", + reqPath: "/auth", + assetPath: "/theme/main.css", + expected: "theme/main.css", + }, + { + name: "server-one-level-req-one-level-asset-two-level", + serverPath: "/dex", + reqPath: "/dex/auth", + assetPath: "/theme/main.css", + expected: "theme/main.css", + }, + { + name: "server-root-req-two-level-asset-three-level", + serverPath: "/dex", + reqPath: "/dex/auth/connector", + assetPath: "assets/css/main.css", + expected: "../assets/css/main.css", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := relativeURL(test.serverPath, test.reqPath, test.assetPath) + if actual != test.expected { + t.Fatalf("Got '%s'. Expected '%s'", actual, test.expected) + } + }) + } +} diff --git a/web/templates/header.html b/web/templates/header.html index edd6289a..0d4fea0f 100644 --- a/web/templates/header.html +++ b/web/templates/header.html @@ -5,15 +5,15 @@ {{ issuer }} - - - + + +
- +