@@ -6,6 +6,7 @@ import (
8 "net/http/httptest"
8 "net/http/httptest"
11 middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
12 middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
@@ -17,9 +18,74 @@ import (
17 . "github.com/onsi/gomega"
18 . "github.com/onsi/gomega"
21+type testLock struct {
28+func (l *testLock) Obtain(_ context.Context, _ time.Duration) error {
30+ if l.obtainAttempts < l.obtainOnAttempt {
31+ return sessionsapi.ErrLockNotObtained
33+ if l.obtainError != nil {
40+func (l *testLock) Peek(_ context.Context) (bool, error) {
44+func (l *testLock) Refresh(_ context.Context, _ time.Duration) error {
48+func (l *testLock) Release(_ context.Context) error {
53+type testLockConcurrent struct {
58+func (l *testLockConcurrent) Obtain(_ context.Context, _ time.Duration) error {
62+ return sessionsapi.ErrLockNotObtained
68+func (l *testLockConcurrent) Peek(_ context.Context) (bool, error) {
74+func (l *testLockConcurrent) Refresh(_ context.Context, _ time.Duration) error {
78+func (l *testLockConcurrent) Release(_ context.Context) error {
20var _ = Describe("Stored Session Suite", func() {
85var _ = Describe("Stored Session Suite", func() {
22 refresh = "Refresh"
87 refresh = "Refresh"
88+ refreshed = "Refreshed"
23 noRefresh = "NoRefresh"
89 noRefresh = "NoRefresh"
24 notImplemented = "NotImplemented"
90 notImplemented = "NotImplemented"
@@ -34,7 +100,7 @@ var _ = Describe("Stored Session Suite", func() {
34 var defaultRefreshFunc = func(_ context.Context, ss *sessionsapi.SessionState) (bool, error) {
100 var defaultRefreshFunc = func(_ context.Context, ss *sessionsapi.SessionState) (bool, error) {
35 switch ss.RefreshToken {
101 switch ss.RefreshToken {
36 case refresh:
102 case refresh:
37− ss.RefreshToken = "Refreshed"
103+ ss.RefreshToken = refreshed
38 return true, nil
104 return true, nil
39 case noRefresh:
105 case noRefresh:
40 return false, nil
106 return false, nil
@@ -181,6 +247,7 @@ var _ = Describe("Stored Session Suite", func() {
181 RefreshToken: noRefresh,
247 RefreshToken: noRefresh,
182 CreatedAt: &createdPast,
248 CreatedAt: &createdPast,
183 ExpiresOn: &createdFuture,
249 ExpiresOn: &createdFuture,
250+ Lock: &sessionsapi.NoOpLock{},
185 store: defaultSessionStore,
252 store: defaultSessionStore,
186 refreshPeriod: 1 * time.Minute,
253 refreshPeriod: 1 * time.Minute,
@@ -222,6 +289,7 @@ var _ = Describe("Stored Session Suite", func() {
222 RefreshToken: "Refreshed",
289 RefreshToken: "Refreshed",
223 CreatedAt: &now,
290 CreatedAt: &now,
224 ExpiresOn: &createdFuture,
291 ExpiresOn: &createdFuture,
292+ Lock: &sessionsapi.NoOpLock{},
226 store: defaultSessionStore,
294 store: defaultSessionStore,
227 refreshPeriod: 1 * time.Minute,
295 refreshPeriod: 1 * time.Minute,
@@ -237,6 +305,7 @@ var _ = Describe("Stored Session Suite", func() {
237 RefreshToken: "RefreshError",
305 RefreshToken: "RefreshError",
238 CreatedAt: &createdPast,
306 CreatedAt: &createdPast,
239 ExpiresOn: &createdFuture,
307 ExpiresOn: &createdFuture,
308+ Lock: &sessionsapi.NoOpLock{},
241 store: defaultSessionStore,
310 store: defaultSessionStore,
242 refreshPeriod: 1 * time.Minute,
311 refreshPeriod: 1 * time.Minute,
@@ -266,15 +335,116 @@ var _ = Describe("Stored Session Suite", func() {
266 validateSession: defaultValidateFunc,
335 validateSession: defaultValidateFunc,
339+ type storedSessionLoaderConcurrentTableInput struct {
340+ existingSession *sessionsapi.SessionState
341+ refreshPeriod time.Duration
345+ DescribeTable("when serving concurrent requests",
346+ func(in storedSessionLoaderConcurrentTableInput) {
347+ lockConc := &testLockConcurrent{}
349+ lock := &sync.RWMutex{}
350+ existingSession := *in.existingSession // deep copy existingSession state
351+ existingSession.Lock = lockConc
352+ store := &fakeSessionStore{
353+ LoadFunc: func(req *http.Request) (*sessionsapi.SessionState, error) {
355+ defer lock.RUnlock()
356+ session := existingSession
357+ return &session, nil
359+ SaveFunc: func(_ http.ResponseWriter, _ *http.Request, session *sessionsapi.SessionState) error {
362+ existingSession = *session
367+ refreshedChan := make(chan bool, in.numConcReqs)
368+ for i := 0; i < in.numConcReqs; i++ {
369+ go func(refreshedChan chan bool, lockConc sessionsapi.Lock) {
370+ scope := &middlewareapi.RequestScope{
374+ // Set up the request with the request header and a request scope
375+ req := httptest.NewRequest("", "/", nil)
376+ req = middlewareapi.AddRequestScope(req, scope)
378+ rw := httptest.NewRecorder()
380+ sessionRefreshed := false
381+ opts := &StoredSessionLoaderOptions{
382+ SessionStore: store,
383+ RefreshPeriod: in.refreshPeriod,
384+ RefreshSession: func(ctx context.Context, s *sessionsapi.SessionState) (bool, error) {
385+ time.Sleep(10 * time.Millisecond)
386+ sessionRefreshed = true
389+ ValidateSession: func(context.Context, *sessionsapi.SessionState) bool {
394+ handler := NewStoredSessionLoader(opts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
395+ handler.ServeHTTP(rw, req)
397+ refreshedChan <- sessionRefreshed
398+ }(refreshedChan, lockConc)
400+ var refreshedSlice []bool
401+ for i := 0; i < in.numConcReqs; i++ {
402+ refreshedSlice = append(refreshedSlice, <-refreshedChan)
404+ sessionRefreshedCount := 0
405+ for _, sessionRefreshed := range refreshedSlice {
406+ if sessionRefreshed {
407+ sessionRefreshedCount++
410+ Expect(sessionRefreshedCount).To(Equal(1))
412+ Entry("with two concurrent requests", storedSessionLoaderConcurrentTableInput{
413+ existingSession: &sessionsapi.SessionState{
414+ RefreshToken: refresh,
415+ CreatedAt: &createdPast,
418+ refreshPeriod: 1 * time.Minute,
420+ Entry("with 5 concurrent requests", storedSessionLoaderConcurrentTableInput{
421+ existingSession: &sessionsapi.SessionState{
422+ RefreshToken: refresh,
423+ CreatedAt: &createdPast,
426+ refreshPeriod: 1 * time.Minute,
428+ Entry("with one request", storedSessionLoaderConcurrentTableInput{
429+ existingSession: &sessionsapi.SessionState{
430+ RefreshToken: refresh,
431+ CreatedAt: &createdPast,
434+ refreshPeriod: 1 * time.Minute,
271 Context("refreshSessionIfNeeded", func() {
439 Context("refreshSessionIfNeeded", func() {
272 type refreshSessionIfNeededTableInput struct {
440 type refreshSessionIfNeededTableInput struct {
273− refreshPeriod time.Duration
441+ refreshPeriod time.Duration
274− session *sessionsapi.SessionState
442+ session *sessionsapi.SessionState
443+ concurrentSessionRefresh bool
275− expectedErr error
444+ expectedErr error
276− expectRefreshed bool
445+ expectRefreshed bool
277− expectValidated bool
446+ expectValidated bool
447+ expectedLockObtained bool
280 createdPast := time.Now().Add(-5 * time.Minute)
450 createdPast := time.Now().Add(-5 * time.Minute)
@@ -285,9 +455,28 @@ var _ = Describe("Stored Session Suite", func() {
285 refreshed := false
455 refreshed := false
286 validated := false
456 validated := false
458+ session := &sessionsapi.SessionState{}
459+ *session = *in.session
460+ if in.concurrentSessionRefresh {
461+ // Update the session that Load returns.
462+ // This simulates a concurrent refresh in the background.
463+ session.CreatedAt = &createdFuture
465+ store := &fakeSessionStore{
466+ LoadFunc: func(req *http.Request) (*sessionsapi.SessionState, error) {
467+ // Loading the session from the provider creates a new lock
468+ session.Lock = &testLock{}
471+ SaveFunc: func(_ http.ResponseWriter, _ *http.Request, s *sessionsapi.SessionState) error {
288 s := &storedSessionLoader{
477 s := &storedSessionLoader{
289 refreshPeriod: in.refreshPeriod,
478 refreshPeriod: in.refreshPeriod,
290− store: &fakeSessionStore{},
479+ store: store,
291 sessionRefresher: func(_ context.Context, ss *sessionsapi.SessionState) (bool, error) {
480 sessionRefresher: func(_ context.Context, ss *sessionsapi.SessionState) (bool, error) {
292 refreshed = true
481 refreshed = true
293 switch ss.RefreshToken {
482 switch ss.RefreshToken {
@@ -316,89 +505,129 @@ var _ = Describe("Stored Session Suite", func() {
317 Expect(refreshed).To(Equal(in.expectRefreshed))
506 Expect(refreshed).To(Equal(in.expectRefreshed))
318 Expect(validated).To(Equal(in.expectValidated))
507 Expect(validated).To(Equal(in.expectValidated))
508+ testLock, ok := in.session.Lock.(*testLock)
509+ Expect(ok).To(Equal(true))
511+ if in.expectedLockObtained {
512+ Expect(testLock.obtainAttempts).Should(BeNumerically(">", 0), "Expected at least one attempt at obtaining the session lock")
514+ Expect(testLock.locked).To(BeFalse(), "Expected lock should always be released")
320 Entry("when the refresh period is 0, and the session does not need refreshing", refreshSessionIfNeededTableInput{
516 Entry("when the refresh period is 0, and the session does not need refreshing", refreshSessionIfNeededTableInput{
321 refreshPeriod: time.Duration(0),
517 refreshPeriod: time.Duration(0),
322 session: &sessionsapi.SessionState{
518 session: &sessionsapi.SessionState{
323 RefreshToken: refresh,
519 RefreshToken: refresh,
324 CreatedAt: &createdFuture,
520 CreatedAt: &createdFuture,
326− expectedErr: nil,
523+ expectedErr: nil,
327− expectRefreshed: false,
524+ expectRefreshed: false,
328− expectValidated: false,
525+ expectValidated: false,
526+ expectedLockObtained: false,
330 Entry("when the refresh period is 0, and the session needs refreshing", refreshSessionIfNeededTableInput{
528 Entry("when the refresh period is 0, and the session needs refreshing", refreshSessionIfNeededTableInput{
331 refreshPeriod: time.Duration(0),
529 refreshPeriod: time.Duration(0),
332 session: &sessionsapi.SessionState{
530 session: &sessionsapi.SessionState{
333 RefreshToken: refresh,
531 RefreshToken: refresh,
334 CreatedAt: &createdPast,
532 CreatedAt: &createdPast,
336− expectedErr: nil,
535+ expectedErr: nil,
337− expectRefreshed: false,
536+ expectRefreshed: false,
338− expectValidated: false,
537+ expectValidated: false,
538+ expectedLockObtained: false,
340 Entry("when the session does not need refreshing", refreshSessionIfNeededTableInput{
540 Entry("when the session does not need refreshing", refreshSessionIfNeededTableInput{
341 refreshPeriod: 1 * time.Minute,
541 refreshPeriod: 1 * time.Minute,
342 session: &sessionsapi.SessionState{
542 session: &sessionsapi.SessionState{
343 RefreshToken: refresh,
543 RefreshToken: refresh,
344 CreatedAt: &createdFuture,
544 CreatedAt: &createdFuture,
346− expectedErr: nil,
547+ expectedErr: nil,
347− expectRefreshed: false,
548+ expectRefreshed: false,
348− expectValidated: false,
549+ expectValidated: false,
550+ expectedLockObtained: false,
350 Entry("when the session is refreshed by the provider", refreshSessionIfNeededTableInput{
552 Entry("when the session is refreshed by the provider", refreshSessionIfNeededTableInput{
351 refreshPeriod: 1 * time.Minute,
553 refreshPeriod: 1 * time.Minute,
352 session: &sessionsapi.SessionState{
554 session: &sessionsapi.SessionState{
353 RefreshToken: refresh,
555 RefreshToken: refresh,
354 CreatedAt: &createdPast,
556 CreatedAt: &createdPast,
356− expectedErr: nil,
559+ expectedErr: nil,
357− expectRefreshed: true,
560+ expectRefreshed: true,
358− expectValidated: true,
561+ expectValidated: true,
562+ expectedLockObtained: true,
360− Entry("when the session is not refreshed by the provider", refreshSessionIfNeededTableInput{
564+ Entry("when obtaining lock failed, but concurrent request refreshed", refreshSessionIfNeededTableInput{
361 refreshPeriod: 1 * time.Minute,
565 refreshPeriod: 1 * time.Minute,
362 session: &sessionsapi.SessionState{
566 session: &sessionsapi.SessionState{
363 RefreshToken: noRefresh,
567 RefreshToken: noRefresh,
364 CreatedAt: &createdPast,
568 CreatedAt: &createdPast,
365− ExpiresOn: &createdFuture,
569+ Lock: &testLock{
573+ concurrentSessionRefresh: true,
367− expectedErr: nil,
574+ expectedErr: nil,
368− expectRefreshed: true,
575+ expectRefreshed: false,
369− expectValidated: true,
576+ expectValidated: false,
577+ expectedLockObtained: true,
371− Entry("when the provider doesn't implement refresh but validation succeeds", refreshSessionIfNeededTableInput{
579+ Entry("when obtaining lock failed with a valid session", refreshSessionIfNeededTableInput{
372 refreshPeriod: 1 * time.Minute,
580 refreshPeriod: 1 * time.Minute,
373 session: &sessionsapi.SessionState{
581 session: &sessionsapi.SessionState{
374− RefreshToken: notImplemented,
582+ RefreshToken: noRefresh,
583+ CreatedAt: &createdPast,
585+ obtainError: sessionsapi.ErrLockNotObtained,
588+ expectedErr: errors.New("timeout obtaining session lock"),
589+ expectRefreshed: false,
590+ expectValidated: false,
591+ expectedLockObtained: true,
593+ Entry("when the session is not refreshed by the provider", refreshSessionIfNeededTableInput{
594+ refreshPeriod: 1 * time.Minute,
595+ session: &sessionsapi.SessionState{
596+ RefreshToken: noRefresh,
375 CreatedAt: &createdPast,
597 CreatedAt: &createdPast,
598+ ExpiresOn: &createdFuture,
377− expectedErr: nil,
601+ expectedErr: nil,
378− expectRefreshed: true,
602+ expectRefreshed: true,
379− expectValidated: true,
603+ expectValidated: true,
604+ expectedLockObtained: true,
381− Entry("when the provider refresh fails but validation succeeds", refreshSessionIfNeededTableInput{
606+ Entry("when the provider doesn't implement refresh", refreshSessionIfNeededTableInput{
382 refreshPeriod: 1 * time.Minute,
607 refreshPeriod: 1 * time.Minute,
383 session: &sessionsapi.SessionState{
608 session: &sessionsapi.SessionState{
384− RefreshToken: "RefreshError",
609+ RefreshToken: notImplemented,
385 CreatedAt: &createdPast,
610 CreatedAt: &createdPast,
387− expectedErr: nil,
613+ expectedErr: nil,
388− expectRefreshed: true,
614+ expectRefreshed: true,
389− expectValidated: true,
615+ expectValidated: true,
616+ expectedLockObtained: true,
391− Entry("when the session is not refreshed by the provider and validation fails", refreshSessionIfNeededTableInput{
618+ Entry("when the session is not refreshed by the provider", refreshSessionIfNeededTableInput{
392 refreshPeriod: 1 * time.Minute,
619 refreshPeriod: 1 * time.Minute,
393 session: &sessionsapi.SessionState{
620 session: &sessionsapi.SessionState{
394 AccessToken: "Invalid",
621 AccessToken: "Invalid",
395 RefreshToken: noRefresh,
622 RefreshToken: noRefresh,
396 CreatedAt: &createdPast,
623 CreatedAt: &createdPast,
397 ExpiresOn: &createdFuture,
624 ExpiresOn: &createdFuture,
399− expectedErr: errors.New("session is invalid"),
627+ expectedErr: errors.New("session is invalid"),
400− expectRefreshed: true,
628+ expectRefreshed: true,
401− expectValidated: true,
629+ expectValidated: true,
630+ expectedLockObtained: true,