fix(sync): auto-trigger auth flow on unauthenticated sync actions (#120)

Problem: running a sync action (e.g. `:Pending gtasks push`) without
being authenticated would silently abort with a warning, requiring
the user to manually run `:Pending auth` first.

Solution: `oauth.with_token()` now auto-triggers the browser auth flow
when no token exists (for non-bundled credentials) and resumes the
original action on success. `auth()` and `_exchange_code()` now call
`on_complete(ok)` on all exit paths. S3 backends run
`aws sts get-caller-identity` before every sync action, auto-triggering
SSO login on expired sessions.
This commit is contained in:
Barrett Ruth 2026-03-10 11:36:31 -04:00
parent 422f8f9b05
commit 149f2dac2e
5 changed files with 256 additions and 5 deletions

View file

@ -45,8 +45,28 @@ function M.with_token(client, name, callback)
util.with_guard(name, function()
local token = client:get_access_token()
if not token then
require('pending.log').warn(name .. ': Not authenticated — run :Pending auth.')
return
local creds = client:resolve_credentials()
if creds.client_id == BUNDLED_CLIENT_ID then
log.warn(name .. ': No credentials configured — run :Pending auth.')
return
end
log.info(name .. ': Not authenticated — starting auth flow...')
local co = coroutine.running()
client:auth(function(ok)
vim.schedule(function()
coroutine.resume(co, ok)
end)
end)
local auth_ok = coroutine.yield()
if not auth_ok then
log.error(name .. ': Authentication failed.')
return
end
token = client:get_access_token()
if not token then
log.error(name .. ': Still not authenticated after auth flow.')
return
end
end
callback(token)
end)
@ -349,7 +369,7 @@ function OAuthClient:setup()
end)
end
---@param on_complete? fun(): nil
---@param on_complete? fun(ok: boolean): nil
---@return nil
function OAuthClient:auth(on_complete)
if _active_close then
@ -360,6 +380,9 @@ 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.')
if on_complete then
on_complete(false)
end
return
end
local port = self.port
@ -411,6 +434,9 @@ function OAuthClient:auth(on_complete)
if not bind_ok or bind_err == nil then
close_server()
log.error(self.name .. ': Port ' .. port .. ' already in use — try again in a moment.')
if on_complete then
on_complete(false)
end
return
end
@ -453,6 +479,9 @@ function OAuthClient:auth(on_complete)
if not server_closed then
close_server()
log.warn(self.name .. ': OAuth callback timed out (120s).')
if on_complete then
on_complete(false)
end
end
end, 120000)
end
@ -461,7 +490,7 @@ end
---@param code string
---@param code_verifier string
---@param port integer
---@param on_complete? fun(): nil
---@param on_complete? fun(ok: boolean): nil
---@return nil
function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complete)
local body = 'client_id='
@ -491,6 +520,9 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet
if result.code ~= 0 then
self:clear_tokens()
log.error(self.name .. ': Token exchange failed.')
if on_complete then
on_complete(false)
end
return
end
@ -498,6 +530,9 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet
if not ok or not decoded.access_token then
self:clear_tokens()
log.error(self.name .. ': Invalid token response.')
if on_complete then
on_complete(false)
end
return
end
@ -505,7 +540,7 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet
self:save_tokens(decoded)
log.info(self.name .. ': Authorized successfully.')
if on_complete then
on_complete()
on_complete(true)
end
end

View file

@ -66,6 +66,35 @@ local function ensure_sync_id(task)
return sync_id
end
---@return boolean
local function ensure_credentials()
local cmd = base_cmd()
vim.list_extend(cmd, { 'sts', 'get-caller-identity', '--output', 'json' })
local result = util.system(cmd, { text = true })
if result.code == 0 then
return true
end
local stderr = result.stderr or ''
if stderr:find('SSO') or stderr:find('sso') then
log.info('S3: SSO session expired — running login...')
local login_cmd = base_cmd()
vim.list_extend(login_cmd, { 'sso', 'login' })
local login_result = util.system(login_cmd, { text = true })
if login_result.code == 0 then
log.info('S3: SSO login successful')
return true
end
log.error('S3: SSO login failed — ' .. (login_result.stderr or ''))
return false
end
if stderr:find('Unable to locate credentials') or stderr:find('NoCredentialProviders') then
log.error('S3: no AWS credentials configured. See :h pending-s3')
else
log.error('S3: credential check failed — ' .. stderr)
end
return false
end
local function create_bucket()
local name = util.input({ prompt = 'S3 bucket name (pending.nvim): ' })
if not name then
@ -177,6 +206,9 @@ end
function M.push()
util.async(function()
util.with_guard('S3', function()
if not ensure_credentials() then
return
end
local s3cfg = get_config()
if not s3cfg or not s3cfg.bucket then
log.error('S3: bucket is required. Set sync.s3.bucket in config.')
@ -231,6 +263,9 @@ end
function M.pull()
util.async(function()
util.with_guard('S3', function()
if not ensure_credentials() then
return
end
local s3cfg = get_config()
if not s3cfg or not s3cfg.bucket then
log.error('S3: bucket is required. Set sync.s3.bucket in config.')
@ -330,6 +365,9 @@ end
function M.sync()
util.async(function()
util.with_guard('S3', function()
if not ensure_credentials() then
return
end
local s3cfg = get_config()
if not s3cfg or not s3cfg.bucket then
log.error('S3: bucket is required. Set sync.s3.bucket in config.')
@ -466,5 +504,6 @@ function M.health()
end
M._ensure_sync_id = ensure_sync_id
M._ensure_credentials = ensure_credentials
return M