From 6910bdb1be96c728e3b8a0ca5469c452797df34a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:21:18 -0500 Subject: [PATCH] Google Tasks sync + shared OAuth module (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gtasks): add Google Tasks bidirectional sync Problem: pending.nvim only supported one-way push to Google Calendar. Users who use Google Tasks had no way to sync tasks bidirectionally. Solution: add `lua/pending/sync/gtasks.lua` backend with OAuth PKCE auth, push/pull/sync actions, and field mapping between pending tasks and Google Tasks (category↔tasklist, `priority`/`recur` via notes). * refactor(cli): promote sync backends to top-level subcommands Problem: `:Pending sync gtasks auth` required an extra `sync` keyword that added no value and made the command unnecessarily verbose. Solution: route `gtasks` and `gcal` as top-level `:Pending` subcommands via `SYNC_BACKEND_SET` lookup. Tab completion introspects backend modules for available actions instead of hardcoding `{ 'auth', 'sync' }`. * docs(gtasks): document Google Tasks backend and CLI changes Problem: vimdoc had no coverage for the gtasks backend and still referenced the old `:Pending sync ` command form. Solution: add `:Pending-gtasks` and `:Pending-gcal` command sections with per-action docs, update sync backend interface, and add gtasks config example. * ci: format * 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. * feat(sync): ship bundled OAuth credentials Problem: Users must manually create a Google Cloud project and place a credentials JSON file before sync works — terrible onboarding. Solution: Add `client_id`/`client_secret` fields to `GcalConfig` and `GtasksConfig`. `oauth.lua` resolves credentials in three tiers: config fields, credentials file, then bundled defaults (placeholders for now). * docs(sync): document bundled credentials and config fields * ci: format --- doc/pending.txt | 53 +++-- lua/pending/config.lua | 4 + lua/pending/health.lua | 2 +- lua/pending/sync/gcal.lua | 402 ++++-------------------------------- lua/pending/sync/gtasks.lua | 398 +++-------------------------------- lua/pending/sync/oauth.lua | 380 ++++++++++++++++++++++++++++++++++ spec/oauth_spec.lua | 230 +++++++++++++++++++++ 7 files changed, 726 insertions(+), 743 deletions(-) create mode 100644 lua/pending/sync/oauth.lua create mode 100644 spec/oauth_spec.lua diff --git a/doc/pending.txt b/doc/pending.txt index 08c6315..d3eb03b 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -608,11 +608,8 @@ loads: >lua sync = { gcal = { calendar = 'Pendings', - credentials_path = '/path/to/client_secret.json', - }, - gtasks = { - credentials_path = '/path/to/client_secret.json', }, + gtasks = {}, }, } < @@ -683,7 +680,9 @@ Fields: ~ {sync} (table, default: {}) *pending.SyncConfig* Sync backend configuration. Each key is a backend name and the value is the backend-specific config - table. Currently only `gcal` is built-in. + table. Built-in backends: `gcal`, `gtasks`. Both + ship bundled OAuth credentials so no setup is + needed beyond `:Pending auth`. {icons} (table) *pending.Icons* Icon characters displayed in the buffer. The @@ -934,12 +933,14 @@ Configuration: >lua sync = { gcal = { calendar = 'Pendings', - credentials_path = '/path/to/client_secret.json', }, }, } < +No configuration is required to get started — bundled OAuth credentials are +used by default. Run `:Pending gcal auth` and the browser opens immediately. + *pending.GcalConfig* Fields: ~ {calendar} (string, default: 'Pendings') @@ -947,13 +948,27 @@ Fields: ~ with this name does not exist it is created automatically on the first sync. - {credentials_path} (string) - Path to the OAuth client secret JSON file downloaded + {client_id} (string, optional) + OAuth client ID. When set together with + {client_secret}, these take priority over the + credentials file and bundled defaults. + + {client_secret} (string, optional) + OAuth client secret. See {client_id}. + + {credentials_path} (string, optional) + Path to an OAuth client secret JSON file downloaded from the Google Cloud Console. Default: `stdpath('data')..'/pending/gcal_credentials.json'`. The file may be in the `installed` wrapper format that Google provides or as a bare credentials object. +Credential resolution: ~ +Credentials are resolved in order: +1. `client_id` + `client_secret` config fields (highest priority). +2. JSON file at `credentials_path` (or the default path). +3. Bundled credentials shipped with the plugin (always available). + OAuth flow: ~ On the first `:Pending gcal` call the plugin detects that no refresh token exists and opens the Google authorization URL in the browser using @@ -988,22 +1003,34 @@ created automatically on first sync. Configuration: >lua vim.g.pending = { sync = { - gtasks = { - credentials_path = '/path/to/client_secret.json', - }, + gtasks = {}, }, } < +No configuration is required to get started — bundled OAuth credentials are +used by default. Run `:Pending gtasks auth` and the browser opens immediately. + *pending.GtasksConfig* Fields: ~ - {credentials_path} (string) - Path to the OAuth client secret JSON file downloaded + {client_id} (string, optional) + OAuth client ID. When set together with + {client_secret}, these take priority over the + credentials file and bundled defaults. + + {client_secret} (string, optional) + OAuth client secret. See {client_id}. + + {credentials_path} (string, optional) + Path to an OAuth client secret JSON file downloaded from the Google Cloud Console. Default: `stdpath('data')..'/pending/gtasks_credentials.json'`. Accepts the `installed` wrapper format or a bare credentials object. +Credential resolution: ~ +Same three-tier resolution as the gcal backend (see |pending-gcal|). + OAuth flow: ~ Same PKCE flow as the gcal backend; listens on port 18393. Tokens are stored at `stdpath('data')/pending/gtasks_tokens.json`. Run `:Pending gtasks auth` diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 58da035..263cc8c 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -9,9 +9,13 @@ ---@class pending.GcalConfig ---@field calendar? string ---@field credentials_path? string +---@field client_id? string +---@field client_secret? string ---@class pending.GtasksConfig ---@field credentials_path? string +---@field client_id? string +---@field client_secret? string ---@class pending.SyncConfig ---@field gcal? pending.GcalConfig diff --git a/lua/pending/health.lua b/lua/pending/health.lua index ca28298..0f1bad8 100644 --- a/lua/pending/health.lua +++ b/lua/pending/health.lua @@ -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 diff --git a/lua/pending/sync/gcal.lua b/lua/pending/sync/gcal.lua index 2ec96a8..9158ca1 100644 --- a/lua/pending/sync/gcal.lua +++ b/lua/pending/sync/gcal.lua @@ -1,361 +1,32 @@ local config = require('pending.config') +local oauth = require('pending.sync.oauth') 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 -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 '

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 - 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)) + local data, err = oauth.curl_request( + 'GET', + BASE_URL .. '/users/me/calendarList', + oauth.auth_headers(access_token) + ) if err then return nil, err end @@ -368,7 +39,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 +71,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 +94,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 +112,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 +200,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 diff --git a/lua/pending/sync/gtasks.lua b/lua/pending/sync/gtasks.lua index 7476ee6..f31de99 100644 --- a/lua/pending/sync/gtasks.lua +++ b/lua/pending/sync/gtasks.lua @@ -1,357 +1,26 @@ local config = require('pending.config') +local oauth = require('pending.sync.oauth') 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 -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 '

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 - 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? 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,10 @@ 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 +111,10 @@ 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 +338,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 +352,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 +423,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 diff --git a/lua/pending/sync/oauth.lua b/lua/pending/sync/oauth.lua new file mode 100644 index 0000000..7dc5ede --- /dev/null +++ b/lua/pending/sync/oauth.lua @@ -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 '

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) + 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 diff --git a/spec/oauth_spec.lua b/spec/oauth_spec.lua new file mode 100644 index 0000000..520227d --- /dev/null +++ b/spec/oauth_spec.lua @@ -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)