Problem: no way to wipe just the token file while keeping credentials intact — `_wipe()` removed both. Solution: add `clear_tokens()` that removes only the token file.
526 lines
14 KiB
Lua
526 lines
14 KiB
Lua
local config = require('pending.config')
|
|
local log = require('pending.log')
|
|
|
|
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 args string[]
|
|
---@param opts? table
|
|
---@return { code: integer, stdout: string, stderr: string }
|
|
function M.system(args, opts)
|
|
local co = coroutine.running()
|
|
if not co then
|
|
return vim.system(args, opts or {}):wait() --[[@as { code: integer, stdout: string, stderr: string }]]
|
|
end
|
|
vim.system(args, opts or {}, function(result)
|
|
vim.schedule(function()
|
|
coroutine.resume(co, result)
|
|
end)
|
|
end)
|
|
return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]]
|
|
end
|
|
|
|
---@param fn fun(): nil
|
|
function M.async(fn)
|
|
coroutine.resume(coroutine.create(fn))
|
|
end
|
|
|
|
---@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 = M.system(args, { text = true })
|
|
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
|
|
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 data_dir = vim.fn.stdpath('data') .. '/pending/'
|
|
local cred_paths = {}
|
|
if backend_cfg.credentials_path then
|
|
table.insert(cred_paths, backend_cfg.credentials_path)
|
|
end
|
|
table.insert(cred_paths, data_dir .. self.name .. '_credentials.json')
|
|
table.insert(cred_paths, data_dir .. 'google_credentials.json')
|
|
for _, cred_path in ipairs(cred_paths) do
|
|
if cred_path then
|
|
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
|
|
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 = M.system({
|
|
'curl',
|
|
'-s',
|
|
'-X',
|
|
'POST',
|
|
'-H',
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'-d',
|
|
body,
|
|
TOKEN_URL,
|
|
}, { text = true })
|
|
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
|
|
return nil
|
|
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
|
|
log.error(self.name .. ': token refresh failed — re-authenticating...')
|
|
return nil
|
|
end
|
|
end
|
|
return tokens.access_token
|
|
end
|
|
|
|
---@return nil
|
|
function OAuthClient:setup()
|
|
local choice = vim.fn.inputlist({
|
|
self.name .. ' setup:',
|
|
'1. Enter client ID and secret',
|
|
'2. Load from JSON file path',
|
|
})
|
|
vim.cmd.redraw()
|
|
|
|
local id, secret
|
|
|
|
if choice == 1 then
|
|
while true do
|
|
id = vim.trim(vim.fn.input(self.name .. ' client ID: '))
|
|
if id == '' then
|
|
return
|
|
end
|
|
if id:match('^%d+%-[%w_]+%.apps%.googleusercontent%.com$') then
|
|
break
|
|
end
|
|
vim.cmd.redraw()
|
|
vim.api.nvim_echo({
|
|
{
|
|
'invalid client ID — expected <numbers>-<hash>.apps.googleusercontent.com',
|
|
'ErrorMsg',
|
|
},
|
|
}, false, {})
|
|
end
|
|
|
|
while true do
|
|
secret = vim.trim(vim.fn.inputsecret(self.name .. ' client secret: '))
|
|
if secret == '' then
|
|
return
|
|
end
|
|
if secret:match('^GOCSPX%-') then
|
|
break
|
|
end
|
|
vim.cmd.redraw()
|
|
vim.api.nvim_echo(
|
|
{ { 'invalid client secret — expected GOCSPX-...', 'ErrorMsg' } },
|
|
false,
|
|
{}
|
|
)
|
|
end
|
|
elseif choice == 2 then
|
|
local fpath
|
|
while true do
|
|
fpath = vim.trim(vim.fn.input(self.name .. ' credentials file: ', '', 'file'))
|
|
if fpath == '' then
|
|
return
|
|
end
|
|
fpath = vim.fn.expand(fpath)
|
|
local creds = M.load_json_file(fpath)
|
|
if creds then
|
|
if creds.installed then
|
|
creds = creds.installed
|
|
end
|
|
if creds.client_id and creds.client_secret then
|
|
id = creds.client_id
|
|
secret = creds.client_secret
|
|
break
|
|
end
|
|
end
|
|
vim.cmd.redraw()
|
|
vim.api.nvim_echo(
|
|
{ { 'could not read client_id/client_secret from ' .. fpath, 'ErrorMsg' } },
|
|
false,
|
|
{}
|
|
)
|
|
end
|
|
else
|
|
return
|
|
end
|
|
|
|
vim.schedule(function()
|
|
local path = vim.fn.stdpath('data') .. '/pending/google_credentials.json'
|
|
local ok = M.save_json_file(path, { client_id = id, client_secret = secret })
|
|
if not ok then
|
|
log.error(self.name .. ': failed to save credentials')
|
|
return
|
|
end
|
|
log.info(self.name .. ': credentials saved, starting authorization...')
|
|
self:auth()
|
|
end)
|
|
end
|
|
|
|
---@param on_complete? fun(): nil
|
|
---@return nil
|
|
function OAuthClient:auth(on_complete)
|
|
local creds = self:resolve_credentials()
|
|
if creds.client_id == BUNDLED_CLIENT_ID then
|
|
log.error(self.name .. ': no credentials configured — run :Pending auth')
|
|
return
|
|
end
|
|
local port = self.port
|
|
|
|
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
|
|
local verifier = {}
|
|
math.randomseed(vim.uv.hrtime())
|
|
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 hex = vim.fn.sha256(code_verifier)
|
|
local binary = hex:gsub('..', function(h)
|
|
return string.char(tonumber(h, 16))
|
|
end)
|
|
local code_challenge = vim.base64.encode(binary):gsub('+', '-'):gsub('/', '_'):gsub('=', '')
|
|
|
|
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)
|
|
log.info('Opening browser for Google authorization...')
|
|
|
|
local server = vim.uv.new_tcp()
|
|
local server_closed = false
|
|
local function close_server()
|
|
if server_closed then
|
|
return
|
|
end
|
|
server_closed = true
|
|
server:close()
|
|
end
|
|
|
|
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
|
|
conn:close()
|
|
close_server()
|
|
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)
|
|
close_server()
|
|
if code then
|
|
vim.schedule(function()
|
|
self:_exchange_code(creds, code, code_verifier, port, on_complete)
|
|
end)
|
|
end
|
|
end)
|
|
end)
|
|
|
|
vim.defer_fn(function()
|
|
if not server_closed then
|
|
close_server()
|
|
log.warn('OAuth callback timed out (120s).')
|
|
end
|
|
end, 120000)
|
|
end
|
|
|
|
---@param creds pending.OAuthCredentials
|
|
---@param code string
|
|
---@param code_verifier string
|
|
---@param port integer
|
|
---@param on_complete? fun(): nil
|
|
---@return nil
|
|
function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complete)
|
|
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 = M.system({
|
|
'curl',
|
|
'-s',
|
|
'-X',
|
|
'POST',
|
|
'-H',
|
|
'Content-Type: application/x-www-form-urlencoded',
|
|
'-d',
|
|
body,
|
|
TOKEN_URL,
|
|
}, { text = true })
|
|
|
|
if result.code ~= 0 then
|
|
self:_wipe()
|
|
log.error('Token exchange failed.')
|
|
return
|
|
end
|
|
|
|
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
|
|
if not ok or not decoded.access_token then
|
|
self:_wipe()
|
|
log.error('Invalid token response.')
|
|
return
|
|
end
|
|
|
|
decoded.obtained_at = os.time()
|
|
self:save_tokens(decoded)
|
|
log.info(self.name .. ' authorized successfully.')
|
|
if on_complete then
|
|
on_complete()
|
|
end
|
|
end
|
|
|
|
---@return nil
|
|
function OAuthClient:_wipe()
|
|
os.remove(self:token_path())
|
|
os.remove(vim.fn.stdpath('data') .. '/pending/google_credentials.json')
|
|
end
|
|
|
|
---@return nil
|
|
function OAuthClient:clear_tokens()
|
|
os.remove(self:token_path())
|
|
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
|
|
M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
|
|
|
|
M.google_client = M.new({
|
|
name = 'google',
|
|
scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar',
|
|
port = 18392,
|
|
config_key = 'google',
|
|
})
|
|
|
|
return M
|