feat(sync): unify Google auth under :Pending auth (#72)

* feat(sync): unify Google auth under :Pending auth

Problem: users had to run `:Pending gtasks auth` and `:Pending gcal
auth` separately, producing two token files and two browser consents
for the same Google account.

Solution: introduce `oauth.google_client` with combined tasks +
calendar scopes and a single `google_tokens.json`. Remove per-backend
`auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth`
that prompts with `vim.ui.select` and delegates to the shared client's
`setup()` or `auth()` based on credential availability.

* docs: update vimdoc for unified Google auth

Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks
auth` / `:Pending gcal auth` commands and separate token files, which no
longer exist after the auth unification.

Solution: add `:Pending auth` entry to COMMANDS and a new
`*pending-google-auth*` section covering the shared PKCE flow, combined
scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action
tables and update all cross-references to use `:Pending auth`.

* ci: format
This commit is contained in:
Barrett Ruth 2026-03-05 21:08:22 -05:00
parent 34b8e1798a
commit 1dd40c9a9f
7 changed files with 102 additions and 77 deletions

View file

@ -65,8 +65,9 @@ CONTENTS *pending-contents*
17. Sync Backends ................................... |pending-sync-backend|
18. Google Calendar .......................................... |pending-gcal|
19. Google Tasks ............................................ |pending-gtasks|
20. Data Format .............................................. |pending-data|
21. Health Check ........................................... |pending-health|
20. Google Authentication ......................... |pending-google-auth|
21. Data Format .............................................. |pending-data|
22. Health Check ........................................... |pending-health|
==============================================================================
REQUIREMENTS *pending-requirements*
@ -148,6 +149,15 @@ COMMANDS *pending-commands*
Populate the quickfix list with all tasks that are overdue or due today.
Open the list with |:copen| to navigate to each task's category.
*:Pending-auth*
:Pending auth
Authorize pending.nvim to access Google services (Tasks and Calendar).
Prompts with |vim.ui.select| to choose gtasks, gcal, or both — all
options run the same combined OAuth flow and produce a single shared
token file. If no credentials are configured, the setup wizard runs
first to collect a client ID and secret.
See |pending-google-auth| for full details.
*:Pending-gtasks*
:Pending gtasks {action}
Run a Google Tasks action. An explicit action is required.
@ -156,13 +166,11 @@ COMMANDS *pending-commands*
`sync` Push local changes then pull remote changes.
`push` Push local changes to Google Tasks only.
`pull` Pull remote changes from Google Tasks only.
`auth` Run the OAuth authorization flow.
Examples: >vim
:Pending gtasks sync " push then pull
:Pending gtasks push " push local → Google Tasks
:Pending gtasks pull " pull Google Tasks → local
:Pending gtasks auth " authorize
<
Tab completion after `:Pending gtasks ` lists available actions.
@ -174,11 +182,9 @@ COMMANDS *pending-commands*
Actions: ~
`push` Push tasks with due dates to Google Calendar.
`auth` Run the OAuth authorization flow.
Examples: >vim
:Pending gcal push " push to Google Calendar
:Pending gcal auth " authorize
<
Tab completion after `:Pending gcal ` lists available actions.
@ -920,7 +926,6 @@ Each module returns a table conforming to the backend interface: >lua
---@class pending.SyncBackend
---@field name string
---@field auth fun(): nil
---@field push? fun(): nil
---@field pull? fun(): nil
---@field sync? fun(): nil
@ -929,15 +934,17 @@ Each module returns a table conforming to the backend interface: >lua
Required fields: ~
{name} Backend identifier (matches the filename).
{sync} Main sync action. Called by `:Pending <name>`.
{auth} Authorization flow. Called by `:Pending <name> auth`.
Optional fields: ~
{push} Push-only action. Called by `:Pending <name> push`.
{pull} Pull-only action. Called by `:Pending <name> pull`.
{sync} Main sync action. Called by `:Pending <name> sync`.
{health} Called by `:checkhealth pending` to report backend-specific
diagnostics (e.g. checking for external tools).
Note: authorization is not a per-backend action. Use `:Pending auth` to
authenticate all Google backends at once. See |pending-google-auth|.
Backend-specific configuration goes under `sync.<name>` in |pending-config|.
==============================================================================
@ -957,7 +964,7 @@ Configuration: >lua
<
No configuration is required to get started — bundled OAuth credentials are
used by default. Run `:Pending gcal auth` and the browser opens immediately.
used by default. Run `:Pending auth` and the browser opens immediately.
*pending.GcalConfig*
Fields: ~
@ -983,15 +990,8 @@ Credentials are resolved in order:
3. Bundled credentials shipped with the plugin (always available).
OAuth flow: ~
On the first `:Pending gcal` call the plugin detects that no refresh token
exists and opens the Google authorization URL in the browser using
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used. After
the user grants consent, the
authorization code is exchanged for tokens and the refresh token is stored at
`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs
use the stored refresh token and refresh the access token automatically when
it is about to expire.
See |pending-google-auth|. Tokens are shared with the gtasks backend and
stored at `stdpath('data')/pending/google_tokens.json`.
`:Pending gcal push` behavior: ~
For each task in the store:
@ -1020,7 +1020,7 @@ Configuration: >lua
<
No configuration is required to get started — bundled OAuth credentials are
used by default. Run `:Pending gtasks auth` and the browser opens immediately.
used by default. Run `:Pending auth` and the browser opens immediately.
*pending.GtasksConfig*
Fields: ~
@ -1043,9 +1043,8 @@ Credential resolution: ~
Same three-tier resolution as the gcal backend (see |pending-gcal|).
OAuth flow: ~
Same PKCE flow as the gcal backend; listens on port 18393. Tokens are stored
at `stdpath('data')/pending/gtasks_tokens.json`. Run `:Pending gtasks auth`
to authorize; subsequent syncs refresh the token automatically.
See |pending-google-auth|. Tokens are shared with the gcal backend and
stored at `stdpath('data')/pending/google_tokens.json`.
`:Pending gtasks` actions: ~
@ -1077,6 +1076,42 @@ the remainder is ignored.
Recurrence (`rec:`) is stored in notes for round-tripping but is not
expanded by Google Tasks (GTasks has no recurrence API).
==============================================================================
GOOGLE AUTHENTICATION *pending-google-auth*
Both the gcal and gtasks backends share a single OAuth client with combined
scopes (`tasks` + `calendar`). One authorization flow covers both services
and produces one token file.
:Pending auth ~
Prompts with |vim.ui.select| offering three options: `gtasks`, `gcal`, and
`both`. All three options run the identical combined OAuth flow — the choice
is informational only. If no real credentials are configured (i.e. bundled
placeholders are in use), the setup wizard runs first to collect a client ID
and client secret before opening the browser.
OAuth flow: ~
A PKCE (Proof Key for Code Exchange) flow is used:
1. A random 64-character `code_verifier` is generated.
2. Its SHA-256 hash is base64url-encoded as the `code_challenge`.
3. The Google authorization URL is opened in the browser via |vim.ui.open()|.
4. A temporary TCP server on port 18392 waits up to 120 seconds for the
OAuth redirect.
5. The authorization code is exchanged for tokens via `curl`.
6. The refresh token is written to
`stdpath('data')/pending/google_tokens.json` with mode `600`.
7. Subsequent syncs refresh the access token automatically when it is about
to expire (within 60 seconds of the `expires_in` window).
Credential resolution: ~
Credentials are resolved in order for the `google` config key:
1. `client_id` + `client_secret` under `sync.google` (highest priority).
2. JSON file at `sync.google.credentials_path` or the default path
`stdpath('data')/pending/google_credentials.json`.
3. Bundled placeholder credentials (always available; trigger setup wizard).
The `installed` wrapper format from the Google Cloud Console is accepted.
==============================================================================
DATA FORMAT *pending-data*

View file

@ -828,6 +828,24 @@ function M.edit(id_str, rest)
log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
end
---@return nil
function M.auth()
local oauth = require('pending.sync.oauth')
vim.ui.select({ 'gtasks', 'gcal', 'both' }, {
prompt = 'Authenticate:',
}, function(choice)
if not choice then
return
end
local creds = oauth.google_client:resolve_credentials()
if creds.client_id == oauth.BUNDLED_CLIENT_ID then
oauth.google_client:setup()
else
oauth.google_client:auth()
end
end)
end
---@param args string
---@return nil
function M.command(args)
@ -841,6 +859,8 @@ function M.command(args)
elseif cmd == 'edit' then
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest)
elseif cmd == 'auth' then
M.auth()
elseif SYNC_BACKEND_SET[cmd] then
local action = rest:match('^(%S+)')
run_sync(cmd, action)

View file

@ -7,14 +7,6 @@ local M = {}
M.name = 'gcal'
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
local SCOPE = 'https://www.googleapis.com/auth/calendar'
local client = oauth.new({
name = 'gcal',
scope = SCOPE,
port = 18392,
config_key = 'gcal',
})
---@param access_token string
---@return table<string, string>? name_to_id
@ -139,15 +131,15 @@ end
---@param callback fun(access_token: string): nil
local function with_token(callback)
oauth.async(function()
local token = client:get_access_token()
local token = oauth.google_client:get_access_token()
if not token then
client:auth(function()
oauth.google_client:auth(function()
oauth.async(function()
local fresh = client:get_access_token()
local fresh = oauth.google_client:get_access_token()
if fresh then
callback(fresh)
else
log.error(client.name .. ': authorization failed or was cancelled')
log.error(oauth.google_client.name .. ': authorization failed or was cancelled')
end
end)
end)
@ -157,14 +149,6 @@ local function with_token(callback)
end)
end
function M.setup()
client:setup()
end
function M.auth()
client:auth()
end
function M.push()
with_token(function(access_token)
local calendars, cal_err = get_all_calendars(access_token)

View file

@ -7,14 +7,6 @@ local M = {}
M.name = 'gtasks'
local BASE_URL = 'https://tasks.googleapis.com/tasks/v1'
local SCOPE = 'https://www.googleapis.com/auth/tasks'
local client = oauth.new({
name = 'gtasks',
scope = SCOPE,
port = 18393,
config_key = 'gtasks',
})
---@param access_token string
---@return table<string, string>? name_to_id
@ -355,15 +347,15 @@ end
---@param callback fun(access_token: string): nil
local function with_token(callback)
oauth.async(function()
local token = client:get_access_token()
local token = oauth.google_client:get_access_token()
if not token then
client:auth(function()
oauth.google_client:auth(function()
oauth.async(function()
local fresh = client:get_access_token()
local fresh = oauth.google_client:get_access_token()
if fresh then
callback(fresh)
else
log.error(client.name .. ': authorization failed or was cancelled')
log.error(oauth.google_client.name .. ': authorization failed or was cancelled')
end
end)
end)
@ -373,14 +365,6 @@ local function with_token(callback)
end)
end
function M.setup()
client:setup()
end
function M.auth()
client:auth()
end
function M.push()
with_token(function(access_token)
local tasklists, s, now_ts = sync_setup(access_token)
@ -462,11 +446,11 @@ M._gtask_to_fields = gtask_to_fields
---@return nil
function M.health()
oauth.health(M.name)
local tokens = client:load_tokens()
local tokens = oauth.google_client:load_tokens()
if tokens and tokens.refresh_token then
vim.health.ok('gtasks tokens found')
else
vim.health.info('no gtasks tokens — run :Pending gtasks auth')
vim.health.info('no gtasks tokens — run :Pending auth')
end
end

View file

@ -501,5 +501,13 @@ end
M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET
M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
M.google_client = M.new({
name = 'google',
scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar',
port = 18392,
config_key = 'google',
})
return M

View file

@ -167,7 +167,7 @@ end, {
nargs = '*',
complete = function(arg_lead, cmd_line)
local pending = require('pending')
local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'undo' }
local subcmds = { 'add', 'archive', 'auth', 'due', 'edit', 'filter', 'undo' }
for _, b in ipairs(pending.sync_backends()) do
table.insert(subcmds, b)
end

View file

@ -73,15 +73,14 @@ describe('sync', function()
assert.is_true(called)
end)
it('routes auth action', function()
it('routes auth command', function()
local called = false
local gcal = require('pending.sync.gcal')
local orig_auth = gcal.auth
gcal.auth = function()
local orig_auth = pending.auth
pending.auth = function()
called = true
end
pending.command('gcal auth')
gcal.auth = orig_auth
pending.command('auth')
pending.auth = orig_auth
assert.is_true(called)
end)
end)
@ -102,11 +101,6 @@ describe('sync', function()
assert.are.equal('gcal', gcal.name)
end)
it('has auth function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.auth))
end)
it('has push function', function()
local gcal = require('pending.sync.gcal')
assert.are.equal('function', type(gcal.push))