From b91e35a684989f209f68f9db85b9b1793245c7e9 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Wed, 14 Jan 2026 13:26:39 +0100 Subject: [PATCH] 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 --- .beads/issues.jsonl | 2 +- client.go | 174 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 03527f5..acc6643 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -17,7 +17,7 @@ {"id":"checkvist-api-c2k","title":"Implement Checklist operations","description":"Create checklists.go with ChecklistService:\n- client.Checklists() returns ChecklistService\n- List(ctx) ([]Checklist, error) - GET /checklists.json\n- Get(ctx, id) (*Checklist, error) - GET /checklists/{id}.json\n- Create(ctx, name) (*Checklist, error) - POST /checklists.json\n- Update(ctx, id, name) (*Checklist, error) - PUT /checklists/{id}.json\n- Delete(ctx, id) error - DELETE /checklists/{id}.json\n- Support archived filter in List\nContext support for all methods.","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:53.566197933+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:53.566197933+01:00","dependencies":[{"issue_id":"checkvist-api-c2k","depends_on_id":"checkvist-api-8u6","type":"blocks","created_at":"2026-01-14T12:32:54.533462004+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-c2k","depends_on_id":"checkvist-api-lpn","type":"blocks","created_at":"2026-01-14T12:32:54.859645166+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-cb8","title":"Extended Features","description":"Phase 3: Implement P1 (should-have) features including client-side filtering and builder patterns for fluent interfaces.","status":"open","priority":1,"issue_type":"epic","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:55.624242123+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:55.624242123+01:00"} {"id":"checkvist-api-e9p","title":"Implement data models","description":"Create models.go with all data structures:\n- Checklist struct (ID, Name, Public, Archived, ReadOnly, TaskCount, TaskCompleted, Tags, TagsAsText, UpdatedAt)\n- Task struct (ID, ChecklistID, ParentID, Content, Status, Position, Priority, Tags, TagsAsText, DueDateRaw, DueDate, AssigneeIDs, CommentsCount, UpdateLine, UpdatedAt, CreatedAt, Children, Notes)\n- TaskStatus enum (StatusOpen=0, StatusClosed=1, StatusInvalidated=2)\n- Note struct (ID, TaskID, Comment, UpdatedAt, CreatedAt)\n- Tags type (map[string]bool)\n- User struct (ID, Username, Email)\n- DueDate struct with constructors (DueAt, DueString, DueInDays) and constants (DueToday, DueTomorrow, DueNextWeek, DueNextMonth)","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:06.900391036+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:22:22.273934664+01:00","closed_at":"2026-01-14T13:22:22.273934664+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-e9p","depends_on_id":"checkvist-api-5wr","type":"blocks","created_at":"2026-01-14T12:32:46.433908937+01:00","created_by":"Oliver Jakoubek"}]} -{"id":"checkvist-api-lpn","title":"Implement authentication with auto token renewal","description":"Add to client.go:\n- Authenticate(ctx context.Context) error - explicit login\n- refreshToken(ctx context.Context) error - token renewal\n- ensureAuthenticated(ctx context.Context) error - auto-auth before requests\n- CurrentUser(ctx context.Context) (*User, error) - get logged in user\n- Token management: store token and expiry, auto-refresh before expiry\n- Thread-safe token access using mutex\n- Support for optional 2FA token\nAPI endpoints:\n- POST /auth/login.json?version=2 (login)\n- POST /auth/refresh_token.json?version=2 (refresh)\n- GET /auth/curr_user.json (current user)\nToken sent via X-Client-Token header","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:08.358878117+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:08.358878117+01:00","dependencies":[{"issue_id":"checkvist-api-lpn","depends_on_id":"checkvist-api-ymg","type":"blocks","created_at":"2026-01-14T12:32:47.656124681+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"checkvist-api-lpn","title":"Implement authentication with auto token renewal","description":"Add to client.go:\n- Authenticate(ctx context.Context) error - explicit login\n- refreshToken(ctx context.Context) error - token renewal\n- ensureAuthenticated(ctx context.Context) error - auto-auth before requests\n- CurrentUser(ctx context.Context) (*User, error) - get logged in user\n- Token management: store token and expiry, auto-refresh before expiry\n- Thread-safe token access using mutex\n- Support for optional 2FA token\nAPI endpoints:\n- POST /auth/login.json?version=2 (login)\n- POST /auth/refresh_token.json?version=2 (refresh)\n- GET /auth/curr_user.json (current user)\nToken sent via X-Client-Token header","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:08.358878117+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:26:32.668016856+01:00","closed_at":"2026-01-14T13:26:32.668016856+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-lpn","depends_on_id":"checkvist-api-ymg","type":"blocks","created_at":"2026-01-14T12:32:47.656124681+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-mnh","title":"Implement error types and sentinel errors","description":"Create errors.go with:\n- APIError struct (StatusCode, Message, RequestID, Err) with Error() and Unwrap() methods\n- Sentinel errors: ErrUnauthorized, ErrNotFound, ErrRateLimited, ErrBadRequest, ErrServerError\n- Helper function to create APIError from HTTP response","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:07.619359293+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:23:43.296883265+01:00","closed_at":"2026-01-14T13:23:43.296883265+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-mnh","depends_on_id":"checkvist-api-5wr","type":"blocks","created_at":"2026-01-14T12:32:46.754134799+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-nrk","title":"Create README with quickstart","description":"Create README.md (in English) with:\n- Project description\n- Installation: go get code.beautifulmachines.dev/jakoubek/checkvist-api\n- Quick start example (init client, list checklists, create task)\n- API overview (Checklists, Tasks, Notes, Filters)\n- Builder pattern examples\n- Error handling examples\n- Configuration options\n- Link to GoDoc\n- License (MIT)","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:38.724338606+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:38.724338606+01:00","dependencies":[{"issue_id":"checkvist-api-nrk","depends_on_id":"checkvist-api-c2k","type":"blocks","created_at":"2026-01-14T12:33:15.785698203+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-nrk","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:33:16.125115134+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-rl9","title":"Implement Task operations","description":"Create tasks.go with TaskService:\n- client.Tasks(checklistID) returns TaskService\n- List(ctx) ([]Task, error) - GET /checklists/{id}/tasks.json\n- Get(ctx, taskID) (*Task, error) - GET /checklists/{id}/tasks/{task_id}.json (includes parent hierarchy)\n- Create(ctx, builder *TaskBuilder) (*Task, error) - POST /checklists/{id}/tasks.json\n- Update(ctx, taskID, opts) (*Task, error) - PUT /checklists/{id}/tasks/{task_id}.json\n- Delete(ctx, taskID) error - DELETE /checklists/{id}/tasks/{task_id}.json\n- Close(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/close.json\n- Reopen(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/reopen.json\n- Invalidate(ctx, taskID) (*Task, error) - POST /checklists/{id}/tasks/{task_id}/invalidate.json\nParse DueDate from DueDateRaw when retrieving tasks.","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:53.90629838+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:53.90629838+01:00","dependencies":[{"issue_id":"checkvist-api-rl9","depends_on_id":"checkvist-api-8u6","type":"blocks","created_at":"2026-01-14T12:32:55.247292478+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-rl9","depends_on_id":"checkvist-api-lpn","type":"blocks","created_at":"2026-01-14T12:32:55.536931433+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/client.go b/client.go index c422189..21c4ff9 100644 --- a/client.go +++ b/client.go @@ -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 +}