diff --git a/doc/pending.txt b/doc/pending.txt index d3eb03b..08c6315 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -608,8 +608,11 @@ loads: >lua sync = { gcal = { calendar = 'Pendings', + credentials_path = '/path/to/client_secret.json', + }, + gtasks = { + credentials_path = '/path/to/client_secret.json', }, - gtasks = {}, }, } < @@ -680,9 +683,7 @@ Fields: ~ {sync} (table, default: {}) *pending.SyncConfig* Sync backend configuration. Each key is a backend name and the value is the backend-specific config - table. Built-in backends: `gcal`, `gtasks`. Both - ship bundled OAuth credentials so no setup is - needed beyond `:Pending auth`. + table. Currently only `gcal` is built-in. {icons} (table) *pending.Icons* Icon characters displayed in the buffer. The @@ -933,14 +934,12 @@ Configuration: >lua sync = { gcal = { calendar = 'Pendings', + credentials_path = '/path/to/client_secret.json', }, }, } < -No configuration is required to get started — bundled OAuth credentials are -used by default. Run `:Pending gcal auth` and the browser opens immediately. - *pending.GcalConfig* Fields: ~ {calendar} (string, default: 'Pendings') @@ -948,27 +947,13 @@ Fields: ~ with this name does not exist it is created automatically on the first sync. - {client_id} (string, optional) - OAuth client ID. When set together with - {client_secret}, these take priority over the - credentials file and bundled defaults. - - {client_secret} (string, optional) - OAuth client secret. See {client_id}. - - {credentials_path} (string, optional) - Path to an OAuth client secret JSON file downloaded + {credentials_path} (string) + Path to the OAuth client secret JSON file downloaded from the Google Cloud Console. Default: `stdpath('data')..'/pending/gcal_credentials.json'`. The file may be in the `installed` wrapper format that Google provides or as a bare credentials object. -Credential resolution: ~ -Credentials are resolved in order: -1. `client_id` + `client_secret` config fields (highest priority). -2. JSON file at `credentials_path` (or the default path). -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 @@ -1003,34 +988,22 @@ created automatically on first sync. Configuration: >lua vim.g.pending = { sync = { - gtasks = {}, + gtasks = { + credentials_path = '/path/to/client_secret.json', + }, }, } < -No configuration is required to get started — bundled OAuth credentials are -used by default. Run `:Pending gtasks auth` and the browser opens immediately. - *pending.GtasksConfig* Fields: ~ - {client_id} (string, optional) - OAuth client ID. When set together with - {client_secret}, these take priority over the - credentials file and bundled defaults. - - {client_secret} (string, optional) - OAuth client secret. See {client_id}. - - {credentials_path} (string, optional) - Path to an OAuth client secret JSON file downloaded + {credentials_path} (string) + Path to the OAuth client secret JSON file downloaded from the Google Cloud Console. Default: `stdpath('data')..'/pending/gtasks_credentials.json'`. Accepts the `installed` wrapper format or a bare credentials object. -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` diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 263cc8c..58da035 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -9,13 +9,9 @@ ---@class pending.GcalConfig ---@field calendar? string ---@field credentials_path? string ----@field client_id? string ----@field client_secret? string ---@class pending.GtasksConfig ---@field credentials_path? string ----@field client_id? string ----@field client_secret? string ---@class pending.SyncConfig ---@field gcal? pending.GcalConfig diff --git a/lua/pending/health.lua b/lua/pending/health.lua index 0f1bad8..ca28298 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -65,7 +65,7 @@ function M.check() for _, path in ipairs(sync_paths) do local name = vim.fn.fnamemodify(path, ':t:r') local bok, backend = pcall(require, 'pending.sync.' .. name) - if bok and backend.name and type(backend.health) == 'function' then + if bok and type(backend.health) == 'function' then vim.health.start('pending.nvim: sync/' .. name) backend.health() end diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 9158ca1..2ec96a8 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,32 +1,361 @@ local config = require('pending.config') -local oauth = require('pending.sync.oauth') local M = {} M.name = 'gcal' local BASE_URL = 'https://www.googleapis.com/calendar/v3' +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/calendar' -local client = oauth.new({ - name = 'gcal', - scope = SCOPE, - port = 18392, - config_key = 'gcal', -}) +---@class pending.GcalCredentials +---@field client_id string +---@field client_secret string +---@field redirect_uris? string[] +---@class pending.GcalTokens +---@field access_token string +---@field refresh_token string +---@field expires_in? integer +---@field obtained_at? integer + +---@return table +local function gcal_config() + local cfg = config.get() + return (cfg.sync and cfg.sync.gcal) or {} +end + +---@return string +local function token_path() + return vim.fn.stdpath('data') .. '/pending/gcal_tokens.json' +end + +---@return string +local function credentials_path() + local gc = gcal_config() + return gc.credentials_path or (vim.fn.stdpath('data') .. '/pending/gcal_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.GcalCredentials? +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.GcalCredentials]] + end + return creds --[[@as pending.GcalCredentials]] +end + +---@return pending.GcalTokens? +local function load_tokens() + return load_json_file(token_path()) --[[@as pending.GcalTokens?]] +end + +---@param tokens pending.GcalTokens +---@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.GcalCredentials +---@param tokens pending.GcalTokens +---@return pending.GcalTokens? +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 Calendar 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 Calendar credentials found at ' .. credentials_path(), + vim.log.levels.ERROR + ) + return + end + + local port = 18392 + 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 '

Authorization successful

You can close this tab.

' + or '

Authorization failed

' + 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.GcalCredentials +---@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 Calendar authorized successfully.') +end + +---@param access_token string ---@return string? calendar_id ---@return string? err local function find_or_create_calendar(access_token) - local cfg = config.get() - local gc = (cfg.sync and cfg.sync.gcal) or {} + local gc = gcal_config() local cal_name = gc.calendar or 'Pendings' - local data, err = oauth.curl_request( - 'GET', - BASE_URL .. '/users/me/calendarList', - oauth.auth_headers(access_token) - ) + local data, err = + curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token)) if err then return nil, err end @@ -39,7 +368,7 @@ local function find_or_create_calendar(access_token) local body = vim.json.encode({ summary = cal_name }) local created, create_err = - oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body) + curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body) if create_err then return nil, create_err end @@ -71,10 +400,10 @@ local function create_event(access_token, calendar_id, task) private = { taskId = tostring(task.id) }, }, } - local data, err = oauth.curl_request( + local data, err = curl_request( 'POST', - BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events', - oauth.auth_headers(access_token), + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events', + auth_headers(access_token), vim.json.encode(event) ) if err then @@ -94,14 +423,10 @@ local function update_event(access_token, calendar_id, event_id, task) start = { date = task.due }, ['end'] = { date = next_day(task.due or '') }, } - local _, err = oauth.curl_request( + local _, err = curl_request( 'PATCH', - BASE_URL - .. '/calendars/' - .. oauth.url_encode(calendar_id) - .. '/events/' - .. oauth.url_encode(event_id), - oauth.auth_headers(access_token), + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), + auth_headers(access_token), vim.json.encode(event) ) return err @@ -112,24 +437,16 @@ end ---@param event_id string ---@return string? err local function delete_event(access_token, calendar_id, event_id) - local _, err = oauth.curl_request( + local _, err = curl_request( 'DELETE', - BASE_URL - .. '/calendars/' - .. oauth.url_encode(calendar_id) - .. '/events/' - .. oauth.url_encode(event_id), - oauth.auth_headers(access_token) + BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id), + auth_headers(access_token) ) return err end -function M.auth() - client:auth() -end - function M.sync() - local access_token = client:get_access_token() + local access_token = get_access_token() if not access_token then return end @@ -200,7 +517,16 @@ end ---@return nil function M.health() - oauth.health(M.name) + if vim.fn.executable('curl') == 1 then + vim.health.ok('curl found (required for gcal sync)') + else + vim.health.warn('curl not found (needed for gcal sync)') + end + if vim.fn.executable('openssl') == 1 then + vim.health.ok('openssl found (required for gcal OAuth PKCE)') + else + vim.health.warn('openssl not found (needed for gcal OAuth)') + end end return M diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index f31de99..7476ee6 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -1,26 +1,357 @@ local config = require('pending.config') -local oauth = require('pending.sync.oauth') 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' -local client = oauth.new({ - name = 'gtasks', - scope = SCOPE, - port = 18393, - config_key = 'gtasks', -}) +---@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 +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 '

Authorization successful

You can close this tab.

' + or '

Authorization failed

' + 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 ---@param access_token string ---@return table? name_to_id ---@return string? err local function get_all_tasklists(access_token) - local data, err = - oauth.curl_request('GET', BASE_URL .. '/users/@me/lists', oauth.auth_headers(access_token)) + local data, err = curl_request('GET', BASE_URL .. '/users/@me/lists', auth_headers(access_token)) if err then return nil, err end @@ -41,12 +372,8 @@ 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 = oauth.curl_request( - 'POST', - BASE_URL .. '/users/@me/lists', - oauth.auth_headers(access_token), - body - ) + local created, err = + curl_request('POST', BASE_URL .. '/users/@me/lists', auth_headers(access_token), body) if err then return nil, err end @@ -64,9 +391,9 @@ end local function list_gtasks(access_token, list_id) local url = BASE_URL .. '/lists/' - .. oauth.url_encode(list_id) + .. url_encode(list_id) .. '/tasks?showCompleted=true&showHidden=true' - local data, err = oauth.curl_request('GET', url, oauth.auth_headers(access_token)) + local data, err = curl_request('GET', url, auth_headers(access_token)) if err then return nil, err end @@ -79,10 +406,10 @@ end ---@return string? task_id ---@return string? err local function create_gtask(access_token, list_id, body) - local data, err = oauth.curl_request( + local data, err = curl_request( 'POST', - BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks', - oauth.auth_headers(access_token), + BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks', + auth_headers(access_token), vim.json.encode(body) ) if err then @@ -97,10 +424,10 @@ end ---@param body table ---@return string? err local function update_gtask(access_token, list_id, task_id, body) - local _, err = oauth.curl_request( + local _, err = curl_request( 'PATCH', - BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks/' .. oauth.url_encode(task_id), - oauth.auth_headers(access_token), + BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), + auth_headers(access_token), vim.json.encode(body) ) return err @@ -111,10 +438,10 @@ end ---@param task_id string ---@return string? err local function delete_gtask(access_token, list_id, task_id) - local _, err = oauth.curl_request( + local _, err = curl_request( 'DELETE', - BASE_URL .. '/lists/' .. oauth.url_encode(list_id) .. '/tasks/' .. oauth.url_encode(task_id), - oauth.auth_headers(access_token) + BASE_URL .. '/lists/' .. url_encode(list_id) .. '/tasks/' .. url_encode(task_id), + auth_headers(access_token) ) return err end @@ -338,7 +665,7 @@ end ---@return pending.Store? store ---@return string? now_ts local function sync_setup() - local access_token = client:get_access_token() + local access_token = get_access_token() if not access_token then return nil end @@ -352,10 +679,6 @@ 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 @@ -423,8 +746,17 @@ M._gtask_to_fields = gtask_to_fields ---@return nil function M.health() - oauth.health(M.name) - local tokens = client:load_tokens() + 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() if tokens and tokens.refresh_token then vim.health.ok('gtasks tokens found') else diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua deleted file mode 100644 index 7dc5ede..0000000 --- a/lua/pending/sync/oauth.lua +++ /dev/null @@ -1,380 +0,0 @@ -local config = require('pending.config') - -local TOKEN_URL = 'https://oauth2.googleapis.com/token' -local AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' - -local BUNDLED_CLIENT_ID = 'PLACEHOLDER' -local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER' - ----@class pending.OAuthCredentials ----@field client_id string ----@field client_secret string - ----@class pending.OAuthTokens ----@field access_token string ----@field refresh_token string ----@field expires_in? integer ----@field obtained_at? integer - ----@class pending.OAuthClient ----@field name string ----@field scope string ----@field port integer ----@field config_key string -local OAuthClient = {} -OAuthClient.__index = OAuthClient - ----@class pending.oauth -local M = {} - ----@param str string ----@return string -function M.url_encode(str) - return ( - str:gsub('([^%w%-%.%_%~])', function(c) - return string.format('%%%02X', string.byte(c)) - end) - ) -end - ----@param path string ----@return table? -function M.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 -function M.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 - ----@param method string ----@param url string ----@param headers? string[] ----@param body? string ----@return table? result ----@return string? err -function M.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[] -function M.auth_headers(access_token) - return { - 'Authorization: Bearer ' .. access_token, - 'Content-Type: application/json', - } -end - ----@param backend_name string ----@return nil -function M.health(backend_name) - if vim.fn.executable('curl') == 1 then - vim.health.ok('curl found (required for ' .. backend_name .. ' sync)') - else - vim.health.warn('curl not found (needed for ' .. backend_name .. ' sync)') - end - if vim.fn.executable('openssl') == 1 then - vim.health.ok('openssl found (required for ' .. backend_name .. ' OAuth PKCE)') - else - vim.health.warn('openssl not found (needed for ' .. backend_name .. ' OAuth)') - end -end - ----@return string -function OAuthClient:token_path() - return vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_tokens.json' -end - ----@return pending.OAuthCredentials -function OAuthClient:resolve_credentials() - local cfg = config.get() - local backend_cfg = (cfg.sync and cfg.sync[self.config_key]) or {} - - if backend_cfg.client_id and backend_cfg.client_secret then - return { - client_id = backend_cfg.client_id, - client_secret = backend_cfg.client_secret, - } - end - - local cred_path = backend_cfg.credentials_path - or (vim.fn.stdpath('data') .. '/pending/' .. self.name .. '_credentials.json') - local creds = M.load_json_file(cred_path) - if creds then - if creds.installed then - creds = creds.installed - end - if creds.client_id and creds.client_secret then - return creds --[[@as pending.OAuthCredentials]] - end - end - - return { - client_id = BUNDLED_CLIENT_ID, - client_secret = BUNDLED_CLIENT_SECRET, - } -end - ----@return pending.OAuthTokens? -function OAuthClient:load_tokens() - return M.load_json_file(self:token_path()) --[[@as pending.OAuthTokens?]] -end - ----@param tokens pending.OAuthTokens ----@return boolean -function OAuthClient:save_tokens(tokens) - return M.save_json_file(self:token_path(), tokens) -end - ----@param creds pending.OAuthCredentials ----@param tokens pending.OAuthTokens ----@return pending.OAuthTokens? -function OAuthClient:refresh_access_token(creds, tokens) - local body = 'client_id=' - .. M.url_encode(creds.client_id) - .. '&client_secret=' - .. M.url_encode(creds.client_secret) - .. '&grant_type=refresh_token' - .. '&refresh_token=' - .. M.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() - self:save_tokens(tokens) - return tokens -end - ----@return string? -function OAuthClient:get_access_token() - local creds = self:resolve_credentials() - local tokens = self:load_tokens() - if not tokens or not tokens.refresh_token then - self:auth() - tokens = self: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 = self: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 - ----@return nil -function OAuthClient:auth() - local creds = self:resolve_credentials() - local port = self.port - - 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=' - .. M.url_encode(creds.client_id) - .. '&redirect_uri=' - .. M.url_encode('http://127.0.0.1:' .. port) - .. '&response_type=code' - .. '&scope=' - .. M.url_encode(self.scope) - .. '&access_type=offline' - .. '&prompt=consent' - .. '&code_challenge=' - .. M.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 conn = vim.uv.new_tcp() - server:accept(conn) - conn: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 '

Authorization successful

You can close this tab.

' - or '

Authorization failed

' - local http_response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n' - .. response_body - conn:write(http_response, function() - conn:shutdown(function() - conn:close() - end) - end) - server:close() - if code then - vim.schedule(function() - self:_exchange_code(creds, code, code_verifier, port) - end) - end - end) - end) -end - ----@param creds pending.OAuthCredentials ----@param code string ----@param code_verifier string ----@param port integer ----@return nil -function OAuthClient:_exchange_code(creds, code, code_verifier, port) - local body = 'client_id=' - .. M.url_encode(creds.client_id) - .. '&client_secret=' - .. M.url_encode(creds.client_secret) - .. '&code=' - .. M.url_encode(code) - .. '&code_verifier=' - .. M.url_encode(code_verifier) - .. '&grant_type=authorization_code' - .. '&redirect_uri=' - .. M.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() - self:save_tokens(decoded) - vim.notify('pending.nvim: ' .. self.name .. ' authorized successfully.') -end - ----@param opts { name: string, scope: string, port: integer, config_key: string } ----@return pending.OAuthClient -function M.new(opts) - return setmetatable({ - name = opts.name, - scope = opts.scope, - port = opts.port, - config_key = opts.config_key, - }, OAuthClient) -end - -M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID -M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET - -return M diff --git a/spec/oauth_spec.lua b/spec/oauth_spec.lua deleted file mode 100644 index 520227d..0000000 --- a/spec/oauth_spec.lua +++ /dev/null @@ -1,230 +0,0 @@ -require('spec.helpers') - -local config = require('pending.config') -local oauth = require('pending.sync.oauth') - -describe('oauth', function() - local tmpdir - - before_each(function() - tmpdir = vim.fn.tempname() - vim.fn.mkdir(tmpdir, 'p') - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - config.reset() - end) - - after_each(function() - vim.fn.delete(tmpdir, 'rf') - vim.g.pending = nil - config.reset() - end) - - describe('url_encode', function() - it('leaves alphanumerics unchanged', function() - assert.equals('hello123', oauth.url_encode('hello123')) - end) - - it('encodes spaces', function() - assert.equals('hello%20world', oauth.url_encode('hello world')) - end) - - it('encodes special characters', function() - assert.equals('a%3Db%26c', oauth.url_encode('a=b&c')) - end) - - it('preserves hyphens, dots, underscores, tildes', function() - assert.equals('a-b.c_d~e', oauth.url_encode('a-b.c_d~e')) - end) - end) - - describe('load_json_file', function() - it('returns nil for missing file', function() - assert.is_nil(oauth.load_json_file(tmpdir .. '/nonexistent.json')) - end) - - it('returns nil for empty file', function() - local path = tmpdir .. '/empty.json' - local f = io.open(path, 'w') - f:write('') - f:close() - assert.is_nil(oauth.load_json_file(path)) - end) - - it('returns nil for invalid JSON', function() - local path = tmpdir .. '/bad.json' - local f = io.open(path, 'w') - f:write('not json') - f:close() - assert.is_nil(oauth.load_json_file(path)) - end) - - it('parses valid JSON', function() - local path = tmpdir .. '/good.json' - local f = io.open(path, 'w') - f:write('{"key":"value"}') - f:close() - local data = oauth.load_json_file(path) - assert.equals('value', data.key) - end) - end) - - describe('save_json_file', function() - it('creates parent directories', function() - local path = tmpdir .. '/sub/dir/file.json' - local ok = oauth.save_json_file(path, { test = true }) - assert.is_true(ok) - local data = oauth.load_json_file(path) - assert.is_true(data.test) - end) - - it('sets restrictive permissions', function() - local path = tmpdir .. '/secret.json' - oauth.save_json_file(path, { x = 1 }) - local perms = vim.fn.getfperm(path) - assert.equals('rw-------', perms) - end) - end) - - describe('resolve_credentials', function() - it('uses config fields when set', function() - config.reset() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { - gtasks = { - client_id = 'config-id', - client_secret = 'config-secret', - }, - }, - } - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - local creds = c:resolve_credentials() - assert.equals('config-id', creds.client_id) - assert.equals('config-secret', creds.client_secret) - end) - - it('uses credentials file when config fields absent', function() - local cred_path = tmpdir .. '/creds.json' - oauth.save_json_file(cred_path, { - client_id = 'file-id', - client_secret = 'file-secret', - }) - config.reset() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { gtasks = { credentials_path = cred_path } }, - } - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - local creds = c:resolve_credentials() - assert.equals('file-id', creds.client_id) - assert.equals('file-secret', creds.client_secret) - end) - - it('unwraps installed wrapper format', function() - local cred_path = tmpdir .. '/wrapped.json' - oauth.save_json_file(cred_path, { - installed = { - client_id = 'wrapped-id', - client_secret = 'wrapped-secret', - }, - }) - config.reset() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { gcal = { credentials_path = cred_path } }, - } - local c = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' }) - local creds = c:resolve_credentials() - assert.equals('wrapped-id', creds.client_id) - assert.equals('wrapped-secret', creds.client_secret) - end) - - it('falls back to bundled credentials', function() - config.reset() - vim.g.pending = { data_path = tmpdir .. '/tasks.json' } - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - local creds = c:resolve_credentials() - assert.equals(oauth._BUNDLED_CLIENT_ID, creds.client_id) - assert.equals(oauth._BUNDLED_CLIENT_SECRET, creds.client_secret) - end) - - it('prefers config fields over credentials file', function() - local cred_path = tmpdir .. '/creds2.json' - oauth.save_json_file(cred_path, { - client_id = 'file-id', - client_secret = 'file-secret', - }) - config.reset() - vim.g.pending = { - data_path = tmpdir .. '/tasks.json', - sync = { - gtasks = { - credentials_path = cred_path, - client_id = 'config-id', - client_secret = 'config-secret', - }, - }, - } - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - local creds = c:resolve_credentials() - assert.equals('config-id', creds.client_id) - assert.equals('config-secret', creds.client_secret) - end) - end) - - describe('token_path', function() - it('includes backend name', function() - local c = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - assert.truthy(c:token_path():match('gtasks_tokens%.json$')) - end) - - it('differs between backends', function() - local g = oauth.new({ name = 'gcal', scope = 'x', port = 0, config_key = 'gcal' }) - local t = oauth.new({ name = 'gtasks', scope = 'x', port = 0, config_key = 'gtasks' }) - assert.not_equals(g:token_path(), t:token_path()) - end) - end) - - describe('load_tokens / save_tokens', function() - it('round-trips tokens', function() - local c = oauth.new({ name = 'test', scope = 'x', port = 0, config_key = 'gtasks' }) - local path = c:token_path() - local dir = vim.fn.fnamemodify(path, ':h') - vim.fn.mkdir(dir, 'p') - local tokens = { - access_token = 'at', - refresh_token = 'rt', - expires_in = 3600, - obtained_at = 1000, - } - c:save_tokens(tokens) - local loaded = c:load_tokens() - assert.equals('at', loaded.access_token) - assert.equals('rt', loaded.refresh_token) - vim.fn.delete(dir, 'rf') - end) - end) - - describe('auth_headers', function() - it('includes bearer token', function() - local headers = oauth.auth_headers('mytoken') - assert.equals('Authorization: Bearer mytoken', headers[1]) - assert.equals('Content-Type: application/json', headers[2]) - end) - end) - - describe('new', function() - it('creates client with correct fields', function() - local c = oauth.new({ - name = 'test', - scope = 'https://example.com', - port = 12345, - config_key = 'test', - }) - assert.equals('test', c.name) - assert.equals('https://example.com', c.scope) - assert.equals(12345, c.port) - assert.equals('test', c.config_key) - end) - end) -end)