feat: add home page with user session info (#4677)

Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
This commit is contained in:
Maksim Nabokikh
2026-03-30 15:33:30 +02:00
committed by GitHub
parent f90a36c390
commit 8031f5b1ca
7 changed files with 490 additions and 14 deletions
+81
View File
@@ -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, `<!DOCTYPE html>
<title>Dex</title>
<h1>Dex IdP</h1>
<h3>A Federated OpenID Connect Provider</h3>
<p><a href=%q>Discovery</a></p>`,
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
}
}
+180
View File
@@ -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")
}
+1 -14
View File
@@ -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, `<!DOCTYPE html>
<title>Dex</title>
<h1>Dex IdP</h1>
<h3>A Federated OpenID Connect Provider</h3>
<p><a href=%q>Discovery</a></p>`,
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)
+30
View File
@@ -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
+21
View File
@@ -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",
+79
View File
@@ -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;
+98
View File
@@ -0,0 +1,98 @@
{{ define "info-value" }}
{{ if gt (len .) 24 }}
<details class="dex-info-details">
<summary>{{ . }}</summary>
<div>{{ . }}</div>
</details>
{{ else }}
<span class="dex-info-value">{{ . }}</span>
{{ end }}
{{ end }}
{{ template "header.html" . }}
<div class="theme-panel">
<div style="text-align: center; margin-bottom: 16px;">
<img src="{{ url .ReqPath logo }}" alt="{{ issuer }}" style="max-height: 36px;">
</div>
{{ if .LoggedIn }}
<hr class="dex-separator">
<div class="dex-info-table">
{{ if .Username }}
<div class="dex-info-row">
<span class="dex-info-label">Username</span>
{{ template "info-value" .Username }}
</div>
{{ end }}
{{ if .Email }}
<div class="dex-info-row">
<span class="dex-info-label">Email</span>
<span class="dex-info-value">{{ .Email }}{{ if .EmailVerified }} &#10003;{{ end }}</span>
</div>
{{ end }}
{{ if .Groups }}
<div class="dex-info-row">
<span class="dex-info-label">Groups</span>
<details class="dex-info-details">
<summary>{{ len .Groups }} group{{ if gt (len .Groups) 1 }}s{{ end }}</summary>
<ul class="dex-info-details__list">
{{ range .Groups }}<li>{{ . }}</li>{{ end }}
</ul>
</details>
</div>
{{ end }}
{{ if .ConnectorName }}
<div class="dex-info-row">
<span class="dex-info-label">Connector</span>
{{ template "info-value" .ConnectorName }}
</div>
{{ end }}
{{ if .IPAddress }}
<div class="dex-info-row">
<span class="dex-info-label">IP address</span>
{{ template "info-value" .IPAddress }}
</div>
{{ end }}
{{ if .UserAgent }}
<div class="dex-info-row">
<span class="dex-info-label">Browser</span>
{{ template "info-value" .UserAgent }}
</div>
{{ end }}
{{ if .LastLoginEpoch }}
<div class="dex-info-row">
<span class="dex-info-label">Last login</span>
<span class="dex-info-value" id="last-login"></span>
</div>
{{ end }}
</div>
<hr class="dex-separator">
<div class="theme-form-row">
<a href="{{ .LogoutURL }}" class="dex-btn theme-btn--primary" style="display: inline-block; text-decoration: none; padding: 8px 16px;">Logout</a>
</div>
{{ else }}
<div>
<div class="dex-subtle-text">Not logged in</div>
</div>
{{ end }}
<div class="theme-form-row">
<a href="{{ .DiscoveryURL }}" class="dex-subtle-text">Discovery</a>
</div>
</div>
{{ if .LastLoginEpoch }}
<script>
(function() {
var epoch = {{ .LastLoginEpoch }};
var el = document.getElementById('last-login');
if (el && epoch) {
el.textContent = new Date(epoch * 1000).toLocaleString();
}
})();
</script>
{{ end }}
{{ template "footer.html" . }}