refactor(sync): extract shared OAuth into oauth.lua

Problem: `gcal.lua` and `gtasks.lua` duplicated ~250 lines of identical
OAuth code (token management, PKCE flow, credential loading, curl
helpers, url encoding).

Solution: Extract a shared `OAuthClient` metatable in `oauth.lua` with
module-level utilities and instance methods. Both backends now delegate
all OAuth to `oauth.new()`. Skip `oauth` in `health.lua` backend
discovery by checking for a `name` field.
This commit is contained in:
Barrett Ruth 2026-03-05 01:17:47 -05:00
parent 6e21f883f6
commit 25c8bd4eb0
5 changed files with 686 additions and 729 deletions

View file

@ -1,3 +1,4 @@
local oauth = require('pending.sync.oauth')
local config = require('pending.config')
local M = {}
@ -5,353 +6,21 @@ local M = {}
M.name = 'gtasks'
local BASE_URL = 'https://tasks.googleapis.com/tasks/v1'
local TOKEN_URL = 'https://oauth2.googleapis.com/token'
local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
local SCOPE = 'https://www.googleapis.com/auth/tasks'
---@class pending.GtasksCredentials
---@field client_id string
---@field client_secret string
---@field redirect_uris? string[]
---@class pending.GtasksTokens
---@field access_token string
---@field refresh_token string
---@field expires_in? integer
---@field obtained_at? integer
---@return table<string, any>
local function gtasks_config()
local cfg = config.get()
return (cfg.sync and cfg.sync.gtasks) or {}
end
---@return string
local function token_path()
return vim.fn.stdpath('data') .. '/pending/gtasks_tokens.json'
end
---@return string
local function credentials_path()
local gc = gtasks_config()
return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gtasks_credentials.json')
end
---@param path string
---@return table?
local function load_json_file(path)
local f = io.open(path, 'r')
if not f then
return nil
end
local content = f:read('*a')
f:close()
if content == '' then
return nil
end
local ok, decoded = pcall(vim.json.decode, content)
if not ok then
return nil
end
return decoded
end
---@param path string
---@param data table
---@return boolean
local function save_json_file(path, data)
local dir = vim.fn.fnamemodify(path, ':h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
local f = io.open(path, 'w')
if not f then
return false
end
f:write(vim.json.encode(data))
f:close()
vim.fn.setfperm(path, 'rw-------')
return true
end
---@return pending.GtasksCredentials?
local function load_credentials()
local creds = load_json_file(credentials_path())
if not creds then
return nil
end
if creds.installed then
return creds.installed --[[@as pending.GtasksCredentials]]
end
return creds --[[@as pending.GtasksCredentials]]
end
---@return pending.GtasksTokens?
local function load_tokens()
return load_json_file(token_path()) --[[@as pending.GtasksTokens?]]
end
---@param tokens pending.GtasksTokens
---@return boolean
local function save_tokens(tokens)
return save_json_file(token_path(), tokens)
end
---@param str string
---@return string
local function url_encode(str)
return (
str:gsub('([^%w%-%.%_%~])', function(c)
return string.format('%%%02X', string.byte(c))
end)
)
end
---@param method string
---@param url string
---@param headers? string[]
---@param body? string
---@return table? result
---@return string? err
local function curl_request(method, url, headers, body)
local args = { 'curl', '-s', '-X', method }
for _, h in ipairs(headers or {}) do
table.insert(args, '-H')
table.insert(args, h)
end
if body then
table.insert(args, '-d')
table.insert(args, body)
end
table.insert(args, url)
local result = vim.system(args, { text = true }):wait()
if result.code ~= 0 then
return nil, 'curl failed: ' .. (result.stderr or '')
end
if not result.stdout or result.stdout == '' then
return {}, nil
end
local ok, decoded = pcall(vim.json.decode, result.stdout)
if not ok then
return nil, 'failed to parse response: ' .. result.stdout
end
if decoded.error then
return nil, 'API error: ' .. (decoded.error.message or vim.json.encode(decoded.error))
end
return decoded, nil
end
---@param access_token string
---@return string[]
local function auth_headers(access_token)
return {
'Authorization: Bearer ' .. access_token,
'Content-Type: application/json',
}
end
---@param creds pending.GtasksCredentials
---@param tokens pending.GtasksTokens
---@return pending.GtasksTokens?
local function refresh_access_token(creds, tokens)
local body = 'client_id='
.. url_encode(creds.client_id)
.. '&client_secret='
.. url_encode(creds.client_secret)
.. '&grant_type=refresh_token'
.. '&refresh_token='
.. url_encode(tokens.refresh_token)
local result = vim
.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
:wait()
if result.code ~= 0 then
return nil
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
return nil
end
tokens.access_token = decoded.access_token --[[@as string]]
tokens.expires_in = decoded.expires_in --[[@as integer?]]
tokens.obtained_at = os.time()
save_tokens(tokens)
return tokens
end
---@return string?
local function get_access_token()
local creds = load_credentials()
if not creds then
vim.notify(
'pending.nvim: No Google credentials found at ' .. credentials_path(),
vim.log.levels.ERROR
)
return nil
end
local tokens = load_tokens()
if not tokens or not tokens.refresh_token then
M.auth()
tokens = load_tokens()
if not tokens then
return nil
end
end
local now = os.time()
local obtained = tokens.obtained_at or 0
local expires = tokens.expires_in or 3600
if now - obtained > expires - 60 then
tokens = refresh_access_token(creds, tokens)
if not tokens then
vim.notify('pending.nvim: Failed to refresh access token.', vim.log.levels.ERROR)
return nil
end
end
return tokens.access_token
end
function M.auth()
local creds = load_credentials()
if not creds then
vim.notify(
'pending.nvim: No Google credentials found at ' .. credentials_path(),
vim.log.levels.ERROR
)
return
end
local port = 18393
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
local verifier = {}
math.randomseed(os.time())
for _ = 1, 64 do
local idx = math.random(1, #verifier_chars)
table.insert(verifier, verifier_chars:sub(idx, idx))
end
local code_verifier = table.concat(verifier)
local sha_pipe = vim
.system({
'sh',
'-c',
'printf "%s" "'
.. code_verifier
.. '" | openssl dgst -sha256 -binary | openssl base64 -A | tr "+/" "-_" | tr -d "="',
}, { text = true })
:wait()
local code_challenge = sha_pipe.stdout or ''
local auth_url = AUTH_URL
.. '?client_id='
.. url_encode(creds.client_id)
.. '&redirect_uri='
.. url_encode('http://127.0.0.1:' .. port)
.. '&response_type=code'
.. '&scope='
.. url_encode(SCOPE)
.. '&access_type=offline'
.. '&prompt=consent'
.. '&code_challenge='
.. url_encode(code_challenge)
.. '&code_challenge_method=S256'
vim.ui.open(auth_url)
vim.notify('pending.nvim: Opening browser for Google authorization...')
local server = vim.uv.new_tcp()
server:bind('127.0.0.1', port)
server:listen(1, function(err)
if err then
return
end
local client = vim.uv.new_tcp()
server:accept(client)
client:read_start(function(read_err, data)
if read_err or not data then
return
end
local code = data:match('[?&]code=([^&%s]+)')
local response_body = code
and '<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>'
or '<html><body><h1>Authorization failed</h1></body></html>'
local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n'
.. response_body
client:write(http_response, function()
client:shutdown(function()
client:close()
end)
end)
server:close()
if code then
vim.schedule(function()
M._exchange_code(creds, code, code_verifier, port)
end)
end
end)
end)
end
---@param creds pending.GtasksCredentials
---@param code string
---@param code_verifier string
---@param port integer
function M._exchange_code(creds, code, code_verifier, port)
local body = 'client_id='
.. url_encode(creds.client_id)
.. '&client_secret='
.. url_encode(creds.client_secret)
.. '&code='
.. url_encode(code)
.. '&code_verifier='
.. url_encode(code_verifier)
.. '&grant_type=authorization_code'
.. '&redirect_uri='
.. url_encode('http://127.0.0.1:' .. port)
local result = vim
.system({
'curl',
'-s',
'-X',
'POST',
'-H',
'Content-Type: application/x-www-form-urlencoded',
'-d',
body,
TOKEN_URL,
}, { text = true })
:wait()
if result.code ~= 0 then
vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR)
return
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
vim.notify('pending.nvim: Invalid token response.', vim.log.levels.ERROR)
return
end
decoded.obtained_at = os.time()
save_tokens(decoded)
vim.notify('pending.nvim: Google Tasks authorized successfully.')
end
local client = oauth.new({
name = 'gtasks',
scope = SCOPE,
port = 18393,
config_key = 'gtasks',
})
---@param access_token string
---@return table<string, string>? name_to_id
---@return string? err
local function get_all_tasklists(access_token)
local data, err = curl_request('GET', BASE_URL .. '/users/@me/lists', auth_headers(access_token))
local data, err =
oauth.curl_request('GET', BASE_URL .. '/users/@me/lists', oauth.auth_headers(access_token))
if err then
return nil, err
end
@ -372,8 +41,12 @@ local function find_or_create_tasklist(access_token, name, existing)
return existing[name], nil
end
local body = vim.json.encode({ title = name })
local created, err =
curl_request('POST', BASE_URL .. '/users/@me/lists', auth_headers(access_token), body)
local created, err = oauth.curl_request(
'POST',
BASE_URL .. '/users/@me/lists',
oauth.auth_headers(access_token),
body
)
if err then
return nil, err
end
@ -391,9 +64,9 @@ end
local function list_gtasks(access_token, list_id)
local url = BASE_URL
.. '/lists/'
.. url_encode(list_id)
.. oauth.url_encode(list_id)
.. '/tasks?showCompleted=true&showHidden=true'
local data, err = curl_request('GET', url, auth_headers(access_token))
local data, err = oauth.curl_request('GET', url, oauth.auth_headers(access_token))
if err then
return nil, err
end
@ -406,10 +79,10 @@ end
---@return string? task_id
---@return string? err
local function create_gtask(access_token, list_id, body)
local data, err = curl_request(
local data, err = oauth.curl_request(
'POST',
BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks',
auth_headers(access_token),
BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks',
oauth.auth_headers(access_token),
vim.json.encode(body)
)
if err then
@ -424,10 +97,14 @@ end
---@param body table
---@return string? err
local function update_gtask(access_token, list_id, task_id, body)
local _, err = curl_request(
local _, err = oauth.curl_request(
'PATCH',
BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id),
auth_headers(access_token),
BASE_URL
.. '/lists/'
.. oauth.url_encode(list_id)
.. '/tasks/'
.. oauth.url_encode(task_id),
oauth.auth_headers(access_token),
vim.json.encode(body)
)
return err
@ -438,10 +115,14 @@ end
---@param task_id string
---@return string? err
local function delete_gtask(access_token, list_id, task_id)
local _, err = curl_request(
local _, err = oauth.curl_request(
'DELETE',
BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id),
auth_headers(access_token)
BASE_URL
.. '/lists/'
.. oauth.url_encode(list_id)
.. '/tasks/'
.. oauth.url_encode(task_id),
oauth.auth_headers(access_token)
)
return err
end
@ -665,7 +346,7 @@ end
---@return pending.Store? store
---@return string? now_ts
local function sync_setup()
local access_token = get_access_token()
local access_token = client:get_access_token()
if not access_token then
return nil
end
@ -679,6 +360,10 @@ local function sync_setup()
return access_token, tasklists, s, now_ts
end
function M.auth()
client:auth()
end
function M.push()
local access_token, tasklists, s, now_ts = sync_setup()
if not access_token then
@ -746,17 +431,8 @@ M._gtask_to_fields = gtask_to_fields
---@return nil
function M.health()
if vim.fn.executable('curl') == 1 then
vim.health.ok('curl found (required for gtasks sync)')
else
vim.health.warn('curl not found (needed for gtasks sync)')
end
if vim.fn.executable('openssl') == 1 then
vim.health.ok('openssl found (required for gtasks OAuth PKCE)')
else
vim.health.warn('openssl not found (needed for gtasks OAuth)')
end
local tokens = load_tokens()
oauth.health(M.name)
local tokens = client:load_tokens()
if tokens and tokens.refresh_token then
vim.health.ok('gtasks tokens found')
else