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 -.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 ' .. self.name .. ' setup') 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 '

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) 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 ---@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