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
|
package handlers
import (
"crypto/rand"
"encoding/base64"
"log"
"net/http"
"golang.org/x/oauth2"
"task-dashboard/internal/api"
"task-dashboard/internal/store"
)
const sessionKeyGoogleOAuthState = "google_oauth_state"
func randomState() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// HandleGoogleTasksOAuthStart redirects the browser to Google's consent
// screen. GET (not POST) is correct here even under CSRF-protected routes --
// this is a plain navigable link the same way Google's own OAuth flow always
// is, and RequireAuth already gates the whole route group, so only an
// already-logged-in doot user can start it.
func (h *Handler) HandleGoogleTasksOAuthStart(w http.ResponseWriter, r *http.Request) {
if h.googleTasksOAuthConfig == nil {
http.Error(w, "Google Tasks OAuth is not configured (missing GOOGLE_OAUTH_CLIENT_ID/SECRET)", http.StatusServiceUnavailable)
return
}
state, err := randomState()
if err != nil {
http.Error(w, "Failed to start OAuth flow", http.StatusInternalServerError)
return
}
h.sessions.Put(r.Context(), sessionKeyGoogleOAuthState, state)
// AccessTypeOffline + ApprovalForce: without both, Google only returns a
// refresh_token on a user's very first-ever consent for this client --
// a reconnect (e.g. after revoking access) would otherwise silently get
// an access-token-only response that can't be persisted/refreshed.
url := h.googleTasksOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
http.Redirect(w, r, url, http.StatusFound)
}
// HandleGoogleTasksOAuthCallback receives Google's redirect after consent,
// exchanges the code for a token, and persists it.
func (h *Handler) HandleGoogleTasksOAuthCallback(w http.ResponseWriter, r *http.Request) {
if h.googleTasksOAuthConfig == nil {
http.Error(w, "Google Tasks OAuth is not configured", http.StatusServiceUnavailable)
return
}
if errParam := r.URL.Query().Get("error"); errParam != "" {
http.Redirect(w, r, "/settings?google_tasks_error="+errParam, http.StatusFound)
return
}
wantState := h.sessions.GetString(r.Context(), sessionKeyGoogleOAuthState)
gotState := r.URL.Query().Get("state")
if wantState == "" || gotState != wantState {
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
return
}
h.sessions.Remove(r.Context(), sessionKeyGoogleOAuthState)
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "Missing code", http.StatusBadRequest)
return
}
tok, err := h.googleTasksOAuthConfig.Exchange(r.Context(), code)
if err != nil {
log.Printf("Google Tasks OAuth exchange failed: %v", err)
http.Redirect(w, r, "/settings?google_tasks_error=exchange_failed", http.StatusFound)
return
}
if tok.RefreshToken == "" {
// Shouldn't happen given AccessTypeOffline+ApprovalForce, but a
// token with no refresh_token would silently stop working in ~1hr.
log.Printf("Google Tasks OAuth exchange returned no refresh_token")
http.Redirect(w, r, "/settings?google_tasks_error=no_refresh_token", http.StatusFound)
return
}
if err := h.store.SaveOAuthToken(api.GoogleTasksOAuthSource, tok); err != nil {
log.Printf("Failed to save Google Tasks OAuth token: %v", err)
http.Error(w, "Failed to save token", http.StatusInternalServerError)
return
}
_ = h.store.InvalidateCache(store.CacheKeyGoogleTasks)
http.Redirect(w, r, "/settings?google_tasks=connected", http.StatusFound)
}
// HandleGoogleTasksOAuthDisconnect removes the stored token.
func (h *Handler) HandleGoogleTasksOAuthDisconnect(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeleteOAuthToken(api.GoogleTasksOAuthSource); err != nil {
JSONError(w, http.StatusInternalServerError, "Failed to disconnect", err)
return
}
_ = h.store.InvalidateCache(store.CacheKeyGoogleTasks)
http.Redirect(w, r, "/settings?google_tasks=disconnected", http.StatusFound)
}
|