diff --git a/main.go b/main.go index ca9f0c6..52bcbc4 100644 --- a/main.go +++ b/main.go @@ -45,6 +45,36 @@ const ( refreshTokenTimeout = 10 * time.Second ) +// OAuth endpoint paths +const ( + endpointDeviceCode = "/oauth/device/code" + endpointToken = "/oauth/token" + endpointTokenInfo = "/oauth/tokeninfo" +) + +// Device authorization error codes per RFC 8628 +const ( + oauthErrAuthorizationPending = "authorization_pending" + oauthErrSlowDown = "slow_down" + oauthErrExpiredToken = "expired_token" + oauthErrAccessDenied = "access_denied" +) + +// 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. +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 +391,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 +448,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 +535,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 +550,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 +620,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 +650,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 +788,7 @@ func refreshAccessToken( req, err := http.NewRequestWithContext( reqCtx, http.MethodPost, - serverURL+"/oauth/token", + serverURL+endpointToken, strings.NewReader(data.Encode()), ) if err != nil { @@ -789,7 +812,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 +821,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 +867,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 +906,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.