Skip to content

Commit fa47e46

Browse files
fix(webserver): bound OIDC provider calls with an explicit timeout
Discovery ran under the service mutex with no deadline. A provider that accepted the connection but never answered would therefore hold a login open indefinitely, and — because discovery is serialized — park every concurrent login behind the lock. Bound discovery, token exchange, and ID token verification. The timeout lives on the service and is carried by its HTTP client rather than by a context alone: go-oidc refreshes the key set on its own background context, where a deadline of ours would never apply, so only a client timeout covers that path. Verified against go-oidc v3.19.0, whose Verifier() documents the background context and propagates the provider's client to the key set. Add a regression test that parks a provider mid-request and asserts the call fails rather than hangs. It passes a context with no deadline on purpose: a test context with one would pass against the unbounded implementation too, and prove nothing. Confirmed the test fails ("discovery is not bounded") when the bound is removed. Also document four functions flagged by review coverage.
1 parent 3a455b6 commit fa47e46

4 files changed

Lines changed: 118 additions & 6 deletions

File tree

internal/webserver/cli_config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func SaveCLIConfig(dbPath string, cfg CLIConfig) error {
106106
return nil
107107
}
108108

109+
// cliConfigPath returns the web config path colocated with dbPath.
109110
func cliConfigPath(dbPath string) string {
110111
return filepath.Join(filepath.Dir(strings.TrimSpace(dbPath)), cliConfigFileName)
111112
}

internal/webserver/oidc.go

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
"net/http"
1011
"strings"
1112
"sync"
1213
"time"
@@ -26,6 +27,11 @@ const (
2627
// oidcFlowLimit caps in-flight authorization requests so unfinished flows
2728
// cannot grow memory without bound.
2829
oidcFlowLimit = 64
30+
// oidcHTTPTimeout bounds every call to the provider: discovery, token
31+
// exchange, and background JWKS refreshes. Without it a hung provider would
32+
// hold a login request open indefinitely, and — because discovery runs under
33+
// the service mutex — stall every other login behind it.
34+
oidcHTTPTimeout = 10 * time.Second
2935
)
3036

3137
// errOIDCDisabled is returned when an OIDC route is reached while OIDC is off.
@@ -49,6 +55,12 @@ type oidcService struct {
4955
cfg OIDCConfig
5056
logf func(string, ...any)
5157

58+
// httpTimeout bounds each individual call to the provider. The HTTP client
59+
// carries the same bound, because go-oidc refreshes the key set on its own
60+
// background context, where a deadline of ours would never apply.
61+
httpTimeout time.Duration
62+
httpClient *http.Client
63+
5264
mu sync.Mutex
5365
provider *oidc.Provider
5466
verifier *oidc.IDTokenVerifier
@@ -66,9 +78,11 @@ func newOIDCService(cfg OIDCConfig, logf func(string, ...any)) *oidcService {
6678
return nil
6779
}
6880
return &oidcService{
69-
cfg: cfg,
70-
logf: logf,
71-
flows: make(map[string]oidcFlow),
81+
cfg: cfg,
82+
logf: logf,
83+
httpTimeout: oidcHTTPTimeout,
84+
httpClient: &http.Client{Timeout: oidcHTTPTimeout},
85+
flows: make(map[string]oidcFlow),
7286
}
7387
}
7488

@@ -82,8 +96,19 @@ func (s *oidcService) DisableBuiltInLogin() bool {
8296
return s.Enabled() && s.cfg.DisableBuiltInLogin
8397
}
8498

99+
// boundedContext attaches the bounded HTTP client and a deadline, so a provider
100+
// that stalls fails the request instead of holding it open.
101+
func (s *oidcService) boundedContext(ctx context.Context) (context.Context, context.CancelFunc) {
102+
return context.WithTimeout(oidc.ClientContext(ctx, s.httpClient), s.httpTimeout)
103+
}
104+
85105
// ensureProvider performs discovery once and caches the result. It is safe to
86106
// call on every request: after the first success it is a mutex and a nil check.
107+
//
108+
// Discovery runs under the mutex so a burst of logins against a cold service
109+
// performs one discovery rather than one per request. That makes bounding it
110+
// essential: the deadline below is what stops a hung provider from parking
111+
// every concurrent login behind this lock.
87112
func (s *oidcService) ensureProvider(ctx context.Context) error {
88113
if !s.Enabled() {
89114
return errOIDCDisabled
@@ -95,7 +120,10 @@ func (s *oidcService) ensureProvider(ctx context.Context) error {
95120
return nil
96121
}
97122

98-
provider, err := oidc.NewProvider(ctx, s.cfg.Issuer)
123+
discoverCtx, cancel := s.boundedContext(ctx)
124+
defer cancel()
125+
126+
provider, err := oidc.NewProvider(discoverCtx, s.cfg.Issuer)
99127
if err != nil {
100128
return fmt.Errorf("oidc: discover issuer: %s", redaction.RedactValue(err.Error(), nil))
101129
}
@@ -140,6 +168,8 @@ func oidcScopeList(scopes string) []string {
140168
return list
141169
}
142170

171+
// supportsPKCE reports whether the provider advertises S256. Only S256 counts:
172+
// the "plain" method offers no protection worth the name.
143173
func supportsPKCE(methods []string) bool {
144174
for _, method := range methods {
145175
if strings.EqualFold(strings.TrimSpace(method), "S256") {
@@ -206,7 +236,12 @@ func (s *oidcService) Exchange(ctx context.Context, state string, code string) (
206236
opts = append(opts, oauth2.VerifierOption(flow.CodeVerifier))
207237
}
208238

209-
token, err := oauthCfg.Exchange(ctx, code, opts...)
239+
// Bounded like discovery: a provider that stalls mid-exchange must fail the
240+
// login, not hold the callback open.
241+
exchangeCtx, cancel := s.boundedContext(ctx)
242+
defer cancel()
243+
244+
token, err := oauthCfg.Exchange(exchangeCtx, code, opts...)
210245
if err != nil {
211246
return nil, fmt.Errorf("oidc: exchange authorization code: %s", redaction.RedactValue(err.Error(), nil))
212247
}
@@ -216,7 +251,12 @@ func (s *oidcService) Exchange(ctx context.Context, state string, code string) (
216251
return nil, errors.New("oidc: provider response did not include an id_token")
217252
}
218253

219-
idToken, err := verifier.Verify(ctx, rawIDToken)
254+
// Verification can trigger a JWKS fetch when the provider has rotated keys,
255+
// so it gets its own deadline rather than the exchange's remaining budget.
256+
verifyCtx, cancelVerify := s.boundedContext(ctx)
257+
defer cancelVerify()
258+
259+
idToken, err := verifier.Verify(verifyCtx, rawIDToken)
220260
if err != nil {
221261
return nil, fmt.Errorf("oidc: verify id token: %s", redaction.RedactValue(err.Error(), nil))
222262
}
@@ -251,6 +291,9 @@ func (c *oidcClaims) Username() string {
251291
return ""
252292
}
253293

294+
// storeFlow records a pending authorization request, evicting expired entries
295+
// and — once at the cap — the entry closest to expiry, so abandoned logins
296+
// cannot grow the map without bound.
254297
func (s *oidcService) storeFlow(state string, flow oidcFlow) {
255298
s.flowMu.Lock()
256299
defer s.flowMu.Unlock()

internal/webserver/oidc_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package webserver
55

66
import (
7+
"context"
78
"crypto/rand"
89
"crypto/rsa"
910
"encoding/json"
@@ -501,6 +502,71 @@ func TestOIDCRoutesAreAbsentWhenDisabled(t *testing.T) {
501502
}
502503
}
503504

505+
// TestOIDCDiscoveryIsBounded covers a provider that accepts the connection and
506+
// then never answers. Discovery runs under the service mutex, so an unbounded
507+
// call here would not just hang this login — it would park every concurrent one
508+
// behind the lock. The request must fail instead of hanging.
509+
func TestOIDCDiscoveryIsBounded(t *testing.T) {
510+
blocked := make(chan struct{})
511+
512+
// Hangs until the test ends: it accepts, then never writes a response.
513+
hung := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
514+
select {
515+
case <-blocked:
516+
case <-r.Context().Done():
517+
}
518+
}))
519+
// Release the handler BEFORE closing the server. httptest.Server.Close waits
520+
// for in-flight requests, so closing first would deadlock against the very
521+
// handler this test is keeping parked.
522+
t.Cleanup(func() {
523+
close(blocked)
524+
hung.Close()
525+
})
526+
527+
srv := newAuthTestServer(t, filepath.Join(t.TempDir(), "db.sqlite"))
528+
srv.oidc = newOIDCService(OIDCConfig{
529+
Enabled: true,
530+
Issuer: hung.URL,
531+
ClientID: "test-client-id",
532+
ClientSecret: "test-client-secret",
533+
RedirectURL: "https://upbrr.example.test/api/auth/oidc/callback",
534+
Scopes: DefaultOIDCScopes,
535+
}, func(string, ...any) {})
536+
537+
// Shorten the service's own bound so the test does not sit for the full
538+
// production timeout.
539+
srv.oidc.httpTimeout = 500 * time.Millisecond
540+
srv.oidc.httpClient = &http.Client{Timeout: 500 * time.Millisecond}
541+
542+
// Deliberately a context with NO deadline. If the service did not impose its
543+
// own bound, nothing here would ever cancel the call — which is exactly the
544+
// failure this test exists to catch. A test context with a deadline would
545+
// pass even against the unbounded implementation, proving nothing.
546+
done := make(chan error, 1)
547+
go func() {
548+
_, _, err := srv.oidc.AuthCodeURL(context.Background())
549+
done <- err
550+
}()
551+
552+
select {
553+
case err := <-done:
554+
if err == nil {
555+
t.Fatal("AuthCodeURL succeeded against a provider that never responds")
556+
}
557+
case <-time.After(10 * time.Second):
558+
t.Fatal("AuthCodeURL hung: discovery is not bounded")
559+
}
560+
561+
// The failure must not leave a half-built provider cached.
562+
srv.oidc.mu.Lock()
563+
cached := srv.oidc.provider != nil
564+
srv.oidc.mu.Unlock()
565+
if cached {
566+
t.Fatal("a failed discovery cached a provider")
567+
}
568+
}
569+
504570
func TestOIDCScopeListAlwaysRequestsOpenID(t *testing.T) {
505571
tests := []struct {
506572
name string

internal/webserver/routes_oidc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ func (s *Server) writeOIDCStateCookie(w http.ResponseWriter, r *http.Request, st
218218
})
219219
}
220220

221+
// clearOIDCStateCookie removes the state cookie once a flow has completed or
222+
// failed, so a stale state cannot linger in the browser.
221223
func (s *Server) clearOIDCStateCookie(w http.ResponseWriter, r *http.Request) {
222224
//nolint:gosec // State clear cookie sets HttpOnly, SameSite, and Secure for HTTPS requests.
223225
http.SetCookie(w, &http.Cookie{

0 commit comments

Comments
 (0)