Authentication Token Management#
The Tuist CLI's authentication token system handles credential storage, JWT refresh, race-condition prevention, and graceful degradation when the server is unavailable. The core entry point is ServerAuthenticationController in cli/Sources/TuistServer/Client/.
Token Types#
AuthenticationToken has three variants:
| Variant | Use case | Refreshable? |
|---|---|---|
.user(accessToken:refreshToken:) | Interactive/local sessions | Yes β via refresh token |
.project(String) | CI environments (limited scopes) | No β static value |
.account(JWT) | OIDC from CI provider | No β valid until JWT expiry |
Environment-variable tokens (TUIST_TOKEN, and deprecated TUIST_CONFIG_TOKEN / TUIST_CONFIG_CLOUD_TOKEN) always resolve to .project and are checked first, before any credential store lookup .
Credential Storage#
ServerCredentialsStore persists {accessToken, refreshToken?} in a platform-specific backend :
- macOS CLI / Linux: JSON file at
~/.config/tuist/credentials/{host}.json(atomic write) - iOS app: Keychain, keyed by
{serverURL}_access_token/{serverURL}_refresh_token
Credentials are accessed via the @TaskLocal ServerCredentialsStore.current , allowing test injection without global mutation. An AsyncStream<ServerCredentials?> (credentialsChanged) notifies observers on every store or delete .
Token Refresh Flow#
authenticationToken(serverURL:) is the public entry point. It :
- Checks for an environment-variable token first (bypasses all refresh logic).
- Looks up an in-memory memoized value from
CachedValueStore(keyed byauthentication-token-{url}), bounded by the token's own JWT expiry minus 60 seconds for.user/.account, or 5 minutes for.project. - If the memo is absent or expired, calls
authenticationTokenRefreshingIfNeeded(...).
Expiry threshold: a token is considered expired when accessToken.expiryDate is β€ 30 seconds away .
Refresh execution matrix#
authenticationTokenRefreshingIfNeeded dispatches based on (tokenStatus, inBackground, locking) :
| Status | Background? | Locking? | Action |
|---|---|---|---|
.valid | any | any | Return token immediately |
.absent | any | any | Return nil (no credentials) |
.expired | false | true | File-system lock β in-process refresh (foreground) |
.expired | false | false | Direct executeRefresh (no coordination) |
.expired | true | true | File-system lock β spawn tuist auth refresh-token {url} subprocess |
.expired | true | false | Throws cantRefreshWithLockingAndBackground (invalid configuration) |
Background refresh is controlled by ServerAuthenticationConfig.backgroundRefresh, a @TaskLocal defaulting to false.
Lock coordination#
To prevent duplicate refresh requests from parallel tuist processes, the foreground path uses a file-system lock at {stateDirectory}/auth-locks/token_{url}.lock . The lock implementation polls every 500 ms, up to 30 attempts (15 seconds total), before throwing timedOut . A lock file older than 10 seconds is treated as stale and removed .
In-process deduplication is handled by CachedValueStore β multiple concurrent callers waiting on the same key coalesce onto a single in-flight Task.
Background refresh subprocess#
When backgroundRefresh == true, the expired-token path spawns :
tuist auth refresh-token {serverURL}
The parent waits (via file-system lock) for the subprocess to write fresh credentials, then reads them back.
Network Error Handling#
refreshTokens(serverURL:refreshToken:) makes the HTTP call via RefreshAuthTokenService. Error mapping:
| Condition | Error thrown |
|---|---|
URLError.notConnectedToInternet | Re-throws original ClientError |
RefreshAuthTokenServiceError.unauthorized | Re-thrown as-is (triggers credential deletion; see below) |
RefreshAuthTokenServiceError.badRequest | Re-thrown as-is |
| Any other non-network error | ClientAuthenticationError.notAuthenticated |
| Undocumented HTTP status | RefreshAuthTokenServiceError.unknownError(statusCode) |
Unauthorized / credential deletion race#
deletingCredentialsOnUnauthorizedError wraps every refresh call. On a 401 from the server:
- It compares the refresh token before the call to the one on-disk after the call.
- If they differ, a peer process has already rotated the token β credentials are preserved.
- If they are the same, the refresh token is genuinely invalid β
ServerCredentialsStore.current.delete(serverURL:)is called .
This prevents a common race where two parallel tuist invocations both try to refresh the same token: the slower one would get a 401 on an already-rotated token and should not wipe the fresh credentials written by the faster one.
HTTP Middleware Integration#
ServerClientAuthenticationMiddleware intercepts every outgoing request:
- Calls
authenticationToken(serverURL:refreshIfNeeded:true)to obtain a (potentially just-refreshed) token. - If
ServerAuthenticationConfig.optionalAuthenticationis enabled, missing or errored tokens allow the request through unauthenticated . - Otherwise throws
ClientAuthenticationError.notAuthenticated. - Injects
Authorization: Bearer {token}on success .
Key Files#
| File | Purpose |
|---|---|
ServerAuthenticationController.swift | Core refresh orchestration, locking, memoization |
RefreshAuthTokenService.swift | HTTP token-refresh call (unauthenticated client) |
ServerCredentialsStore.swift | Credential persistence (Keychain / file) |
CachedValueStore.swift | In-process and cross-process memoization |
ServerAuthenticationConfig.swift | @TaskLocal config (backgroundRefresh, optionalAuthentication) |
ServerClientAuthenticationMiddleware.swift | OpenAPI middleware injecting Bearer token |
ClientAuthenticationError.swift | Shared unauthenticated error type |