Skip to content

Add rsconnect token to print a stored OAuth access token - #884

Merged
tdstein merged 1 commit into
mainfrom
oauth-token-command
Oct 26, 2026
Merged

tdstein merged 1 commit into
mainfrom
oauth-token-command

Conversation

@nealrichardson

Copy link
Copy Markdown
Contributor

Intent

Other tools need a way to use the credentials from rsconnect login. The motivating case is the Connect SDKs (posit-sdk-py, connectapi), which still require an API key. Reading the keyring and servers.json directly would be fragile. The storage format is internal, refresh tokens rotate (so a second process that refreshes would break the stored login), and R can't easily read a keyring entry written by Python. A command keeps rsconnect as the only thing that reads, refreshes, and writes stored credentials, following the same pattern as gh auth token and gcloud auth print-access-token.

Design discussion: posit-dev/posit-cli#21. posit-cli mounts rsconnect's commands, so this also becomes posit connect token.

Type of Change

  • Bug Fix
  • New Feature
  • Breaking Change

Approach

rsconnect token [-n NAME | -s URL] [--refresh] [--format text|json]
  • Prints the stored access token without contacting the server. It doesn't check expiry ahead of time. Callers have to handle a 401 anyway, because tokens can be revoked, so the command doesn't track expiry. After a 401, the caller runs token --refresh and retries once.
  • --format json prints {"access_token", "token_type": "Bearer", "server"}.
  • Exit status: 0 on success, 4 when the user needs to log in again (no stored OAuth credentials, a server not added with OAuth, or the server rejected the refresh token), and 1 for anything else (for example, an unreachable server). 4 matches gh. Nothing is printed on stdout on failure.
  • Never prints API keys and never prompts.
  • The "Run '… login'" hint is built from the invoking command path, so under posit-cli it says posit connect login.

One refresh path, serialized. A new rsconnect.oauth.refresh_stored_tokens(..., rejected_access_token=...) holds a cross-process file lock (fcntl.flock on POSIX, msvcrt.locking on Windows) in the config directory. While holding it, the function re-reads the stored access token. If that token no longer matches the rejected one, another process has already refreshed, and the function returns that token without a second exchange. RSConnectClient._attempt_token_refresh (the 401 retry used by every command) now calls this function too. So concurrent deploys and token --refresh calls no longer invalidate each other's rotated refresh tokens. If the lock file can't be created, the refresh runs unlocked, as it did before.

Behavior changes to the existing 401 retry:

  • It uses a token another process already refreshed instead of refreshing again.
  • When the server reports the OAuth client was deleted, it still clears the tokens and re-registers a new client. It now logs one warning, instead of separate warnings for whether re-registration succeeded or failed.

Out of scope: Connect Cloud logins. They have their own token model, and token reports them as not added with OAuth login.

Automated Tests

tests/test_oauth_token.py covers:

  • reading stored tokens from the keyring and from the saved entry when there's no keyring;
  • refresh with token rotation, and keeping the refresh token when the server doesn't rotate it;
  • skipping the exchange when another process already refreshed;
  • NotLoggedInError, InvalidGrantError (stored tokens kept), and InvalidClientError (tokens cleared, new client registered);
  • the lock: a second holder times out, 4 concurrent threads exchange the refresh token once, and an uncreatable lock file falls back to running unlocked;
  • the client's 401 refresh;
  • the command: text and JSON output, -n/-s/default server, --refresh, exit codes 4 and 1, never printing an API key, and the login hint under a mounting CLI.

A new autouse fixture in tests/conftest.py keeps the lock file out of the real config directory.

Directions for Reviewers

Against a server you've logged in to with rsconnect login:

T=$(rsconnect token -n myserver)
curl -H "Authorization: Bearer $T" https://myserver/__api__/v1/user   # 401 if the stored token is stale
T=$(rsconnect token -n myserver --refresh)
curl -H "Authorization: Bearer $T" https://myserver/__api__/v1/user   # 200
rsconnect token -n myserver --format json

I ran this against a real server: the stale token got a 401, and after --refresh the new token got a 200 and was saved. 5 concurrent token --refresh processes all exited 0 with the same single token, and that token worked. Against a login whose refresh token had expired, the command exited with 4 and printed the login hint.

Checklist

  • I have updated CHANGELOG.md to cover notable changes.
  • I have updated all related GitHub issues to reflect their current state.
  • I have run the rsconnect-python-tests-at-night workflow in Connect against this feature branch.

🤖 Generated with Claude Code

Other tools, such as the Connect SDKs, can run `rsconnect token` to make API
requests with the user's `rsconnect login` instead of a separate API key,
without reading or refreshing the credential store themselves.

The command prints the stored token without contacting the server. After a
401, callers run it with --refresh. Refreshing goes through a new shared
`refresh_stored_tokens`, which the client's own 401 retry also uses. It holds
a cross-process lock and skips the exchange when another process has already
replaced the rejected token, so concurrent refreshes don't invalidate each
other's rotated refresh tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-26 17:29 UTC

@github-actions

Copy link
Copy Markdown

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
11020 9676 88% 0% 🟢

New Files

No new covered files...

Modified Files

File Coverage Status
rsconnect/api.py 89% 🟢
rsconnect/main.py 85% 🟢
rsconnect/oauth.py 87% 🟢
TOTAL 87% 🟢

updated for commit: 4b2f480 by action🐍

@tdstein
tdstein merged commit b1f5ca2 into main Oct 26, 2026
27 checks passed
@tdstein
tdstein deleted the oauth-token-command branch October 26, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants