summaryrefslogtreecommitdiff
path: root/internal/handlers/google_oauth.go
blob: 18fd255657d07b1101bec4050910f8442bd0c030 (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
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)
}