44 "context"
55 "net/http"
66 "net/http/httptest"
7+ "net/url"
78 "strings"
89 "testing"
910 "time"
@@ -41,7 +42,7 @@ func TestExchangeCodeForToken_Success(t *testing.T) {
4142 t .Fatal (err )
4243 }
4344
44- err = client .ExchangeCodeForToken (context .Background (), "auth_code_123" )
45+ err = client .ExchangeCodeForToken (context .Background (), "auth_code_123" , "state_abc" , "state_abc" )
4546 if err != nil {
4647 t .Fatalf ("unexpected error: %v" , err )
4748 }
@@ -66,7 +67,7 @@ func TestExchangeCodeForToken_EmptyCode(t *testing.T) {
6667 config .SetDefaults ()
6768 client , _ := NewClient (config )
6869
69- err := client .ExchangeCodeForToken (context .Background (), "" )
70+ err := client .ExchangeCodeForToken (context .Background (), "" , "state" , "state" )
7071 if err == nil {
7172 t .Fatal ("expected error for empty code" )
7273 }
@@ -91,7 +92,7 @@ func TestExchangeCodeForToken_ServerError(t *testing.T) {
9192 config .BaseURL = server .URL
9293
9394 client , _ := NewClient (config )
94- err := client .ExchangeCodeForToken (context .Background (), "bad_code" )
95+ err := client .ExchangeCodeForToken (context .Background (), "bad_code" , "state" , "state" )
9596 if err == nil {
9697 t .Fatal ("expected error for server error response" )
9798 }
@@ -116,7 +117,7 @@ func TestExchangeCodeForToken_NoExpiresIn(t *testing.T) {
116117 config .BaseURL = server .URL
117118
118119 client , _ := NewClient (config )
119- err := client .ExchangeCodeForToken (context .Background (), "code" )
120+ err := client .ExchangeCodeForToken (context .Background (), "code" , "state" , "state" )
120121 if err != nil {
121122 t .Fatalf ("unexpected error: %v" , err )
122123 }
@@ -477,15 +478,26 @@ func TestGetAuthURL_ContainsRequiredParams(t *testing.T) {
477478 config .SetDefaults ()
478479 client , _ := NewClient (config )
479480
480- authURL := client .GetAuthURL ([]string {"threads_basic" })
481+ authURL , state , err := client .GetAuthURL ([]string {"threads_basic" })
482+ if err != nil {
483+ t .Fatalf ("unexpected error: %v" , err )
484+ }
481485 if authURL == "" {
482486 t .Fatal ("expected non-empty auth URL" )
483487 }
488+ if state == "" {
489+ t .Fatal ("expected non-empty state; callers cannot enforce CSRF protection without it" )
490+ }
484491 for _ , param := range []string {"client_id=my-app-id" , "response_type=code" , "scope=threads_basic" } {
485492 if ! strings .Contains (authURL , param ) {
486493 t .Errorf ("expected auth URL to contain %q, got %s" , param , authURL )
487494 }
488495 }
496+ // The embedded state must be the state value returned to the caller,
497+ // so the caller can compare it against the callback.
498+ if ! strings .Contains (authURL , "state=" + url .QueryEscape (state )) {
499+ t .Errorf ("expected auth URL to embed the returned state %q, got %s" , state , authURL )
500+ }
489501}
490502
491503func TestGetAuthURL_DefaultScopes (t * testing.T ) {
@@ -497,12 +509,42 @@ func TestGetAuthURL_DefaultScopes(t *testing.T) {
497509 config .SetDefaults ()
498510 client , _ := NewClient (config )
499511
500- authURL := client .GetAuthURL (nil )
512+ authURL , _ , err := client .GetAuthURL (nil )
513+ if err != nil {
514+ t .Fatalf ("unexpected error: %v" , err )
515+ }
501516 if ! strings .Contains (authURL , "threads_basic" ) {
502517 t .Error ("expected default scope threads_basic in auth URL" )
503518 }
504519}
505520
521+ // TestGetAuthURL_UniqueState: state must be unpredictable — a guessable
522+ // state neutralises CSRF protection.
523+ func TestGetAuthURL_UniqueState (t * testing.T ) {
524+ config := & Config {
525+ ClientID : "my-app-id" ,
526+ ClientSecret : "secret" ,
527+ RedirectURI : "https://example.com/callback" ,
528+ }
529+ config .SetDefaults ()
530+ client , _ := NewClient (config )
531+
532+ _ , s1 , err := client .GetAuthURL (nil )
533+ if err != nil {
534+ t .Fatalf ("unexpected error: %v" , err )
535+ }
536+ _ , s2 , err := client .GetAuthURL (nil )
537+ if err != nil {
538+ t .Fatalf ("unexpected error: %v" , err )
539+ }
540+ if s1 == s2 {
541+ t .Fatal ("GetAuthURL must produce a fresh state on each call" )
542+ }
543+ if len (s1 ) < 32 {
544+ t .Errorf ("state looks too short to be high-entropy: len=%d" , len (s1 ))
545+ }
546+ }
547+
506548func TestExchangeCodeForToken_WithLogger (t * testing.T ) {
507549 handler := func (w http.ResponseWriter , r * http.Request ) {
508550 w .Header ().Set ("Content-Type" , "application/json" )
@@ -522,12 +564,86 @@ func TestExchangeCodeForToken_WithLogger(t *testing.T) {
522564 config .BaseURL = server .URL
523565
524566 client , _ := NewClient (config )
525- err := client .ExchangeCodeForToken (context .Background (), "code" )
567+ err := client .ExchangeCodeForToken (context .Background (), "code" , "state" , "state" )
526568 if err != nil {
527569 t .Fatalf ("unexpected error: %v" , err )
528570 }
529571}
530572
573+ // TestExchangeCodeForToken_StateMismatch asserts the core CSRF protection:
574+ // when the state echoed on the callback does not match the state persisted
575+ // by the caller (from GetAuthURL), ExchangeCodeForToken must refuse the
576+ // exchange BEFORE hitting the token endpoint, so no attacker-controlled code
577+ // can be redeemed into the victim's session.
578+ func TestExchangeCodeForToken_StateMismatch (t * testing.T ) {
579+ called := false
580+ handler := func (w http.ResponseWriter , r * http.Request ) {
581+ called = true
582+ w .WriteHeader (200 )
583+ }
584+ server := httptest .NewServer (http .HandlerFunc (handler ))
585+ t .Cleanup (server .Close )
586+
587+ config := & Config {
588+ ClientID : "test-id" ,
589+ ClientSecret : "test-secret" ,
590+ RedirectURI : "https://example.com/callback" ,
591+ }
592+ config .SetDefaults ()
593+ config .BaseURL = server .URL
594+
595+ client , _ := NewClient (config )
596+ err := client .ExchangeCodeForToken (context .Background (), "code" , "expected-state" , "attacker-chosen-state" )
597+ if err == nil {
598+ t .Fatal ("expected error for state mismatch" )
599+ }
600+ if ! IsAuthenticationError (err ) {
601+ t .Errorf ("expected AuthenticationError (CSRF), got %T: %v" , err , err )
602+ }
603+ if called {
604+ t .Error ("token endpoint must not be called when state mismatches" )
605+ }
606+ if client .IsAuthenticated () {
607+ t .Error ("client must not become authenticated when state mismatches" )
608+ }
609+ }
610+
611+ func TestExchangeCodeForToken_EmptyExpectedState (t * testing.T ) {
612+ config := & Config {
613+ ClientID : "test-id" ,
614+ ClientSecret : "test-secret" ,
615+ RedirectURI : "https://example.com/callback" ,
616+ }
617+ config .SetDefaults ()
618+ client , _ := NewClient (config )
619+
620+ err := client .ExchangeCodeForToken (context .Background (), "code" , "" , "anything" )
621+ if err == nil {
622+ t .Fatal ("expected error when expectedState is empty (defeats CSRF check)" )
623+ }
624+ if ! IsValidationError (err ) {
625+ t .Errorf ("expected ValidationError, got %T" , err )
626+ }
627+ }
628+
629+ func TestExchangeCodeForToken_EmptyReceivedState (t * testing.T ) {
630+ config := & Config {
631+ ClientID : "test-id" ,
632+ ClientSecret : "test-secret" ,
633+ RedirectURI : "https://example.com/callback" ,
634+ }
635+ config .SetDefaults ()
636+ client , _ := NewClient (config )
637+
638+ err := client .ExchangeCodeForToken (context .Background (), "code" , "expected" , "" )
639+ if err == nil {
640+ t .Fatal ("expected error when receivedState is empty" )
641+ }
642+ if ! IsValidationError (err ) {
643+ t .Errorf ("expected ValidationError, got %T" , err )
644+ }
645+ }
646+
531647func TestGetLongLivedToken_NoToken (t * testing.T ) {
532648 config := & Config {
533649 ClientID : "test-id" ,
0 commit comments