From 3d73ce717af046bae4c26f2ab223fe8d8bf0ae5d Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sun, 8 Mar 2026 17:19:05 +0800 Subject: [PATCH 1/2] 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 --- main.go | 67 +++++++++++++++++++++++++++++++--------------------- tui/model.go | 9 ++++++- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/main.go b/main.go index ca9f0c6..07c50c9 100644 --- a/main.go +++ b/main.go @@ -45,6 +45,32 @@ const ( refreshTokenTimeout = 10 * time.Second ) +// OAuth endpoint paths +const ( + endpointDeviceCode = "/oauth/device/code" + endpointToken = "/oauth/token" + endpointTokenInfo = "/oauth/tokeninfo" +) + +// OAuth error codes per RFC 8628 +const ( + oauthErrAuthorizationPending = "authorization_pending" + oauthErrSlowDown = "slow_down" + oauthErrExpiredToken = "expired_token" + oauthErrAccessDenied = "access_denied" + oauthErrInvalidGrant = "invalid_grant" + oauthErrInvalidToken = "invalid_token" +) + +// tokenResponse is the common structure for OAuth token endpoint responses. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + func init() { // Load .env file if exists (ignore error if not found) _ = godotenv.Load() @@ -361,7 +387,7 @@ func requestDeviceCode(ctx context.Context) (*oauth2.DeviceAuthResponse, error) req, err := http.NewRequestWithContext( reqCtx, http.MethodPost, - serverURL+"/oauth/device/code", + serverURL+endpointDeviceCode, strings.NewReader(data.Encode()), ) if err != nil { @@ -418,8 +444,8 @@ func performDeviceFlow(ctx context.Context, d tui.Displayer) (*TokenStorage, err config := &oauth2.Config{ ClientID: clientID, Endpoint: oauth2.Endpoint{ - DeviceAuthURL: serverURL + "/oauth/device/code", - TokenURL: serverURL + "/oauth/token", + DeviceAuthURL: serverURL + endpointDeviceCode, + TokenURL: serverURL + endpointToken, }, Scopes: []string{"read", "write"}, } @@ -505,11 +531,11 @@ func pollForTokenWithProgress( var errResp ErrorResponse if jsonErr := json.Unmarshal(oauthErr.Body, &errResp); jsonErr == nil { switch errResp.Error { - case "authorization_pending": + case oauthErrAuthorizationPending: // User hasn't authorized yet, continue polling continue - case "slow_down": + case oauthErrSlowDown: // Server requests slower polling - increase interval backoffMultiplier *= 1.5 pollInterval = min( @@ -520,10 +546,10 @@ func pollForTokenWithProgress( d.PollSlowDown(pollInterval) continue - case "expired_token": + case oauthErrExpiredToken: return nil, errors.New("device code expired, please restart the flow") - case "access_denied": + case oauthErrAccessDenied: return nil, errors.New("user denied authorization") default: @@ -590,14 +616,7 @@ func exchangeDeviceCode( } // Parse successful token response - var tokenResp struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` - } - + var tokenResp tokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("failed to parse token response: %w", err) } @@ -627,7 +646,7 @@ func verifyToken(ctx context.Context, accessToken string, d tui.Displayer) error defer cancel() req, err := http.NewRequestWithContext( - reqCtx, http.MethodGet, serverURL+"/oauth/tokeninfo", nil, + reqCtx, http.MethodGet, serverURL+endpointTokenInfo, nil, ) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -765,7 +784,7 @@ func refreshAccessToken( req, err := http.NewRequestWithContext( reqCtx, http.MethodPost, - serverURL+"/oauth/token", + serverURL+endpointToken, strings.NewReader(data.Encode()), ) if err != nil { @@ -789,7 +808,7 @@ func refreshAccessToken( var errResp ErrorResponse if err := json.Unmarshal(body, &errResp); err == nil { // Check if refresh token is expired or invalid - if errResp.Error == "invalid_grant" || errResp.Error == "invalid_token" { + if errResp.Error == oauthErrInvalidGrant || errResp.Error == oauthErrInvalidToken { return nil, ErrRefreshTokenExpired } return nil, fmt.Errorf("%s: %s", errResp.Error, errResp.ErrorDescription) @@ -798,13 +817,7 @@ func refreshAccessToken( } // Parse token response - var tokenResp struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - } - + var tokenResp tokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("failed to parse token response: %w", err) } @@ -850,7 +863,7 @@ func makeAPICallWithAutoRefresh(ctx context.Context, storage *TokenStorage, d tu defer cancel() req, err := http.NewRequestWithContext( - reqCtx, http.MethodGet, serverURL+"/oauth/tokeninfo", nil, + reqCtx, http.MethodGet, serverURL+endpointTokenInfo, nil, ) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -889,7 +902,7 @@ func makeAPICallWithAutoRefresh(ctx context.Context, storage *TokenStorage, d tu defer retryCancel() req, err = http.NewRequestWithContext( - retryCtx, http.MethodGet, serverURL+"/oauth/tokeninfo", nil, + retryCtx, http.MethodGet, serverURL+endpointTokenInfo, nil, ) if err != nil { return fmt.Errorf("failed to create retry request: %w", err) diff --git a/tui/model.go b/tui/model.go index 365c669..18e70de 100644 --- a/tui/model.go +++ b/tui/model.go @@ -375,9 +375,16 @@ func (m Model) viewStatusLog() string { return b.String() } -// addStatus appends a line to the status log. +// maxStatusLines limits the number of lines kept in the scrolling status log. +const maxStatusLines = 50 + +// addStatus appends a line to the status log, discarding the oldest entries +// when the log exceeds maxStatusLines. func (m *Model) addStatus(kind statusKind, text string) { m.statusLines = append(m.statusLines, statusLine{kind: kind, text: text}) + if len(m.statusLines) > maxStatusLines { + m.statusLines = m.statusLines[len(m.statusLines)-maxStatusLines:] + } } // tickAfterSecond returns a command that fires tickMsg after one second. From 8afb78d4efbbf9a5d6fd8763bb66f23bb2463135 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sun, 8 Mar 2026 17:25:45 +0800 Subject: [PATCH 2/2] 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 --- main.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 07c50c9..52bcbc4 100644 --- a/main.go +++ b/main.go @@ -52,14 +52,18 @@ const ( endpointTokenInfo = "/oauth/tokeninfo" ) -// OAuth error codes per RFC 8628 +// Device authorization error codes per RFC 8628 const ( oauthErrAuthorizationPending = "authorization_pending" oauthErrSlowDown = "slow_down" oauthErrExpiredToken = "expired_token" oauthErrAccessDenied = "access_denied" - oauthErrInvalidGrant = "invalid_grant" - oauthErrInvalidToken = "invalid_token" +) + +// Common OAuth 2.0 token endpoint error codes (e.g., RFC 6749) +const ( + oauthErrInvalidGrant = "invalid_grant" + oauthErrInvalidToken = "invalid_token" ) // tokenResponse is the common structure for OAuth token endpoint responses.