Skip to content

Commit 85425a5

Browse files
committed
refactor(server): add a handler abstraction and extract discovery
Introduce the pattern for decomposing the god Server into per-domain handlers, in the style of Ory Hydra/Kratos: - server/router defines the abstraction: a Mux that registers routes (with the server's prefixing/CORS/header wrapping hidden behind it) and a Handler interface (Mount(Mux)) that a self-contained domain implements to register its own routes. NewServer collects handlers and mounts them, instead of hardcoding every path. - server/discovery is the first such domain: the OIDC discovery document and JWKS move out of Server into a discovery.Handler that holds only a small Config (the issuer, an AbsURL builder, a RenderError callback, the signer, and the supported response/grant/PKCE values) and mounts /keys and /.well-known/openid-configuration itself. The gRPC API calls its Construct method. The discovery document is now built lazily on first request. No behaviour change; the handler is unit-tested in its own package. Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
1 parent 69045b4 commit 85425a5

8 files changed

Lines changed: 301 additions & 144 deletions

File tree

server/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ func (d dexAPI) GetVersion(ctx context.Context, req *api.VersionReq) (*api.Versi
298298
}
299299

300300
func (d dexAPI) GetDiscovery(ctx context.Context, req *api.DiscoveryReq) (*api.DiscoveryResp, error) {
301-
discoveryDoc := d.server.constructDiscovery(ctx)
301+
discoveryDoc := d.server.discovery.Construct(ctx)
302302
data, err := json.Marshal(discoveryDoc)
303303
if err != nil {
304304
return nil, fmt.Errorf("failed to marshal discovery data: %v", err)

server/discovery.go

Lines changed: 0 additions & 132 deletions
This file was deleted.

server/discovery/discovery.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Package discovery serves the OIDC discovery document
2+
// (/.well-known/openid-configuration) and the JWKS endpoint (/keys).
3+
package discovery
4+
5+
import (
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"log/slog"
10+
"net/http"
11+
"sort"
12+
"strconv"
13+
"sync"
14+
"time"
15+
16+
jose "github.com/go-jose/go-jose/v4"
17+
18+
"github.com/dexidp/dex/server/router"
19+
"github.com/dexidp/dex/server/signer"
20+
)
21+
22+
// Config holds the discovery handler's dependencies. AbsURL builds an absolute
23+
// URL under the issuer; RenderError renders an HTML error page. Both are
24+
// supplied by the server so the handler does not depend on the whole Server.
25+
type Config struct {
26+
Issuer string
27+
AbsURL func(...string) string
28+
RenderError func(*http.Request, http.ResponseWriter, int, string)
29+
Signer signer.Signer
30+
Logger *slog.Logger
31+
ResponseTypes map[string]bool
32+
GrantTypes []string
33+
PKCEMethods []string
34+
SessionsEnabled bool
35+
}
36+
37+
// Handler serves the discovery document and the JWKS.
38+
type Handler struct {
39+
Config
40+
41+
docOnce sync.Once
42+
docData []byte
43+
docErr error
44+
}
45+
46+
// New returns a discovery handler.
47+
func New(c Config) *Handler {
48+
return &Handler{Config: c}
49+
}
50+
51+
// Mount registers the discovery routes.
52+
func (h *Handler) Mount(m router.Mux) {
53+
m.HandleCORS("/.well-known/openid-configuration", h.serveDocument)
54+
m.HandleCORS("/keys", h.Keys)
55+
}
56+
57+
// Document is the OIDC discovery document.
58+
type Document struct {
59+
Issuer string `json:"issuer"`
60+
Auth string `json:"authorization_endpoint"`
61+
Token string `json:"token_endpoint"`
62+
Keys string `json:"jwks_uri"`
63+
UserInfo string `json:"userinfo_endpoint"`
64+
DeviceEndpoint string `json:"device_authorization_endpoint"`
65+
Introspect string `json:"introspection_endpoint"`
66+
EndSession string `json:"end_session_endpoint,omitempty"`
67+
GrantTypes []string `json:"grant_types_supported"`
68+
ResponseTypes []string `json:"response_types_supported"`
69+
Subjects []string `json:"subject_types_supported"`
70+
IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
71+
CodeChallengeAlgs []string `json:"code_challenge_methods_supported"`
72+
Scopes []string `json:"scopes_supported"`
73+
AuthMethods []string `json:"token_endpoint_auth_methods_supported"`
74+
Claims []string `json:"claims_supported"`
75+
}
76+
77+
// Keys serves the JSON Web Key Set.
78+
func (h *Handler) Keys(w http.ResponseWriter, r *http.Request) {
79+
ctx := r.Context()
80+
// TODO(ericchiang): Cache this.
81+
keys, err := h.Signer.ValidationKeys(ctx)
82+
if err != nil {
83+
h.Logger.ErrorContext(ctx, "failed to get keys", "err", err)
84+
h.RenderError(r, w, http.StatusInternalServerError, "Internal server error.")
85+
return
86+
}
87+
88+
if len(keys) == 0 {
89+
h.Logger.ErrorContext(ctx, "no public keys found.")
90+
h.RenderError(r, w, http.StatusInternalServerError, "Internal server error.")
91+
return
92+
}
93+
94+
jwks := jose.JSONWebKeySet{
95+
Keys: make([]jose.JSONWebKey, len(keys)),
96+
}
97+
for i, key := range keys {
98+
jwks.Keys[i] = *key
99+
}
100+
101+
data, err := json.MarshalIndent(jwks, "", " ")
102+
if err != nil {
103+
h.Logger.ErrorContext(ctx, "failed to marshal discovery data", "err", err)
104+
h.RenderError(r, w, http.StatusInternalServerError, "Internal server error.")
105+
return
106+
}
107+
108+
// We don't have NextRotation info from Signer interface easily,
109+
// so we'll just set a reasonable default cache time.
110+
maxAge := time.Minute * 10
111+
112+
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, must-revalidate", int(maxAge.Seconds())))
113+
w.Header().Set("Content-Type", "application/json")
114+
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
115+
w.Write(data)
116+
}
117+
118+
// serveDocument serves the discovery document, marshaling it once on first use.
119+
func (h *Handler) serveDocument(w http.ResponseWriter, r *http.Request) {
120+
h.docOnce.Do(func() {
121+
h.docData, h.docErr = json.MarshalIndent(h.Construct(r.Context()), "", " ")
122+
})
123+
if h.docErr != nil {
124+
h.Logger.ErrorContext(r.Context(), "failed to marshal discovery data", "err", h.docErr)
125+
h.RenderError(r, w, http.StatusInternalServerError, "Internal server error.")
126+
return
127+
}
128+
129+
w.Header().Set("Content-Type", "application/json")
130+
w.Header().Set("Content-Length", strconv.Itoa(len(h.docData)))
131+
w.Write(h.docData)
132+
}
133+
134+
// Construct builds the discovery document from the current configuration.
135+
func (h *Handler) Construct(ctx context.Context) Document {
136+
d := Document{
137+
Issuer: h.Issuer,
138+
Auth: h.AbsURL("/auth"),
139+
Token: h.AbsURL("/token"),
140+
Keys: h.AbsURL("/keys"),
141+
UserInfo: h.AbsURL("/userinfo"),
142+
DeviceEndpoint: h.AbsURL("/device/code"),
143+
Introspect: h.AbsURL("/token/introspect"),
144+
Subjects: []string{"public"},
145+
IDTokenAlgs: []string{string(jose.RS256)},
146+
CodeChallengeAlgs: h.PKCEMethods,
147+
Scopes: []string{"openid", "email", "groups", "profile", "offline_access"},
148+
AuthMethods: []string{"client_secret_basic", "client_secret_post"},
149+
Claims: []string{
150+
"iss", "sub", "aud", "iat", "exp", "email", "email_verified",
151+
"locale", "name", "preferred_username", "at_hash",
152+
},
153+
}
154+
155+
// Determine signing algorithm from signer.
156+
signingAlg, err := h.Signer.Algorithm(ctx)
157+
if err != nil {
158+
h.Logger.Error("failed to get signing algorithm", "err", err)
159+
} else {
160+
d.IDTokenAlgs = []string{string(signingAlg)}
161+
}
162+
163+
for responseType := range h.ResponseTypes {
164+
d.ResponseTypes = append(d.ResponseTypes, responseType)
165+
}
166+
sort.Strings(d.ResponseTypes)
167+
168+
d.GrantTypes = h.GrantTypes
169+
170+
if h.SessionsEnabled {
171+
d.EndSession = h.AbsURL("/logout")
172+
}
173+
174+
return d
175+
}

server/discovery/discovery_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package discovery
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"log/slog"
8+
"strings"
9+
"testing"
10+
11+
jose "github.com/go-jose/go-jose/v4"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/dexidp/dex/server/signer"
15+
)
16+
17+
func testHandler(t *testing.T, sessionsEnabled bool) *Handler {
18+
t.Helper()
19+
key, err := rsa.GenerateKey(rand.Reader, 2048)
20+
require.NoError(t, err)
21+
sig, err := signer.NewMockSigner(key)
22+
require.NoError(t, err)
23+
24+
return New(Config{
25+
Issuer: "https://dex.example.com",
26+
AbsURL: func(p ...string) string { return "https://dex.example.com" + strings.Join(p, "") },
27+
Signer: sig,
28+
Logger: slog.New(slog.DiscardHandler),
29+
ResponseTypes: map[string]bool{"id_token": true, "code": true},
30+
GrantTypes: []string{"authorization_code", "refresh_token"},
31+
PKCEMethods: []string{"S256", "plain"},
32+
SessionsEnabled: sessionsEnabled,
33+
})
34+
}
35+
36+
func TestConstruct(t *testing.T) {
37+
doc := testHandler(t, true).Construct(context.Background())
38+
39+
require.Equal(t, "https://dex.example.com", doc.Issuer)
40+
require.Equal(t, "https://dex.example.com/auth", doc.Auth)
41+
require.Equal(t, "https://dex.example.com/token", doc.Token)
42+
require.Equal(t, "https://dex.example.com/keys", doc.Keys)
43+
require.Equal(t, "https://dex.example.com/token/introspect", doc.Introspect)
44+
// Response types are sorted.
45+
require.Equal(t, []string{"code", "id_token"}, doc.ResponseTypes)
46+
require.Equal(t, []string{"authorization_code", "refresh_token"}, doc.GrantTypes)
47+
require.Equal(t, []string{"S256", "plain"}, doc.CodeChallengeAlgs)
48+
require.Equal(t, []string{string(jose.RS256)}, doc.IDTokenAlgs)
49+
// end_session_endpoint is present only when sessions are enabled.
50+
require.Equal(t, "https://dex.example.com/logout", doc.EndSession)
51+
}
52+
53+
func TestConstructNoSessions(t *testing.T) {
54+
doc := testHandler(t, false).Construct(context.Background())
55+
require.Empty(t, doc.EndSession)
56+
}

0 commit comments

Comments
 (0)