diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-11 08:50:17 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-11 08:50:17 -0700 |
| commit | c8fd7d3d8f2c93c24a4a5fc41361dfed8714e73a (patch) | |
| tree | e88cf8afa702be0925c6f606df2bb3c270679fce /crates/atuin-client/src/api_client.rs | |
| parent | chore: update changelog (diff) | |
| download | atuin-c8fd7d3d8f2c93c24a4a5fc41361dfed8714e73a.zip | |
feat: Allow authenticating with Atuin Hub (#3237)
## Summary
This PR enables the Atuin CLI to authenticate with Atuin Hub, unifying
authentication across CLI sync and Hub features (AI, runbooks, etc.).
### Key Changes
- **Dual auth support**: New `AuthToken` enum supports both `Bearer`
(Hub) and `Token` (legacy CLI) authentication
- **Smart protocol selection**: New `sync_protocol` setting
(`auto`/`hub`/`legacy`) determines auth method. By default,
`api.atuin.sh` uses Hub auth; custom sync addresses use legacy auth
- **Hub login flow**: `atuin login` now initiates an OAuth-like flow for
Hub users—generates a code, user authorizes in browser, CLI polls for
completion
- **Account linking**: After Hub auth, silently attempts to link
existing CLI sync account to Hub account for seamless migration
- **Graceful fallback**: `sync_auth_token()` prefers Hub token when
available, falls back to CLI session token
### Auth Flow
1. User runs `atuin login` (with default sync address)
2. CLI requests auth code from Hub, displays URL
3. User opens URL, logs in/registers on Hub
4. Hub attaches API token to code
5. CLI polls, receives token, saves as hub session
6. If user had existing CLI sync account, it's automatically linked
### Backward Compatibility
- Existing self-hosted users: unaffected (legacy auth via `Token`
header)
- Existing `api.atuin.sh` users: continue working with CLI session until
they run `atuin login`
- New users: go through Hub flow automatically
## Test Plan
- [ ] New user registration via Hub flow
- [ ] Existing CLI user can still sync without changes
- [ ] `atuin login` links CLI account to Hub account
- [ ] Self-hosted users unaffected by changes
- [ ] AI commands work after Hub auth
---------
Co-authored-by: Ellie Huxtable <ellie@elliehuxtable.com>
Diffstat (limited to 'crates/atuin-client/src/api_client.rs')
| -rw-r--r-- | crates/atuin-client/src/api_client.rs | 37 |
1 files changed, 32 insertions, 5 deletions
diff --git a/crates/atuin-client/src/api_client.rs b/crates/atuin-client/src/api_client.rs index aeca6492..066fecb5 100644 --- a/crates/atuin-client/src/api_client.rs +++ b/crates/atuin-client/src/api_client.rs @@ -30,6 +30,32 @@ use crate::{history::History, sync::hash_str, utils::get_host_user}; static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),); +/// Authentication token for sync API requests. +/// +/// The sync API supports two authentication methods: +/// - `Bearer`: Hub API tokens (for users authenticated via Atuin Hub) +/// - `Token`: Legacy CLI session tokens (for users registered via CLI or self-hosted) +/// +/// When both are available, Hub tokens are preferred as they provide unified +/// authentication across CLI and Hub features. +#[derive(Debug, Clone)] +pub enum AuthToken { + /// Hub API token, used with "Bearer {token}" header + Bearer(String), + /// Legacy CLI session token, used with "Token {token}" header + Token(String), +} + +impl AuthToken { + /// Format the token as an Authorization header value + fn to_header_value(&self) -> String { + match self { + AuthToken::Bearer(token) => format!("Bearer {token}"), + AuthToken::Token(token) => format!("Token {token}"), + } + } +} + pub struct Client<'a> { sync_addr: &'a str, client: reqwest::Client, @@ -162,6 +188,7 @@ pub fn ensure_version(response: &Response) -> Result<bool> { async fn handle_resp_error(resp: Response) -> Result<Response> { let status = resp.status(); + let url = resp.url().to_string(); if status == StatusCode::SERVICE_UNAVAILABLE { bail!( @@ -178,16 +205,16 @@ async fn handle_resp_error(resp: Response) -> Result<Response> { let reason = error.reason; if status.is_client_error() { - bail!("Invalid request to the service: {status} - {reason}.") + bail!("Invalid request to the service at {url}, {status} - {reason}.") } bail!( - "There was an error with the atuin sync service, server error {status}: {reason}.\nIf the problem persists, contact the host" + "There was an error with the atuin sync service at {url}, server error {status}: {reason}.\nIf the problem persists, contact the host" ) } bail!( - "There was an error with the atuin sync service: Status {status:?}.\nIf the problem persists, contact the host" + "There was an error with the atuin sync service at {url}, Status {status:?}.\nIf the problem persists, contact the host" ) } @@ -197,13 +224,13 @@ async fn handle_resp_error(resp: Response) -> Result<Response> { impl<'a> Client<'a> { pub fn new( sync_addr: &'a str, - session_token: &str, + auth: AuthToken, connect_timeout: u64, timeout: u64, ) -> Result<Self> { ensure_crypto_provider(); let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, format!("Token {session_token}").parse()?); + headers.insert(AUTHORIZATION, auth.to_header_value().parse()?); // used for semver server check headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?); |
