Skip to content

Commit 77bbe57

Browse files
appleboyclaude
andauthored
refactor(oauth): extract shared constants and types (#13)
* refactor(oauth): extract shared constants and types to reduce duplication - Replace inline OAuth error code strings with named constants per RFC 8628 - Replace hardcoded endpoint URL paths with named constants - Unify duplicate anonymous token response structs into shared tokenResponse type - Cap TUI status log to 50 entries to prevent unbounded memory growth Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(oauth): split error code constants by correct RFC reference Separate RFC 8628 device authorization error codes from RFC 6749 token endpoint error codes to avoid misleading readers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b88b4c8 commit 77bbe57

2 files changed

Lines changed: 52 additions & 28 deletions

File tree

main.go

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,36 @@ const (
4545
refreshTokenTimeout = 10 * time.Second
4646
)
4747

48+
// OAuth endpoint paths
49+
const (
50+
endpointDeviceCode = "/oauth/device/code"
51+
endpointToken = "/oauth/token"
52+
endpointTokenInfo = "/oauth/tokeninfo"
53+
)
54+
55+
// Device authorization error codes per RFC 8628
56+
const (
57+
oauthErrAuthorizationPending = "authorization_pending"
58+
oauthErrSlowDown = "slow_down"
59+
oauthErrExpiredToken = "expired_token"
60+
oauthErrAccessDenied = "access_denied"
61+
)
62+
63+
// Common OAuth 2.0 token endpoint error codes (e.g., RFC 6749)
64+
const (
65+
oauthErrInvalidGrant = "invalid_grant"
66+
oauthErrInvalidToken = "invalid_token"
67+
)
68+
69+
// tokenResponse is the common structure for OAuth token endpoint responses.
70+
type tokenResponse struct {
71+
AccessToken string `json:"access_token"`
72+
RefreshToken string `json:"refresh_token"`
73+
TokenType string `json:"token_type"`
74+
ExpiresIn int `json:"expires_in"`
75+
Scope string `json:"scope"`
76+
}
77+
4878
func init() {
4979
// Load .env file if exists (ignore error if not found)
5080
_ = godotenv.Load()
@@ -361,7 +391,7 @@ func requestDeviceCode(ctx context.Context) (*oauth2.DeviceAuthResponse, error)
361391
req, err := http.NewRequestWithContext(
362392
reqCtx,
363393
http.MethodPost,
364-
serverURL+"/oauth/device/code",
394+
serverURL+endpointDeviceCode,
365395
strings.NewReader(data.Encode()),
366396
)
367397
if err != nil {
@@ -418,8 +448,8 @@ func performDeviceFlow(ctx context.Context, d tui.Displayer) (*TokenStorage, err
418448
config := &oauth2.Config{
419449
ClientID: clientID,
420450
Endpoint: oauth2.Endpoint{
421-
DeviceAuthURL: serverURL + "/oauth/device/code",
422-
TokenURL: serverURL + "/oauth/token",
451+
DeviceAuthURL: serverURL + endpointDeviceCode,
452+
TokenURL: serverURL + endpointToken,
423453
},
424454
Scopes: []string{"read", "write"},
425455
}
@@ -505,11 +535,11 @@ func pollForTokenWithProgress(
505535
var errResp ErrorResponse
506536
if jsonErr := json.Unmarshal(oauthErr.Body, &errResp); jsonErr == nil {
507537
switch errResp.Error {
508-
case "authorization_pending":
538+
case oauthErrAuthorizationPending:
509539
// User hasn't authorized yet, continue polling
510540
continue
511541

512-
case "slow_down":
542+
case oauthErrSlowDown:
513543
// Server requests slower polling - increase interval
514544
backoffMultiplier *= 1.5
515545
pollInterval = min(
@@ -520,10 +550,10 @@ func pollForTokenWithProgress(
520550
d.PollSlowDown(pollInterval)
521551
continue
522552

523-
case "expired_token":
553+
case oauthErrExpiredToken:
524554
return nil, errors.New("device code expired, please restart the flow")
525555

526-
case "access_denied":
556+
case oauthErrAccessDenied:
527557
return nil, errors.New("user denied authorization")
528558

529559
default:
@@ -590,14 +620,7 @@ func exchangeDeviceCode(
590620
}
591621

592622
// Parse successful token response
593-
var tokenResp struct {
594-
AccessToken string `json:"access_token"`
595-
RefreshToken string `json:"refresh_token"`
596-
TokenType string `json:"token_type"`
597-
ExpiresIn int `json:"expires_in"`
598-
Scope string `json:"scope"`
599-
}
600-
623+
var tokenResp tokenResponse
601624
if err := json.Unmarshal(body, &tokenResp); err != nil {
602625
return nil, fmt.Errorf("failed to parse token response: %w", err)
603626
}
@@ -627,7 +650,7 @@ func verifyToken(ctx context.Context, accessToken string, d tui.Displayer) error
627650
defer cancel()
628651

629652
req, err := http.NewRequestWithContext(
630-
reqCtx, http.MethodGet, serverURL+"/oauth/tokeninfo", nil,
653+
reqCtx, http.MethodGet, serverURL+endpointTokenInfo, nil,
631654
)
632655
if err != nil {
633656
return fmt.Errorf("failed to create request: %w", err)
@@ -765,7 +788,7 @@ func refreshAccessToken(
765788
req, err := http.NewRequestWithContext(
766789
reqCtx,
767790
http.MethodPost,
768-
serverURL+"/oauth/token",
791+
serverURL+endpointToken,
769792
strings.NewReader(data.Encode()),
770793
)
771794
if err != nil {
@@ -789,7 +812,7 @@ func refreshAccessToken(
789812
var errResp ErrorResponse
790813
if err := json.Unmarshal(body, &errResp); err == nil {
791814
// Check if refresh token is expired or invalid
792-
if errResp.Error == "invalid_grant" || errResp.Error == "invalid_token" {
815+
if errResp.Error == oauthErrInvalidGrant || errResp.Error == oauthErrInvalidToken {
793816
return nil, ErrRefreshTokenExpired
794817
}
795818
return nil, fmt.Errorf("%s: %s", errResp.Error, errResp.ErrorDescription)
@@ -798,13 +821,7 @@ func refreshAccessToken(
798821
}
799822

800823
// Parse token response
801-
var tokenResp struct {
802-
AccessToken string `json:"access_token"`
803-
RefreshToken string `json:"refresh_token"`
804-
TokenType string `json:"token_type"`
805-
ExpiresIn int `json:"expires_in"`
806-
}
807-
824+
var tokenResp tokenResponse
808825
if err := json.Unmarshal(body, &tokenResp); err != nil {
809826
return nil, fmt.Errorf("failed to parse token response: %w", err)
810827
}
@@ -850,7 +867,7 @@ func makeAPICallWithAutoRefresh(ctx context.Context, storage *TokenStorage, d tu
850867
defer cancel()
851868

852869
req, err := http.NewRequestWithContext(
853-
reqCtx, http.MethodGet, serverURL+"/oauth/tokeninfo", nil,
870+
reqCtx, http.MethodGet, serverURL+endpointTokenInfo, nil,
854871
)
855872
if err != nil {
856873
return fmt.Errorf("failed to create request: %w", err)
@@ -889,7 +906,7 @@ func makeAPICallWithAutoRefresh(ctx context.Context, storage *TokenStorage, d tu
889906
defer retryCancel()
890907

891908
req, err = http.NewRequestWithContext(
892-
retryCtx, http.MethodGet, serverURL+"/oauth/tokeninfo", nil,
909+
retryCtx, http.MethodGet, serverURL+endpointTokenInfo, nil,
893910
)
894911
if err != nil {
895912
return fmt.Errorf("failed to create retry request: %w", err)

tui/model.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,16 @@ func (m Model) viewStatusLog() string {
375375
return b.String()
376376
}
377377

378-
// addStatus appends a line to the status log.
378+
// maxStatusLines limits the number of lines kept in the scrolling status log.
379+
const maxStatusLines = 50
380+
381+
// addStatus appends a line to the status log, discarding the oldest entries
382+
// when the log exceeds maxStatusLines.
379383
func (m *Model) addStatus(kind statusKind, text string) {
380384
m.statusLines = append(m.statusLines, statusLine{kind: kind, text: text})
385+
if len(m.statusLines) > maxStatusLines {
386+
m.statusLines = m.statusLines[len(m.statusLines)-maxStatusLines:]
387+
}
381388
}
382389

383390
// tickAfterSecond returns a command that fires tickMsg after one second.

0 commit comments

Comments
 (0)