From 84e4a45911cd69747d2ee6848a162c29d95c57d4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 11:18:36 -0500 Subject: [PATCH] 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. --- lua/pending/sync/oauth.lua | 113 ++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua index 7dc5ede..022a9c7 100644 --- a/lua/pending/sync/oauth.lua +++ b/lua/pending/sync/oauth.lua @@ -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)