From 8031f5b1cab0705f48d1f926ebef11416eabcff9 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Mon, 30 Mar 2026 15:33:30 +0200 Subject: [PATCH] feat: add home page with user session info (#4677) Signed-off-by: maksim.nabokikh --- server/home.go | 81 ++++++++++++++++++ server/home_test.go | 180 +++++++++++++++++++++++++++++++++++++++ server/server.go | 15 +--- server/templates.go | 30 +++++++ server/templates_test.go | 21 +++++ web/static/main.css | 79 +++++++++++++++++ web/templates/home.html | 98 +++++++++++++++++++++ 7 files changed, 490 insertions(+), 14 deletions(-) create mode 100644 server/home.go create mode 100644 server/home_test.go create mode 100644 web/templates/home.html diff --git a/server/home.go b/server/home.go new file mode 100644 index 00000000..0e15ab32 --- /dev/null +++ b/server/home.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/dexidp/dex/storage" +) + +func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { + if s.sessionConfig == nil || s.templates.homeTmpl == nil { + s.handleHomeInline(w, r) + return + } + + ctx := r.Context() + data := homeData{ + DiscoveryURL: s.issuerURL.JoinPath(".well-known", "openid-configuration").String(), + LogoutURL: s.absURL("/logout"), + } + + if cookie, err := r.Cookie(s.sessionConfig.CookieName); err == nil && cookie.Value != "" { + if userID, connectorID, nonce, err := parseSessionCookie(cookie.Value); err == nil { + session, err := s.storage.GetAuthSession(ctx, userID, connectorID) + if err == nil && session.Nonce == nonce { + data.LoggedIn = true + data.IPAddress = session.IPAddress + data.UserAgent = session.UserAgent + s.populateHomeData(ctx, &data, userID, connectorID) + } else if err != nil && !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "home: failed to get auth session", "err", err) + } + } + } + + if err := s.templates.home(r, w, data); err != nil { + s.logger.ErrorContext(ctx, "failed to render home template", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + } +} + +func (s *Server) handleHomeInline(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprintf(w, ` + Dex +

Dex IdP

+

A Federated OpenID Connect Provider

+

Discovery

`, + s.issuerURL.JoinPath(".well-known", "openid-configuration").String()) + if err != nil { + s.logger.Error("failed to write response", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Handling the / path error.") + } +} + +func (s *Server) populateHomeData(ctx context.Context, data *homeData, userID, connectorID string) { + ui, err := s.storage.GetUserIdentity(ctx, userID, connectorID) + if err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "home: failed to get user identity", "err", err) + } + return + } + + data.Username = ui.Claims.PreferredUsername + if data.Username == "" { + data.Username = ui.Claims.Username + } + data.Email = ui.Claims.Email + data.EmailVerified = ui.Claims.EmailVerified + data.Groups = ui.Claims.Groups + if !ui.LastLogin.IsZero() { + data.LastLoginEpoch = ui.LastLogin.Unix() + } + + conn, err := s.storage.GetConnector(ctx, connectorID) + if err == nil { + data.ConnectorName = conn.Name + } +} diff --git a/server/home_test.go b/server/home_test.go new file mode 100644 index 00000000..3425408a --- /dev/null +++ b/server/home_test.go @@ -0,0 +1,180 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + gosundheit "github.com/AppsFlyer/go-sundheit" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/server/signer" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" +) + +func newTestServerWithSessions(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) { + t.Helper() + + var server *Server + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server.ServeHTTP(w, r) + })) + + logger := newLogger(t) + ctx := t.Context() + + sig, err := signer.NewMockSigner(testKey) + require.NoError(t, err) + + config := Config{ + Issuer: s.URL, + Storage: memory.New(logger), + Web: WebConfig{ + Dir: "../web", + }, + Logger: logger, + PrometheusRegistry: prometheus.NewRegistry(), + HealthChecker: gosundheit.New(), + SkipApprovalScreen: true, + AllowedGrantTypes: []string{ + grantTypeAuthorizationCode, + grantTypeClientCredentials, + grantTypeRefreshToken, + grantTypeTokenExchange, + grantTypeDeviceCode, + }, + Signer: sig, + SessionConfig: &SessionConfig{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: 1 * time.Hour, + }, + } + if updateConfig != nil { + updateConfig(&config) + } + s.URL = config.Issuer + + connector := storage.Connector{ + ID: "mock", + Type: "mockCallback", + Name: "Mock", + ResourceVersion: "1", + } + require.NoError(t, config.Storage.CreateConnector(ctx, connector)) + + server, err = newServer(ctx, config) + require.NoError(t, err) + + if server.refreshTokenPolicy == nil { + server.refreshTokenPolicy, err = NewRefreshTokenPolicy(logger, false, "", "", "") + require.NoError(t, err) + server.refreshTokenPolicy.now = config.Now + } + + return s, server +} + +func TestHomeNoSessions(t *testing.T) { + httpServer, server := newTestServer(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + body := rr.Body.String() + require.Contains(t, body, "Dex IdP") + require.Contains(t, body, "Discovery") + require.NotContains(t, body, "Logout") +} + +func TestHomeNotLoggedIn(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, httptest.NewRequest("GET", "/", nil)) + require.Equal(t, http.StatusOK, rr.Code) + + body := rr.Body.String() + require.Contains(t, body, "Discovery") + require.Contains(t, body, "Not logged in") + require.NotContains(t, body, "Logout") +} + +func TestHomeLoggedIn(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + ctx := t.Context() + userID := "test-user" + connectorID := "mock" + nonce := "testnonce" + now := time.Now() + + require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: userID, + ConnectorID: connectorID, + Nonce: nonce, + CreatedAt: now, + LastActivity: now, + })) + + require.NoError(t, server.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: userID, + ConnectorID: connectorID, + Claims: storage.Claims{ + UserID: userID, + Username: "Test User", + PreferredUsername: "testuser", + Email: "test@example.com", + EmailVerified: true, + Groups: []string{"admins", "devs"}, + }, + LastLogin: now, + })) + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{ + Name: "dex_session", + Value: sessionCookieValue(userID, connectorID, nonce), + }) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + body := rr.Body.String() + require.Contains(t, body, "Logout") + require.Contains(t, body, "/logout") + require.Contains(t, body, "testuser") + require.Contains(t, body, "test@example.com") + require.Contains(t, body, "Mock") + require.Contains(t, body, "admins") + require.Contains(t, body, "Discovery") + require.NotContains(t, body, "Not logged in") +} + +func TestHomeInvalidCookie(t *testing.T) { + httpServer, server := newTestServerWithSessions(t, nil) + defer httpServer.Close() + + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{ + Name: "dex_session", + Value: "invalid-cookie-value", + }) + + rr := httptest.NewRecorder() + server.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + body := rr.Body.String() + require.NotContains(t, body, "Logout") + require.Contains(t, body, "Not logged in") + require.Contains(t, body, "Discovery") +} diff --git a/server/server.go b/server/server.go index 4d55d5ee..2f023623 100644 --- a/server/server.go +++ b/server/server.go @@ -521,20 +521,7 @@ func newServer(ctx context.Context, c Config) (*Server, error) { return nil, err } handleWithCORS("/.well-known/openid-configuration", discoveryHandler) - // Handle the root path for the better user experience. - handleWithCORS("/", func(w http.ResponseWriter, r *http.Request) { - _, err := fmt.Fprintf(w, ` - Dex -

Dex IdP

-

A Federated OpenID Connect Provider

-

Discovery

`, - s.issuerURL.JoinPath(".well-known", "openid-configuration").String()) - if err != nil { - s.logger.Error("failed to write response", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Handling the / path error.") - return - } - }) + handleWithCORS("/", s.handleHome) // TODO(ericchiang): rate limit certain paths based on IP. handleWithCORS("/token", s.handleToken) diff --git a/server/templates.go b/server/templates.go index 693f0f1c..a2818b28 100644 --- a/server/templates.go +++ b/server/templates.go @@ -23,6 +23,7 @@ const ( tmplDevice = "device.html" tmplDeviceSuccess = "device_success.html" tmplTOTPVerify = "totp_verify.html" + tmplHome = "home.html" ) var requiredTmpls = []string{ @@ -44,6 +45,7 @@ type templates struct { deviceTmpl *template.Template deviceSuccessTmpl *template.Template totpVerifyTmpl *template.Template + homeTmpl *template.Template } type webConfig struct { @@ -172,6 +174,7 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) { deviceTmpl: tmpls.Lookup(tmplDevice), deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess), totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify), + homeTmpl: tmpls.Lookup(tmplHome), }, nil } @@ -228,6 +231,13 @@ func relativeURL(serverPath, reqPath, assetPath string) string { // Remove common prefix of request path with server path _, req = stripCommonParts(server, req) + // When the request is at the server root (e.g., reqPath == "/dex"), + // the browser treats the last path segment as a file, not a directory. + // Prepend the server path so relative URLs resolve correctly. + if len(req) == 0 && len(server) > 0 { + asset = append(server, asset...) + } + // Remove common prefix of request path with asset path asset, req = stripCommonParts(asset, req) @@ -356,6 +366,26 @@ func (t *templates) totpVerify(r *http.Request, w http.ResponseWriter, postURL, return renderTemplate(w, t.totpVerifyTmpl, data) } +type homeData struct { + LoggedIn bool + Username string + Email string + EmailVerified bool + Groups []string + ConnectorName string + LastLoginEpoch int64 + IPAddress string + UserAgent string + LogoutURL string + DiscoveryURL string + ReqPath string +} + +func (t *templates) home(r *http.Request, w http.ResponseWriter, data homeData) error { + data.ReqPath = r.URL.Path + return renderTemplate(w, t.homeTmpl, data) +} + func (t *templates) oob(r *http.Request, w http.ResponseWriter, code string) error { data := struct { Code string diff --git a/server/templates_test.go b/server/templates_test.go index defb2e5e..726aa843 100644 --- a/server/templates_test.go +++ b/server/templates_test.go @@ -31,6 +31,27 @@ func TestRelativeURL(t *testing.T) { assetPath: "assets/css/main.css", expected: "../assets/css/main.css", }, + { + name: "server-root-page", + serverPath: "/dex", + reqPath: "/dex", + assetPath: "static/main.css", + expected: "dex/static/main.css", + }, + { + name: "server-root-page-nested", + serverPath: "/dex/idp", + reqPath: "/dex/idp", + assetPath: "theme/styles.css", + expected: "dex/idp/theme/styles.css", + }, + { + name: "server-root-no-subpath", + serverPath: "/", + reqPath: "/", + assetPath: "static/main.css", + expected: "static/main.css", + }, { name: "external-url", serverPath: "/dex", diff --git a/web/static/main.css b/web/static/main.css index 47e5c9a6..da1d836e 100644 --- a/web/static/main.css +++ b/web/static/main.css @@ -146,6 +146,85 @@ body { text-align: left; } +.dex-info-table { + margin: 12px auto; + text-align: left; + max-width: 300px; +} + +.dex-info-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #eee; + font-size: 13px; +} + +.dex-info-row:last-child { + border-bottom: none; +} + +.dex-info-label { + color: #888; + font-weight: 500; +} + +.dex-info-value { + color: #333; + text-align: right; + word-break: break-word; + min-width: 0; + max-width: 200px; +} + +.dex-info-details { + color: #333; + font-size: 13px; + min-width: 0; + max-width: 200px; + text-align: right; +} + +.dex-info-details summary { + cursor: pointer; + list-style: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #4A90D9; +} + +.dex-info-details summary:hover { + text-decoration: underline; +} + +.dex-info-details summary::-webkit-details-marker { + display: none; +} + +.dex-info-details summary::after { + content: " \25B8"; + color: #aaa; +} + +.dex-info-details[open] summary::after { + content: " \25BE"; +} + +.dex-info-details[open] > :not(summary) { + text-align: left; +} + +.dex-info-details[open] summary { + margin-bottom: 4px; +} + +.dex-info-details .dex-info-details__list { + margin: 0; + padding-left: 16px; + line-height: 1.8; +} + .dex-error-box { background-color: #e5383b; border-radius: 6px; diff --git a/web/templates/home.html b/web/templates/home.html new file mode 100644 index 00000000..1dca8433 --- /dev/null +++ b/web/templates/home.html @@ -0,0 +1,98 @@ +{{ define "info-value" }} + {{ if gt (len .) 24 }} +
+ {{ . }} +
{{ . }}
+
+ {{ else }} + {{ . }} + {{ end }} +{{ end }} + +{{ template "header.html" . }} + +
+
+ {{ issuer }} +
+ + {{ if .LoggedIn }} +
+
+ {{ if .Username }} +
+ Username + {{ template "info-value" .Username }} +
+ {{ end }} + {{ if .Email }} +
+ Email + {{ .Email }}{{ if .EmailVerified }} ✓{{ end }} +
+ {{ end }} + {{ if .Groups }} +
+ Groups +
+ {{ len .Groups }} group{{ if gt (len .Groups) 1 }}s{{ end }} +
    + {{ range .Groups }}
  • {{ . }}
  • {{ end }} +
+
+
+ {{ end }} + {{ if .ConnectorName }} +
+ Connector + {{ template "info-value" .ConnectorName }} +
+ {{ end }} + {{ if .IPAddress }} +
+ IP address + {{ template "info-value" .IPAddress }} +
+ {{ end }} + {{ if .UserAgent }} +
+ Browser + {{ template "info-value" .UserAgent }} +
+ {{ end }} + {{ if .LastLoginEpoch }} +
+ Last login + +
+ {{ end }} +
+
+ +
+ Logout +
+ {{ else }} +
+
Not logged in
+
+ {{ end }} + +
+ Discovery +
+
+ +{{ if .LastLoginEpoch }} + +{{ end }} + +{{ template "footer.html" . }}