refactor(oauth): async coroutine support, pure-Lua PKCE, server hardening

Problem: OAuth module shelled out to openssl for PKCE, used blocking
`vim.system():wait()`, had a weak `os.time()` PRNG seed, and the TCP
callback server leaked on read errors with no timeout.

Solution: Add `M.system()` coroutine wrapper and `M.async()` helper,
replace openssl with `vim.fn.sha256` + `vim.base64.encode`, seed from
`vim.uv.hrtime()`, add `close_server()` guard with 120s timeout, and
close the server on read errors.
This commit is contained in:
Barrett Ruth 2026-03-05 11:18:36 -05:00
parent e0e3af6787
commit 84e4a45911

View file

@ -27,6 +27,27 @@ OAuthClient.__index = OAuthClient
---@class pending.oauth
local M = {}
---@param args string[]
---@param opts? table
---@return vim.SystemCompleted
function M.system(args, opts)
local co = coroutine.running()
if not co then
return vim.system(args, opts or {}):wait()
end
vim.system(args, opts or {}, function(result)
vim.schedule(function()
coroutine.resume(co, result)
end)
end)
return coroutine.yield()
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)
@ -91,7 +112,7 @@ function M.curl_request(method, url, headers, body)
table.insert(args, body)
end
table.insert(args, url)
local result = vim.system(args, { text = true }):wait()
local result = M.system(args, { text = true })
if result.code ~= 0 then
return nil, 'curl failed: ' .. (result.stderr or '')
end
@ -125,11 +146,6 @@ function M.health(backend_name)
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
@ -189,19 +205,17 @@ function OAuthClient:refresh_access_token(creds, tokens)
.. '&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()
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
@ -247,23 +261,18 @@ function OAuthClient:auth()
local verifier_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
local verifier = {}
math.randomseed(os.time())
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 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 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='
@ -283,6 +292,15 @@ function OAuthClient:auth()
vim.notify('pending.nvim: 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
@ -292,6 +310,8 @@ function OAuthClient:auth()
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]+)')
@ -305,7 +325,7 @@ function OAuthClient:auth()
conn:close()
end)
end)
server:close()
close_server()
if code then
vim.schedule(function()
self:_exchange_code(creds, code, code_verifier, port)
@ -313,6 +333,13 @@ function OAuthClient:auth()
end
end)
end)
vim.defer_fn(function()
if not server_closed then
close_server()
vim.notify('pending.nvim: OAuth callback timed out (120s).', vim.log.levels.WARN)
end
end, 120000)
end
---@param creds pending.OAuthCredentials
@ -333,19 +360,17 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port)
.. '&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()
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
vim.notify('pending.nvim: Token exchange failed.', vim.log.levels.ERROR)