Skip to content

fix: stop recurring macOS keychain prompts and self-heal dead SSO sessions - #383

Open
cloudsmith-iduffy wants to merge 4 commits into
masterfrom
fix/macos-keychain-prompts
Open

fix: stop recurring macOS keychain prompts and self-heal dead SSO sessions#383
cloudsmith-iduffy wants to merge 4 commits into
masterfrom
fix/macos-keychain-prompts

Conversation

@cloudsmith-iduffy

Copy link
Copy Markdown
Contributor

Description

Motivation

On macOS, every SSO session produced a stream of "cloudsmith wants to use your credential information" prompts. Clicking "Always Allow" did not stop them. The prompts got worse for anyone who runs the CLI from more than one install (standalone binary, a uv environment, uv run).

The root cause is in the keyring library's macOS backend: every write is a delete followed by a re-create (SecItemDelete + SecItemAdd). A re-created item is a new keychain item with a fresh access control list, so it forgets every "Always Allow" the user ever granted. The CLI rewrites its three token items on every refresh — at most every 30 minutes — so grants never survived, and each install flavor kept stealing the items from the others.

Two related problems surfaced while fixing this:

  1. Keyring entries were not profile-scoped. Profiles that target the same API host shared one set of SSO tokens and clobbered each other.
  2. Dead tokens got stuck. When the server rejected a refresh (for example after a lost refresh-token rotation), the CLI kept the dead tokens, warned every 30 minutes, and served a stale bearer token in between. There was no way back to a working state short of a manual logout.

Changes

Keychain writes now update items in place. A new core/macos_keychain.py binds SecItemUpdate via ctypes. _set_value tries it first on macOS and falls back to the normal keyring write when the item does not exist. SecItemUpdate changes the stored secret and keeps the item's access control list, so "Always Allow" now sticks permanently. Each binary pays its grants once, ever. Note: keyring.get_keyring() returns a chainer backend that delegates writes to its first member, so the backend gate inspects backends[0], not the chainer.

Keyring entries are profile-scoped. Until now, every profile stored its SSO tokens under one unscoped service name per API host (for example cloudsmith_cli-access_token-<host>). This change appends -profile-<name> to the service name for non-default profiles, giving each profile its own token set. The old unscoped entries are what the code calls legacy entries, and existing sessions live in them, so backwards compatibility works in two parts:

  • The default profile keeps using the unscoped names. Nothing changes for users who never pass a profile — the same entries are read and written as before.
  • A non-default profile first reads its own scoped entries and falls back to the unscoped ones when they do not exist. An existing session therefore keeps working after the upgrade; the first successful refresh writes the rotated tokens to the scoped entries, completing the migration without a re-login. Logout for a scoped profile removes both its scoped entries and the unscoped ones, so no stale tokens are left behind.

Rejected refreshes self-heal. When the refresh endpoint definitively rejects the stored tokens (400, 401, 403, or 422 — the API returns 422 for an invalid refresh token), the CLI deletes that profile's entries and returns to a clean logged-out state with a clear "run cloudsmith auth" message. The wipe touches only the profile's own entries; if the profile was still running on the unscoped entries via the fallback, those hold the dead tokens and are removed instead — so one profile's rejection can never destroy another profile's session. Transient failures (network errors, 5xx) keep the existing behavior: stamp the attempt time and retry after the 30-minute throttle. A refresh response without an access token is treated as a failure instead of producing an Authorization: Bearer None header, and a locally missing refresh token skips the refresh instead of posting an empty token.

Verification

Verified live on macOS against a real API host: three consecutive forced refresh cycles rotated the access token each time while all three keychain items kept their original creation date — same items, updated in place, access grants intact. The item-identity check is security find-generic-password -s <service> | grep cdat; a changing cdat means the item was re-created and the ACL was reset. The rejection path was also verified live: a dead refresh token produced a 422, and the CLI wiped the entries and dropped cleanly to logged-out. There are no macOS-only automated tests since CI has no macOS runners; the unit tests are cross-platform.

Type of Change

  • Bug fix

Additional Notes

Phase-out plan for the legacy unscoped entries. The unscoped service names stay as the default profile's canonical storage — they are only "legacy" from the point of view of non-default profiles. The fallback exists to migrate existing sessions without a forced re-login, and it retires itself:

  1. This release. A non-default profile reads the unscoped entries only when its own scoped entries do not exist. The first successful refresh writes the tokens to the scoped entries, so the fallback becomes dead code for that profile within 30 minutes of normal use. A logout or a rejected refresh also removes the unscoped entries the profile was running on.
  2. Some time in the future. Remove the fallback read (_get_value_with_fallback) and the include_legacy handling in delete_sso_tokens. Any session that never migrated — a user who skipped the intermediate releases — resolves no credentials and gets the standard "run cloudsmith auth" message, not an error.
  3. The window can stay short: profile use is effectively limited to Cloudsmith employees, and active sessions migrate automatically on their first refresh after upgrading.

…ead SSO sessions

The keyring library implements each keychain write as a delete
followed by a re-create. The re-created item has a fresh access
control list, so every "Always Allow" grant was lost on the next
token refresh and the keychain prompts returned forever.

- Add core/macos_keychain.py and update keychain items in place with
  SecItemUpdate, which keeps the access control list. Fall back to
  the normal keyring write when the item does not exist. Resolve the
  chainer backend to its first member before the update.
- Scope keyring service names by profile. Non-default profiles read
  the legacy unscoped entries as a fallback, so existing sessions
  stay valid and migrate to scoped entries on the next refresh. The
  default profile keeps the unscoped names.
- Clear a profile's SSO tokens when the server rejects the refresh
  (400/401/403/422), so the CLI returns to a clean logged-out state
  instead of retrying dead tokens every 30 minutes. Transient
  failures keep the throttled retry. Treat a refresh response
  without an access token as a failure, and skip the refresh when no
  refresh token is stored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the fix/macos-keychain-prompts branch from dbaf7cf to 7e3c0e3 Compare August 23, 2026 12:04
@cloudsmith-iduffy
cloudsmith-iduffy marked this pull request as ready for review August 23, 2026 12:56
@cloudsmith-iduffy
cloudsmith-iduffy requested a review from a team as a code owner August 23, 2026 12:56
Copilot AI lite review requested due to automatic review settings August 23, 2026 12:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the CLI’s SSO token storage/refresh behavior to (1) avoid recurring macOS Keychain permission prompts by updating items in place and (2) make SSO sessions more resilient and isolated by scoping keyring entries per profile and self-healing rejected refreshes.

Changes:

  • Add a macOS-specific in-place Keychain update path (SecItemUpdate) and wire it into keyring writes.
  • Scope SSO token service names by profile (with legacy fallback/migration) and propagate profile through auth/logout/whoami flows.
  • Improve refresh behavior by wiping definitively rejected sessions and avoiding Bearer None outcomes.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
cloudsmith_cli/core/tests/test_metadata.py Updates keyring stubs to accept the new profile argument.
cloudsmith_cli/core/tests/test_keyring.py Adds coverage for profile-scoped key names and macOS in-place update gating.
cloudsmith_cli/core/tests/test_keyring_provider.py Adds coverage for profile propagation and rejected-refresh wipe behavior.
cloudsmith_cli/core/macos_keychain.py New ctypes-based SecItemUpdate helper to update Keychain items without ACL reset.
cloudsmith_cli/core/keyring.py Implements profile-scoped key formatting, legacy fallback reads, and macOS in-place update hook.
cloudsmith_cli/core/credentials/providers/keyring_provider.py Passes profile through keyring calls; wipes tokens on definitive refresh rejection.
cloudsmith_cli/cli/webserver.py Persists profile on the auth callback server and stores SSO tokens with profile scoping.
cloudsmith_cli/cli/tests/test_webserver.py Extends tests to assert profile is forwarded to store_sso_tokens.
cloudsmith_cli/cli/tests/commands/test_logout.py Ensures test environment clears CLOUDSMITH_PROFILE and asserts profile-aware keyring deletion.
cloudsmith_cli/cli/commands/whoami.py Adds profile-aware SSO status reporting for verbose output.
cloudsmith_cli/cli/commands/logout.py Clears keyring tokens for the active profile.
cloudsmith_cli/cli/commands/auth.py Passes profile into the SAML authentication webserver flow.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cloudsmith_cli/core/macos_keychain.py
Comment thread cloudsmith_cli/core/credentials/providers/keyring_provider.py
@cloudsmith-iduffy

Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts in this pull request

Copilot AI and others added 2 commits August 25, 2026 15:39
Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com>
Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com>

Copilot AI commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Conflicts are resolved and merged with master in 6c31a38, with a follow-up import fix in e681bd3.

The credential provider chain imported all providers at module level,
which pulled requests and cloudsmith_api into every CLI invocation
through the keyring provider's SAML dependency. Defer that import to
CredentialProviderChain.__init__ so it only loads when the chain is
built.

Also fix a logout test assertion that dropped the profile=None keyword
argument the command actually passes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants