summaryrefslogtreecommitdiff
path: root/internal/api/google_tasks_oauth.go
blob: c6f816c017af91e4d4536a4a4e44f92c152a3eed (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
package api

import (
	"context"
	"fmt"
	"strings"
	"time"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
	"google.golang.org/api/option"
	"google.golang.org/api/tasks/v1"
)

// TokenStore is the persistence doot's store package satisfies (defined
// here, not imported from internal/store, so this package doesn't take a
// dependency on the store package -- api clients otherwise never touch the
// DB directly).
type TokenStore interface {
	GetOAuthToken(source string) (*oauth2.Token, error)
	SaveOAuthToken(source string, tok *oauth2.Token) error
	// RecordOAuthTokenError persists why a refresh failed, so the settings page
	// can distinguish "connected" from "has a row but the grant is dead".
	RecordOAuthTokenError(source string, message string) error
}

// NeedsReconnect reports whether an error means the stored grant is finished
// and only a fresh consent will fix it.
//
// Google answers invalid_grant when a refresh token has been revoked,
// superseded, or has simply aged out — which for an OAuth client still in
// "Testing" publishing status happens after seven days. Retrying cannot help;
// the user has to reconnect. Any other error (a network blip, a 5xx) is worth
// retrying and must not push the user through consent needlessly.
func NeedsReconnect(err error) bool {
	return err != nil && strings.Contains(err.Error(), "invalid_grant")
}

// GoogleTasksOAuthSource is the key GoogleTasksClient's token is stored
// under in oauth_tokens -- shared between the connect/callback handlers and
// the client constructor so they always agree on which row they mean.
const GoogleTasksOAuthSource = "google_tasks"

// GoogleTasksOAuthConfig builds the oauth2.Config for the Google Tasks
// consent flow. redirectURL must exactly match an "Authorized redirect URI"
// configured on the OAuth 2.0 Client ID in Google Cloud Console.
func GoogleTasksOAuthConfig(clientID, clientSecret, redirectURL string) *oauth2.Config {
	return &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		RedirectURL:  redirectURL,
		Scopes:       []string{tasks.TasksScope},
		Endpoint:     google.Endpoint,
	}
}

// dbTokenSource implements oauth2.TokenSource by reading the current token
// from the store on every call and persisting it back if the underlying
// oauth2.Config.TokenSource refreshed it. oauth2.NewClient wraps whatever
// source it's given in its own in-memory oauth2.ReuseTokenSource, so this
// only actually runs once per access-token lifetime (~1hr), not per API
// call -- cheap enough to just hit the DB each time rather than add a
// second layer of in-process caching.
type dbTokenSource struct {
	store  TokenStore
	source string
	config *oauth2.Config
}

// tokenRefreshTimeout bounds a single refresh round-trip to Google.
const tokenRefreshTimeout = 30 * time.Second

func (d *dbTokenSource) Token() (*oauth2.Token, error) {
	current, err := d.store.GetOAuthToken(d.source)
	if err != nil {
		return nil, fmt.Errorf("loading stored token: %w", err)
	}
	if current == nil || current.RefreshToken == "" {
		return nil, fmt.Errorf("google tasks not connected -- visit /settings and connect it")
	}

	// Deliberately NOT a context captured at construction time. main.go builds
	// the client with a short-lived init context and cancels it the moment
	// construction returns; reusing that here meant the first refresh after the
	// startup access token aged out — roughly an hour in — failed with
	// "context canceled", and so did every one after it. Google Tasks was
	// broken from that point until a restart, which bought another hour.
	//
	// A token refresh is not logically part of whichever request happened to
	// trigger it, so it gets its own background context with its own bound.
	ctx, cancel := context.WithTimeout(context.Background(), tokenRefreshTimeout)
	defer cancel()

	refreshed, err := d.config.TokenSource(ctx, current).Token()
	if err != nil {
		// Record it: a failed refresh is invisible otherwise, and an
		// invalid_grant in particular means no amount of waiting will fix it.
		msg := err.Error()
		if NeedsReconnect(err) {
			msg = "Google rejected the saved authorisation (invalid_grant) — reconnect to continue. " +
				"If this recurs weekly, the OAuth client is still in Testing publishing status, " +
				"which expires refresh tokens after 7 days."
		}
		_ = d.store.RecordOAuthTokenError(d.source, msg)
		return nil, fmt.Errorf("refreshing token: %w", err)
	}

	if refreshed.AccessToken != current.AccessToken {
		// Google only returns RefreshToken on the initial grant -- a
		// refresh response often omits it, which would otherwise
		// overwrite the stored refresh token with an empty string and
		// permanently break future refreshes.
		if refreshed.RefreshToken == "" {
			refreshed.RefreshToken = current.RefreshToken
		}
		if err := d.store.SaveOAuthToken(d.source, refreshed); err != nil {
			return nil, fmt.Errorf("persisting refreshed token: %w", err)
		}
	}

	return refreshed, nil
}

// NewGoogleTasksOAuthClient builds a GoogleTasksClient backed by a stored
// user-consent OAuth token instead of service-account credentials. Safe to
// call even before the user has connected anything -- construction always
// succeeds; API calls fail with the dbTokenSource error above until a
// token exists (see HandleGoogleTasksOAuthCallback).
func NewGoogleTasksOAuthClient(ctx context.Context, oauthConfig *oauth2.Config, tokenStore TokenStore, tasklistID, timezone string) (*GoogleTasksClient, error) {
	src := &dbTokenSource{store: tokenStore, source: GoogleTasksOAuthSource, config: oauthConfig}
	// context.Background(), not ctx: oauth2.NewClient binds the context it is
	// given to the returned client's transport for its whole lifetime, and ctx
	// here is the caller's short-lived init context. The per-call context still
	// applies — it comes from the tasks service methods themselves.
	httpClient := oauth2.NewClient(context.Background(), src)
	return NewGoogleTasksClient(ctx, option.WithHTTPClient(httpClient), tasklistID, timezone)
}