Implement authentication with auto token renewal

Add authentication methods to client.go:
- Authenticate(ctx) for explicit login
- AuthenticateWith2FA(ctx, token) for 2FA support
- refreshToken(ctx) for token renewal
- ensureAuthenticated(ctx) for auto-auth before requests
- CurrentUser(ctx) to get logged-in user info
- getToken() for thread-safe token access

Features:
- Token stored with expiry time (23h for safety margin)
- Auto-refresh when token expires within 1 hour
- Falls back to full authentication if refresh fails
- Thread-safe token access using RWMutex
- Sends token via X-Client-Token header

API endpoints used:
- POST /auth/login.json?version=2
- POST /auth/refresh_token.json?version=2
- GET /auth/curr_user.json

Closes checkvist-api-lpn
This commit is contained in:
Oliver Jakoubek 2026-01-14 13:26:39 +01:00
commit b91e35a684
2 changed files with 175 additions and 1 deletions

174
client.go
View file

@ -11,8 +11,14 @@
package checkvist
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@ -81,3 +87,171 @@ func NewClient(username, remoteKey string, opts ...Option) *Client {
return c
}
// authResponse represents the response from the authentication endpoint.
type authResponse struct {
Token string `json:"token"`
}
// Authenticate performs explicit authentication with the Checkvist API.
// This is optional - the client will automatically authenticate when needed.
// Use this to verify credentials or to pre-authenticate before making requests.
func (c *Client) Authenticate(ctx context.Context) error {
return c.authenticate(ctx, "")
}
// AuthenticateWith2FA performs authentication with a 2FA token.
func (c *Client) AuthenticateWith2FA(ctx context.Context, twoFAToken string) error {
return c.authenticate(ctx, twoFAToken)
}
// authenticate performs the actual authentication request.
func (c *Client) authenticate(ctx context.Context, twoFAToken string) error {
data := url.Values{}
data.Set("username", c.username)
data.Set("remote_key", c.remoteKey)
if twoFAToken != "" {
data.Set("totp", twoFAToken)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/auth/login.json?version=2",
strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("creating auth request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("auth request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return NewAPIError(resp, string(body))
}
var authResp authResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return fmt.Errorf("decoding auth response: %w", err)
}
c.mu.Lock()
c.token = authResp.Token
// Token is valid for 1 day, but we refresh earlier to be safe
c.tokenExp = time.Now().Add(23 * time.Hour)
c.mu.Unlock()
c.logger.Debug("authenticated successfully", "username", c.username)
return nil
}
// refreshToken renews the authentication token.
func (c *Client) refreshToken(ctx context.Context) error {
c.mu.RLock()
currentToken := c.token
c.mu.RUnlock()
if currentToken == "" {
return c.Authenticate(ctx)
}
data := url.Values{}
data.Set("old_token", currentToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/auth/refresh_token.json?version=2",
strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("creating refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("refresh request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// If refresh fails, try full authentication
c.logger.Debug("token refresh failed, attempting full authentication")
return c.Authenticate(ctx)
}
var authResp authResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return fmt.Errorf("decoding refresh response: %w", err)
}
c.mu.Lock()
c.token = authResp.Token
// Refreshed tokens can be valid for up to 90 days, but we refresh more frequently
c.tokenExp = time.Now().Add(23 * time.Hour)
c.mu.Unlock()
c.logger.Debug("token refreshed successfully")
return nil
}
// ensureAuthenticated ensures the client has a valid authentication token.
// This is called automatically before each API request.
func (c *Client) ensureAuthenticated(ctx context.Context) error {
c.mu.RLock()
token := c.token
tokenExp := c.tokenExp
c.mu.RUnlock()
if token == "" {
return c.Authenticate(ctx)
}
// Refresh token if it will expire within the next hour
if time.Now().Add(1 * time.Hour).After(tokenExp) {
return c.refreshToken(ctx)
}
return nil
}
// getToken returns the current authentication token.
// Thread-safe.
func (c *Client) getToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token
}
// CurrentUser returns information about the currently authenticated user.
func (c *Client) CurrentUser(ctx context.Context) (*User, error) {
if err := c.ensureAuthenticated(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/auth/curr_user.json", nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("X-Client-Token", c.getToken())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, NewAPIError(resp, string(body))
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &user, nil
}