Skip to content

Commit 991530c

Browse files
authored
fix(security): OAuth state CSRF + log redaction + ownership & rate-limit hardening (#27)
2 parents 96c064a + 5509f83 commit 991530c

14 files changed

Lines changed: 525 additions & 221 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
7171
- name: Update coverage badge
7272
if: github.ref == 'refs/heads/main' && matrix.go-version == '1.24'
73-
uses: schneegans/dynamic-badges-action@v1.7.0
73+
uses: schneegans/dynamic-badges-action@v1.8.0
7474
with:
7575
auth: ${{ secrets.GIST_TOKEN }}
7676
gistID: 2c608589294aed9aa900256daeec0fd4
@@ -83,7 +83,7 @@ jobs:
8383

8484
- name: Upload coverage to Codecov
8585
if: matrix.go-version == '1.24'
86-
uses: codecov/codecov-action@v5
86+
uses: codecov/codecov-action@v6
8787
with:
8888
files: coverage.out
8989
fail_ci_if_error: false

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,21 @@ config := &threads.Config{
5757

5858
client, err := threads.NewClient(config)
5959

60-
// Get authorization URL
61-
authURL := client.GetAuthURL(config.Scopes)
62-
// Redirect user to authURL
60+
// Get authorization URL. Persist state in the user's session (signed cookie,
61+
// server-side session, etc.) — it MUST be compared against the state query
62+
// parameter on the OAuth callback to prevent CSRF / authorization-code
63+
// fixation (RFC 6749 §10.12, OAuth 2.0 Security BCP §4.7).
64+
authURL, state, err := client.GetAuthURL(config.Scopes)
65+
if err != nil {
66+
log.Fatal(err)
67+
}
68+
// Redirect user to authURL; on the callback, read ?code=... and ?state=...
6369

64-
// Exchange authorization code for token
70+
// Exchange authorization code for token. The client refuses the exchange if
71+
// the expected state does not match the state echoed on the callback
72+
// (constant-time compare).
6573
ctx := context.Background()
66-
err = client.ExchangeCodeForToken(ctx, "auth-code-from-callback")
74+
err = client.ExchangeCodeForToken(ctx, "auth-code-from-callback", state, receivedState)
6775
err = client.GetLongLivedToken(ctx) // Convert to long-lived token
6876
```
6977

auth.go

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package threads
33
import (
44
"context"
55
"crypto/rand"
6+
"crypto/subtle"
67
"encoding/base64"
78
"encoding/json"
89
"fmt"
@@ -48,19 +49,26 @@ func generateState() (string, error) {
4849
return base64.URLEncoding.EncodeToString(b), nil
4950
}
5051

51-
// GetAuthURL generates the authorization URL for OAuth 2.0 flow.
52-
// Users should be redirected to this URL to grant permissions to your app.
53-
// If scopes are not provided, defaults to threads_basic and threads_content_publish.
54-
// Returns the complete authorization URL including all necessary parameters.
55-
func (c *Client) GetAuthURL(scopes []string) string {
52+
// GetAuthURL generates the authorization URL for the OAuth 2.0 flow along with
53+
// the random state parameter embedded in it. Callers MUST persist the returned
54+
// state in the user's session (e.g. a signed cookie) and pass it as
55+
// expectedState to ExchangeCodeForToken when the provider redirects back,
56+
// comparing it against the state echoed on the callback. This is required by
57+
// RFC 6749 §10.12 and OAuth 2.0 Security BCP §4.7 to prevent login/CSRF and
58+
// authorization-code fixation attacks.
59+
//
60+
// If scopes are not provided, defaults to threads_basic and
61+
// threads_content_publish. Returns an error if the system's secure random
62+
// source is unavailable; in that case no URL is returned so callers cannot
63+
// accidentally issue a flow with a guessable state (fail closed).
64+
func (c *Client) GetAuthURL(scopes []string) (authURL, state string, err error) {
5665
if len(scopes) == 0 {
5766
scopes = []string{"threads_basic", "threads_content_publish"}
5867
}
5968

60-
state, err := generateState()
69+
state, err = generateState()
6170
if err != nil {
62-
// If we can't generate state, use a simple timestamp-based fallback
63-
state = fmt.Sprintf("state_%d", time.Now().Unix())
71+
return "", "", err
6472
}
6573

6674
params := url.Values{
@@ -71,18 +79,34 @@ func (c *Client) GetAuthURL(scopes []string) string {
7179
"state": {state},
7280
}
7381

74-
authURL := fmt.Sprintf("https://www.threads.net/oauth/authorize?%s", params.Encode())
75-
return authURL
82+
authURL = fmt.Sprintf("https://www.threads.net/oauth/authorize?%s", params.Encode())
83+
return authURL, state, nil
7684
}
7785

7886
// ExchangeCodeForToken exchanges an authorization code for an access token.
79-
// This should be called after the user authorizes your app, and you receive the code
80-
// from the redirect URI callback. The resulting token is automatically stored
81-
// in the client and token storage.
82-
func (c *Client) ExchangeCodeForToken(ctx context.Context, code string) error {
87+
// This should be called after the user authorizes your app and the provider
88+
// redirects back with a code and state.
89+
//
90+
// expectedState is the state value that was returned by GetAuthURL and
91+
// persisted in the user's session; receivedState is the state query parameter
92+
// echoed on the callback. Both must be non-empty and must match (compared in
93+
// constant time). A mismatch indicates an OAuth CSRF / authorization-code
94+
// fixation attack and the exchange is refused before any network call.
95+
//
96+
// The resulting token is automatically stored in the client and token storage.
97+
func (c *Client) ExchangeCodeForToken(ctx context.Context, code, expectedState, receivedState string) error {
8398
if code == "" {
8499
return NewValidationError(400, "Authorization code is required", "Code parameter cannot be empty", "code")
85100
}
101+
if expectedState == "" {
102+
return NewValidationError(400, "Expected state is required", "expectedState must be the value returned by GetAuthURL and persisted in the user's session", "expected_state")
103+
}
104+
if receivedState == "" {
105+
return NewValidationError(400, "Received state is required", "receivedState must be the state query parameter echoed by the provider on the callback", "received_state")
106+
}
107+
if subtle.ConstantTimeCompare([]byte(expectedState), []byte(receivedState)) != 1 {
108+
return NewAuthenticationError(400, "OAuth state mismatch", "The state parameter returned by the provider does not match the one issued by GetAuthURL; possible CSRF/code-fixation attempt")
109+
}
86110

87111
data := url.Values{
88112
"client_id": {c.config.ClientID},

auth_test.go

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
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

491503
func 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+
506548
func 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+
531647
func TestGetLongLivedToken_NoToken(t *testing.T) {
532648
config := &Config{
533649
ClientID: "test-id",

0 commit comments

Comments
 (0)