@@ -10,6 +10,8 @@ import (
1010 "net/http"
1111 "net/http/httptest"
1212 "net/http/httputil"
13+ "net/url"
14+ "slices"
1315 "strings"
1416 "testing"
1517
@@ -738,6 +740,130 @@ func TestApplicationTypeInference(t *testing.T) {
738740 }
739741}
740742
743+ func TestAuthorize_OfflineAccessScope (t * testing.T ) {
744+ tests := []struct {
745+ name string
746+ requestRefreshToken bool
747+ asScopesSupported []string
748+ challengeScopes string
749+ wantOfflineAccess bool
750+ }{
751+ {
752+ name : "AddedWhenASSupportsAndClientRequests" ,
753+ requestRefreshToken : true ,
754+ asScopesSupported : []string {"openid" , "offline_access" },
755+ wantOfflineAccess : true ,
756+ },
757+ {
758+ name : "NotAddedWhenClientDoesNotRequest" ,
759+ requestRefreshToken : false ,
760+ asScopesSupported : []string {"openid" , "offline_access" },
761+ wantOfflineAccess : false ,
762+ },
763+ {
764+ name : "NotAddedWhenASDoesNotSupport" ,
765+ requestRefreshToken : true ,
766+ asScopesSupported : []string {"openid" },
767+ wantOfflineAccess : false ,
768+ },
769+ {
770+ name : "NotDuplicatedWhenAlreadyInScopes" ,
771+ requestRefreshToken : true ,
772+ asScopesSupported : []string {"openid" , "offline_access" },
773+ challengeScopes : "read offline_access" ,
774+ wantOfflineAccess : true ,
775+ },
776+ }
777+
778+ for _ , tt := range tests {
779+ t .Run (tt .name , func (t * testing.T ) {
780+ authServer := oauthtest .NewFakeAuthorizationServer (oauthtest.Config {
781+ ScopesSupported : tt .asScopesSupported ,
782+ RegistrationConfig : & oauthtest.RegistrationConfig {
783+ PreregisteredClients : map [string ]oauthtest.ClientInfo {
784+ "test_client_id" : {
785+ Secret : "test_client_secret" ,
786+ RedirectURIs : []string {"http://localhost:12345/callback" },
787+ },
788+ },
789+ },
790+ })
791+ authServer .Start (t )
792+
793+ resourceMux := http .NewServeMux ()
794+ resourceServer := httptest .NewServer (resourceMux )
795+ t .Cleanup (resourceServer .Close )
796+ resourceURL := resourceServer .URL + "/resource"
797+ resourceMux .Handle ("/.well-known/oauth-protected-resource/resource" , ProtectedResourceMetadataHandler (& oauthex.ProtectedResourceMetadata {
798+ Resource : resourceURL ,
799+ AuthorizationServers : []string {authServer .URL ()},
800+ }))
801+
802+ var capturedAuthURL string
803+ handler , err := NewAuthorizationCodeHandler (& AuthorizationCodeHandlerConfig {
804+ RedirectURL : "http://localhost:12345/callback" ,
805+ PreregisteredClient : & oauthex.ClientCredentials {
806+ ClientID : "test_client_id" ,
807+ ClientSecretAuth : & oauthex.ClientSecretAuth {
808+ ClientSecret : "test_client_secret" ,
809+ },
810+ },
811+ RequestRefreshToken : tt .requestRefreshToken ,
812+ AuthorizationCodeFetcher : func (ctx context.Context , args * AuthorizationArgs ) (* AuthorizationResult , error ) {
813+ capturedAuthURL = args .URL
814+ return nil , fmt .Errorf ("stop after capturing URL" )
815+ },
816+ })
817+ if err != nil {
818+ t .Fatalf ("NewAuthorizationCodeHandler failed: %v" , err )
819+ }
820+
821+ req := httptest .NewRequest (http .MethodGet , resourceURL , nil )
822+ resp := & http.Response {
823+ StatusCode : http .StatusUnauthorized ,
824+ Header : make (http.Header ),
825+ Body : http .NoBody ,
826+ Request : req ,
827+ }
828+ wwwAuth := "Bearer resource_metadata=" + resourceServer .URL + "/.well-known/oauth-protected-resource/resource"
829+ if tt .challengeScopes != "" {
830+ wwwAuth += fmt .Sprintf (", scope=%q" , tt .challengeScopes )
831+ }
832+ resp .Header .Set ("WWW-Authenticate" , wwwAuth )
833+
834+ // Authorize will fail at the fetcher, but we only care about the URL.
835+ handler .Authorize (context .Background (), req , resp )
836+
837+ if capturedAuthURL == "" {
838+ t .Fatal ("AuthorizationCodeFetcher was not called" )
839+ }
840+ u , err := url .Parse (capturedAuthURL )
841+ if err != nil {
842+ t .Fatalf ("failed to parse captured auth URL: %v" , err )
843+ }
844+ scopes := strings .Fields (u .Query ().Get ("scope" ))
845+ hasOfflineAccess := slices .Contains (scopes , "offline_access" )
846+ if hasOfflineAccess != tt .wantOfflineAccess {
847+ t .Errorf ("offline_access in scopes = %v, want %v (scopes: %v)" , hasOfflineAccess , tt .wantOfflineAccess , scopes )
848+ }
849+
850+ // When offline_access was already present in challenge scopes,
851+ // verify it appears exactly once.
852+ if tt .wantOfflineAccess {
853+ count := 0
854+ for _ , s := range scopes {
855+ if s == "offline_access" {
856+ count ++
857+ }
858+ }
859+ if count != 1 {
860+ t .Errorf ("offline_access appears %d times in scopes, want 1" , count )
861+ }
862+ }
863+ })
864+ }
865+ }
866+
741867// validConfig for test to create an AuthorizationCodeHandler using its constructor.
742868// Values that are relevant to the test should be set explicitly.
743869func validConfig () * AuthorizationCodeHandlerConfig {
0 commit comments