Auth Storage
May 25, 2026 · View on GitHub
RepoBar has two token storage modes:
- Keychain: production default. OAuth tokens, client credentials, and PATs use the macOS Keychain.
- File: debug/autonomy mode. Tokens are stored as JSON files under
~/Library/Application Support/RepoBar/DebugAuth.
TokenStore.shared chooses the backend in this order:
REPOBAR_TOKEN_STOREenvironment variable.RepoBarTokenStorein the app bundleInfo.plist.- Debug-build file storage fallback.
- Release-build Keychain fallback.
Accepted file values are file and disk. Set REPOBAR_TOKEN_STORE=keychain to force Keychain in debug builds.
Debug App Builds
Scripts/package_app.sh debug writes this into the generated app bundle:
<key>RepoBarTokenStore</key><string>file</string>
That means pnpm start and pnpm restart use file-backed auth and must not trigger macOS Keychain prompts during autonomous development. The debug app still signs normally, but it also strips keychain-access-groups when no provisioning profile is configured.
SwiftPM debug CLI/test binaries do not have the app bundle Info.plist, so debug builds also default to file-backed storage in code. Local swift test, pnpm test, .build/debug/repobarcli, and the packaged debug app therefore share the same non-Keychain backend unless explicitly overridden.
To force the same behavior for an installed release CLI/debug process:
REPOBAR_TOKEN_STORE=file repobar status
To force Keychain while debugging:
REPOBAR_TOKEN_STORE=keychain pnpm start
REPOBAR_TOKEN_STORE=keychain .build/debug/repobarcli status
Release Builds
Release builds do not write RepoBarTokenStore=file, so they use Keychain by default.
Developer ID builds currently strip keychain-access-groups unless REPOBAR_SKIP_KEYCHAIN_GROUPS=0 is set for a properly provisioned build. Without a valid provisioning profile, shipping that entitlement causes AMFI launch failures on newer macOS versions.
File Backend Notes
The file backend exists for local debug autonomy, not for shipped secrets. It stores the same data shape as Keychain:
default: OAuth access/refresh tokens.client: OAuth client credentials.pat: Personal Access Token.
Files are written with 0600 permissions where supported. TokenStore.clear() removes the file-backed OAuth, client, and PAT entries for the configured service.
Account-Scoped Keys (Phase 1)
In addition to the legacy fixed keys above, TokenStore accepts account-scoped APIs that key each item by accountID:
<accountID>:default— OAuth access/refresh tokens for the account.<accountID>:client— OAuth client credentials for the account.<accountID>:pat— Personal Access Token for the account.
Public APIs:
save(tokens:accountID:),loadTokens(accountID:)save(clientCredentials:accountID:),loadClientCredentials(accountID:)savePAT(_:accountID:),loadPAT(accountID:)clear(accountID:)removes all three kinds for one account.allAccountIDs()returns the union of account IDs found in file storage and (best effort) Keychain.
Storage representation:
- Keychain:
kSecAttrAccountis set to"<accountID>:<kind>".allAccountIDs()enumerates entries for the configured service (across access groups) and parses out the account ID prefix, ignoring legacy fixed accounts. - File: files are still named
<service>-<account>.json. The colon separator is sanitized to a dash, so on disk you will see…-<accountID>-default.json,…-<accountID>-client.json, and…-<accountID>-pat.json. Because the on-disk name is sanitized (e.g.,#→-), the filename is not a reliable source of the original account ID. To preserve the original string, file storage also maintains a small JSON index at<service>-accounts-index.jsonnext to the token files. Each account-scopedsave…(accountID:)call records the originalaccountIDin this index, andclear(accountID:)removes it.allAccountIDs()returns the union of indexed IDs and any IDs scanned from filenames (the latter only as a fallback for entries that pre-date the index), sorted and deduplicated. Legacy non-account wrappers (save(tokens:),savePAT(_:), etc.) never write to the index.
The legacy save(tokens:), load(), save(clientCredentials:), loadClientCredentials(), savePAT(_:), loadPAT(), clear(), and clearPAT() APIs are preserved as wrappers over the fixed default / client / pat keys for backwards compatibility during the multi-account transition.
Multi-Account Wiring
Phase 2+ wires the account-scoped keys to the rest of the app:
AccountManager(Sources/RepoBar/Auth/AccountManager.swift) owns oneGitHubClientperAccountand runs anAccountScopedOAuthRefresherthat reads and writes tokens vialoadTokens(accountID:)/save(tokens:accountID:).- On launch,
AppState.bootstrapAccounts()callsmigrateLegacyAccountIfNeeded()which probesGET /userwith whichever credential currently lives under the legacy fixed keys, derives a stableAccount(<host>#<username>), and copies the existing OAuth tokens / client credentials / PAT under the new account-scoped keys. The legacy entries are left in place so that downgrading to a previous build still finds them. - After successful
repobar loginorrepobar import-gh-token, both commands probeGET /user, derive anAccount, persist tokens under<accountID>:default/<accountID>:client/<accountID>:pat, and append the account toUserSettings.accounts. The legacy fast-path entries are also written so that callers that still readTokenStore.shared.load()keep working. HTTPResponseDiskCache.databaseURL(accountID:)andRepoBarPersistentCache.databaseURL(accountID:)resolve to~/Library/Application Support/RepoBar/Cache/<safe-account-id>.sqlite, so eachGitHubClientwrites responses to its own SQLite database. PassingaccountID: nilreturns the legacy shared path.