@@ -21,6 +21,7 @@ import (
2121 "golang.org/x/crypto/bcrypt"
2222 "golang.org/x/oauth2"
2323
24+ "github.com/dexidp/dex/server/internal"
2425 "github.com/dexidp/dex/storage"
2526)
2627
@@ -62,6 +63,7 @@ func TestHandleDiscovery(t *testing.T) {
6263 Introspect : fmt .Sprintf ("%s/token/introspect" , httpServer .URL ),
6364 GrantTypes : []string {
6465 "authorization_code" ,
66+ "client_credentials" ,
6567 "refresh_token" ,
6668 "urn:ietf:params:oauth:grant-type:device_code" ,
6769 "urn:ietf:params:oauth:grant-type:token-exchange" ,
@@ -645,6 +647,176 @@ func TestHandlePasswordLoginWithSkipApproval(t *testing.T) {
645647 }
646648}
647649
650+ func TestHandleClientCredentials (t * testing.T ) {
651+ tests := []struct {
652+ name string
653+ clientID string
654+ clientSecret string
655+ scopes string
656+ wantCode int
657+ wantAccessTok bool
658+ wantIDToken bool
659+ wantUsername string
660+ }{
661+ {
662+ name : "Basic grant, no scopes" ,
663+ clientID : "test" ,
664+ clientSecret : "barfoo" ,
665+ scopes : "" ,
666+ wantCode : 200 ,
667+ wantAccessTok : true ,
668+ wantIDToken : false ,
669+ },
670+ {
671+ name : "With openid scope" ,
672+ clientID : "test" ,
673+ clientSecret : "barfoo" ,
674+ scopes : "openid" ,
675+ wantCode : 200 ,
676+ wantAccessTok : true ,
677+ wantIDToken : true ,
678+ },
679+ {
680+ name : "With openid and profile scope includes username" ,
681+ clientID : "test" ,
682+ clientSecret : "barfoo" ,
683+ scopes : "openid profile" ,
684+ wantCode : 200 ,
685+ wantAccessTok : true ,
686+ wantIDToken : true ,
687+ wantUsername : "Test Client" ,
688+ },
689+ {
690+ name : "With openid email profile groups" ,
691+ clientID : "test" ,
692+ clientSecret : "barfoo" ,
693+ scopes : "openid email profile groups" ,
694+ wantCode : 200 ,
695+ wantAccessTok : true ,
696+ wantIDToken : true ,
697+ wantUsername : "Test Client" ,
698+ },
699+ {
700+ name : "Invalid client secret" ,
701+ clientID : "test" ,
702+ clientSecret : "wrong" ,
703+ scopes : "" ,
704+ wantCode : 401 ,
705+ },
706+ {
707+ name : "Unknown client" ,
708+ clientID : "nonexistent" ,
709+ clientSecret : "secret" ,
710+ scopes : "" ,
711+ wantCode : 401 ,
712+ },
713+ {
714+ name : "offline_access scope rejected" ,
715+ clientID : "test" ,
716+ clientSecret : "barfoo" ,
717+ scopes : "openid offline_access" ,
718+ wantCode : 400 ,
719+ },
720+ {
721+ name : "Unrecognized scope" ,
722+ clientID : "test" ,
723+ clientSecret : "barfoo" ,
724+ scopes : "openid bogus" ,
725+ wantCode : 400 ,
726+ },
727+ }
728+ for _ , tc := range tests {
729+ t .Run (tc .name , func (t * testing.T ) {
730+ ctx := t .Context ()
731+
732+ httpServer , s := newTestServer (t , func (c * Config ) {
733+ c .Now = time .Now
734+ })
735+ defer httpServer .Close ()
736+
737+ // Create a confidential client for testing.
738+ err := s .storage .CreateClient (ctx , storage.Client {
739+ ID : "test" ,
740+ Secret : "barfoo" ,
741+ RedirectURIs : []string {"https://example.com/callback" },
742+ Name : "Test Client" ,
743+ })
744+ require .NoError (t , err )
745+
746+ u , err := url .Parse (s .issuerURL .String ())
747+ require .NoError (t , err )
748+ u .Path = path .Join (u .Path , "/token" )
749+
750+ v := url.Values {}
751+ v .Add ("grant_type" , "client_credentials" )
752+ if tc .scopes != "" {
753+ v .Add ("scope" , tc .scopes )
754+ }
755+
756+ req , _ := http .NewRequest ("POST" , u .String (), bytes .NewBufferString (v .Encode ()))
757+ req .Header .Set ("Content-Type" , "application/x-www-form-urlencoded" )
758+ req .SetBasicAuth (tc .clientID , tc .clientSecret )
759+
760+ rr := httptest .NewRecorder ()
761+ s .ServeHTTP (rr , req )
762+
763+ require .Equal (t , tc .wantCode , rr .Code )
764+
765+ if tc .wantCode == 200 {
766+ var resp struct {
767+ AccessToken string `json:"access_token"`
768+ TokenType string `json:"token_type"`
769+ ExpiresIn int `json:"expires_in"`
770+ IDToken string `json:"id_token"`
771+ RefreshToken string `json:"refresh_token"`
772+ }
773+ err := json .Unmarshal (rr .Body .Bytes (), & resp )
774+ require .NoError (t , err )
775+
776+ if tc .wantAccessTok {
777+ require .NotEmpty (t , resp .AccessToken )
778+ require .Equal (t , "bearer" , resp .TokenType )
779+ require .Greater (t , resp .ExpiresIn , 0 )
780+ }
781+ if tc .wantIDToken {
782+ require .NotEmpty (t , resp .IDToken )
783+
784+ // Verify the ID token claims.
785+ provider , err := oidc .NewProvider (ctx , httpServer .URL )
786+ require .NoError (t , err )
787+ verifier := provider .Verifier (& oidc.Config {ClientID : tc .clientID })
788+ idToken , err := verifier .Verify (ctx , resp .IDToken )
789+ require .NoError (t , err )
790+
791+ // Decode the subject to verify the connector ID.
792+ var sub internal.IDTokenSubject
793+ require .NoError (t , internal .Unmarshal (idToken .Subject , & sub ))
794+ require .Equal (t , "" , sub .ConnId )
795+ require .Equal (t , tc .clientID , sub .UserId )
796+
797+ var claims struct {
798+ Name string `json:"name"`
799+ PreferredUsername string `json:"preferred_username"`
800+ }
801+ require .NoError (t , idToken .Claims (& claims ))
802+
803+ if tc .wantUsername != "" {
804+ require .Equal (t , tc .wantUsername , claims .Name )
805+ require .Equal (t , tc .wantUsername , claims .PreferredUsername )
806+ } else {
807+ require .Empty (t , claims .Name )
808+ require .Empty (t , claims .PreferredUsername )
809+ }
810+ } else {
811+ require .Empty (t , resp .IDToken )
812+ }
813+ // client_credentials must never return a refresh token.
814+ require .Empty (t , resp .RefreshToken )
815+ }
816+ })
817+ }
818+ }
819+
648820func TestHandleConnectorCallbackWithSkipApproval (t * testing.T ) {
649821 ctx := t .Context ()
650822
0 commit comments