@@ -2,12 +2,14 @@ package externalauth_test
22
33import (
44 "context"
5+ "database/sql"
56 "encoding/json"
67 "fmt"
78 "net/http"
89 "net/http/httptest"
910 "net/url"
1011 "strings"
12+ "sync/atomic"
1113 "testing"
1214 "time"
1315
@@ -26,6 +28,7 @@ import (
2628 "github.com/coder/coder/v2/coderd/database"
2729 "github.com/coder/coder/v2/coderd/database/dbmock"
2830 "github.com/coder/coder/v2/coderd/database/dbtestutil"
31+ "github.com/coder/coder/v2/coderd/database/dbtime"
2932 "github.com/coder/coder/v2/coderd/externalauth"
3033 "github.com/coder/coder/v2/coderd/promoauth"
3134 "github.com/coder/coder/v2/codersdk"
@@ -379,6 +382,148 @@ func TestRefreshToken(t *testing.T) {
379382 require .True (t , ok )
380383 require .Equal (t , updated .OAuthAccessToken , mapping ["access_token" ])
381384 })
385+
386+ // SaveBeforeValidate tests that a successfully refreshed token is
387+ // persisted to the DB even when post-refresh validation fails. This
388+ // prevents the data-loss scenario where GitHub rotates the refresh
389+ // token on use but the new token is silently discarded because a
390+ // rate-limited validation endpoint returns 403.
391+ t .Run ("SaveBeforeValidate" , func (t * testing.T ) {
392+ t .Parallel ()
393+
394+ db , _ := dbtestutil .NewDB (t )
395+
396+ // simulateRateLimit controls whether the validate endpoint
397+ // returns 403 (true) or 200 (false).
398+ var simulateRateLimit atomic.Bool
399+ simulateRateLimit .Store (true )
400+
401+ var refreshCalls atomic.Int64
402+ fake , config , link := setupOauth2Test (t , testConfig {
403+ FakeIDPOpts : []oidctest.FakeIDPOpt {
404+ oidctest .WithRefresh (func (_ string ) error {
405+ refreshCalls .Add (1 )
406+ return nil
407+ }),
408+ oidctest .WithDynamicUserInfo (func (_ string ) (jwt.MapClaims , error ) {
409+ if simulateRateLimit .Load () {
410+ return jwt.MapClaims {}, oidctest .StatusError (http .StatusForbidden , xerrors .New ("rate limit exceeded" ))
411+ }
412+ return jwt.MapClaims {}, nil
413+ }),
414+ },
415+ ExternalAuthOpt : func (cfg * externalauth.Config ) {
416+ cfg .Type = codersdk .EnhancedExternalAuthProviderGitHub .String ()
417+ },
418+ DB : db ,
419+ })
420+
421+ ctx := oidc .ClientContext (context .Background (), fake .HTTPClient (nil ))
422+
423+ oldAccessToken := link .OAuthAccessToken
424+ oldRefreshToken := link .OAuthRefreshToken
425+
426+ // Expire the token to force a refresh.
427+ link .OAuthExpiry = expired
428+
429+ // --- First call: refresh succeeds, validation fails (403). ---
430+ _ , err := config .RefreshToken (ctx , db , link )
431+ require .Error (t , err , "expected error because validation returned 403" )
432+ require .True (t , externalauth .IsInvalidTokenError (err ))
433+ require .Equal (t , int64 (1 ), refreshCalls .Load (), "IDP refresh should have been called exactly once" )
434+
435+ // Critical assertion: the DB must contain the NEW tokens from the
436+ // successful refresh, not the old (now-stale) ones.
437+ dbLink , err := db .GetExternalAuthLink (context .Background (), database.GetExternalAuthLinkParams {
438+ ProviderID : link .ProviderID ,
439+ UserID : link .UserID ,
440+ })
441+ require .NoError (t , err )
442+ require .NotEqual (t , oldAccessToken , dbLink .OAuthAccessToken ,
443+ "DB should have the new access token from the successful refresh" )
444+ require .NotEqual (t , oldRefreshToken , dbLink .OAuthRefreshToken ,
445+ "DB should have the new refresh token (old one was rotated by the IDP)" )
446+
447+ // --- Second call: uses the saved token from DB, no re-refresh. ---
448+ // The saved token has a future expiry, so TokenSource should return
449+ // it without contacting the IDP. Validation should succeed now.
450+ simulateRateLimit .Store (false )
451+ updated , err := config .RefreshToken (ctx , db , dbLink )
452+ require .NoError (t , err , "second call should succeed because rate limit lifted" )
453+ require .Equal (t , int64 (1 ), refreshCalls .Load (),
454+ "IDP refresh should NOT have been called again; the saved token is not expired" )
455+ require .Equal (t , dbLink .OAuthAccessToken , updated .OAuthAccessToken ,
456+ "returned token should match what was saved in the DB" )
457+ })
458+
459+ // ConcurrentRefreshOptimisticLock verifies that when caller A saves
460+ // a successfully refreshed token (early save), caller B's subsequent
461+ // attempt to destroy the refresh token via
462+ // UpdateExternalAuthLinkRefreshToken is a no-op. The optimistic lock
463+ // (WHERE oauth_refresh_token = @old) prevents B from overwriting
464+ // A's valid token.
465+ t .Run ("ConcurrentRefreshOptimisticLock" , func (t * testing.T ) {
466+ t .Parallel ()
467+
468+ db , _ := dbtestutil .NewDB (t )
469+
470+ fake , config , link := setupOauth2Test (t , testConfig {
471+ FakeIDPOpts : []oidctest.FakeIDPOpt {
472+ oidctest .WithRefresh (func (_ string ) error {
473+ return nil
474+ }),
475+ oidctest .WithDynamicUserInfo (func (_ string ) (jwt.MapClaims , error ) {
476+ return jwt.MapClaims {}, nil
477+ }),
478+ },
479+ ExternalAuthOpt : func (cfg * externalauth.Config ) {
480+ cfg .Type = codersdk .EnhancedExternalAuthProviderGitHub .String ()
481+ },
482+ DB : db ,
483+ })
484+
485+ ctx := oidc .ClientContext (context .Background (), fake .HTTPClient (nil ))
486+
487+ // Snapshot the original tokens before any refresh.
488+ oldRefreshToken := link .OAuthRefreshToken
489+
490+ // Expire the token to force a refresh.
491+ link .OAuthExpiry = expired
492+
493+ // Caller A: refresh and save successfully.
494+ updated , err := config .RefreshToken (ctx , db , link )
495+ require .NoError (t , err )
496+ require .NotEqual (t , oldRefreshToken , updated .OAuthRefreshToken ,
497+ "caller A should have a new refresh token" )
498+
499+ // Caller B had a stale read of the original link. It tries to
500+ // destroy the refresh token using the OLD refresh token in the
501+ // optimistic lock. Because caller A already wrote a different
502+ // refresh token, this WHERE clause matches nothing.
503+ err = db .UpdateExternalAuthLinkRefreshToken (ctx , database.UpdateExternalAuthLinkRefreshTokenParams {
504+ OauthRefreshFailureReason : "simulated failure from stale caller B" ,
505+ OAuthRefreshToken : "" ,
506+ OAuthRefreshTokenKeyID : sql.NullString {}.String ,
507+ UpdatedAt : dbtime .Now (),
508+ ProviderID : link .ProviderID ,
509+ UserID : link .UserID ,
510+ OldOauthRefreshToken : oldRefreshToken ,
511+ })
512+ require .NoError (t , err , "optimistic lock write should not error, it is a no-op" )
513+
514+ // Verify DB still has caller A's valid token.
515+ dbLink , err := db .GetExternalAuthLink (context .Background (), database.GetExternalAuthLinkParams {
516+ ProviderID : link .ProviderID ,
517+ UserID : link .UserID ,
518+ })
519+ require .NoError (t , err )
520+ require .Equal (t , updated .OAuthAccessToken , dbLink .OAuthAccessToken ,
521+ "caller A's access token should still be in DB" )
522+ require .Equal (t , updated .OAuthRefreshToken , dbLink .OAuthRefreshToken ,
523+ "caller A's refresh token should still be in DB" )
524+ require .Empty (t , dbLink .OauthRefreshFailureReason ,
525+ "caller B's failure reason should not have been written" )
526+ })
382527}
383528
384529func TestRevokeToken (t * testing.T ) {
0 commit comments