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:
parent
6e21f883f6
commit
25c8bd4eb0
5 changed files with 686 additions and 729 deletions
|
|
@ -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 type(backend.health) == 'function' then
|
||||
if bok and backend.name and type(backend.health) == 'function' then
|
||||
vim.health.start('pending.nvim: sync/' .. name)
|
||||
backend.health()
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
local oauth = require('pending.sync.oauth')
|
||||
local config = require('pending.config')
|
||||
|
||||
local M = {}
|
||||
|
|
@ -5,357 +6,24 @@ 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'
|
||||
|
||||
---@class pending.GcalCredentials
|
||||
---@field client_id string
|
||||
---@field client_secret string
|
||||
---@field redirect_uris? string[]
|
||||
local client = oauth.new({
|
||||
name = 'gcal',
|
||||
scope = SCOPE,
|
||||
port = 18392,
|
||||
config_key = 'gcal',
|
||||
})
|
||||
|
||||
---@class pending.GcalTokens
|
||||
---@field access_token string
|
||||
---@field refresh_token string
|
||||
---@field expires_in? integer
|
||||
---@field obtained_at? integer
|
||||
|
||||
---@return table<string, any>
|
||||
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 '<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.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 gc = gcal_config()
|
||||
local cfg = config.get()
|
||||
local gc = (cfg.sync and cfg.sync.gcal) or {}
|
||||
local cal_name = gc.calendar or 'Pendings'
|
||||
|
||||
local data, err =
|
||||
curl_request('GET', BASE_URL .. '/users/me/calendarList', auth_headers(access_token))
|
||||
oauth.curl_request('GET', BASE_URL .. '/users/me/calendarList', oauth.auth_headers(access_token))
|
||||
if err then
|
||||
return nil, err
|
||||
end
|
||||
|
|
@ -368,7 +36,7 @@ local function find_or_create_calendar(access_token)
|
|||
|
||||
local body = vim.json.encode({ summary = cal_name })
|
||||
local created, create_err =
|
||||
curl_request('POST', BASE_URL .. '/calendars', auth_headers(access_token), body)
|
||||
oauth.curl_request('POST', BASE_URL .. '/calendars', oauth.auth_headers(access_token), body)
|
||||
if create_err then
|
||||
return nil, create_err
|
||||
end
|
||||
|
|
@ -400,10 +68,10 @@ local function create_event(access_token, calendar_id, task)
|
|||
private = { taskId = tostring(task.id) },
|
||||
},
|
||||
}
|
||||
local data, err = curl_request(
|
||||
local data, err = oauth.curl_request(
|
||||
'POST',
|
||||
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events',
|
||||
auth_headers(access_token),
|
||||
BASE_URL .. '/calendars/' .. oauth.url_encode(calendar_id) .. '/events',
|
||||
oauth.auth_headers(access_token),
|
||||
vim.json.encode(event)
|
||||
)
|
||||
if err then
|
||||
|
|
@ -423,10 +91,14 @@ local function update_event(access_token, calendar_id, event_id, task)
|
|||
start = { date = task.due },
|
||||
['end'] = { date = next_day(task.due or '') },
|
||||
}
|
||||
local _, err = curl_request(
|
||||
local _, err = oauth.curl_request(
|
||||
'PATCH',
|
||||
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
|
||||
auth_headers(access_token),
|
||||
BASE_URL
|
||||
.. '/calendars/'
|
||||
.. oauth.url_encode(calendar_id)
|
||||
.. '/events/'
|
||||
.. oauth.url_encode(event_id),
|
||||
oauth.auth_headers(access_token),
|
||||
vim.json.encode(event)
|
||||
)
|
||||
return err
|
||||
|
|
@ -437,16 +109,24 @@ end
|
|||
---@param event_id string
|
||||
---@return string? err
|
||||
local function delete_event(access_token, calendar_id, event_id)
|
||||
local _, err = curl_request(
|
||||
local _, err = oauth.curl_request(
|
||||
'DELETE',
|
||||
BASE_URL .. '/calendars/' .. url_encode(calendar_id) .. '/events/' .. url_encode(event_id),
|
||||
auth_headers(access_token)
|
||||
BASE_URL
|
||||
.. '/calendars/'
|
||||
.. oauth.url_encode(calendar_id)
|
||||
.. '/events/'
|
||||
.. oauth.url_encode(event_id),
|
||||
oauth.auth_headers(access_token)
|
||||
)
|
||||
return err
|
||||
end
|
||||
|
||||
function M.auth()
|
||||
client:auth()
|
||||
end
|
||||
|
||||
function M.sync()
|
||||
local access_token = get_access_token()
|
||||
local access_token = client:get_access_token()
|
||||
if not access_token then
|
||||
return
|
||||
end
|
||||
|
|
@ -517,16 +197,7 @@ end
|
|||
|
||||
---@return nil
|
||||
function M.health()
|
||||
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
|
||||
oauth.health(M.name)
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
380
lua/pending/sync/oauth.lua
Normal file
380
lua/pending/sync/oauth.lua
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
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 '<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
|
||||
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
|
||||
230
spec/oauth_spec.lua
Normal file
230
spec/oauth_spec.lua
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue