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" . }}
+
+
+
+

+
+
+ {{ 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 }}
+
+
+
+
+ {{ else }}
+
+ {{ end }}
+
+
+
+
+{{ if .LastLoginEpoch }}
+
+{{ end }}
+
+{{ template "footer.html" . }}