Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ BINDIR = $(PREFIX)/bin
BINARY = ivaldi
TARGET = target/release/$(BINARY)

.PHONY: build test install install-extras uninstall clean help
.PHONY: build test install install-extras install-raven-completions uninstall clean help

## Build release binary
build:
Expand Down Expand Up @@ -35,6 +35,14 @@ install-extras: build
@$(TARGET) completions fish > $(PREFIX)/share/fish/vendor_completions.d/ivaldi.fish
@echo "Installed man pages and completions under $(PREFIX)/share"

## Install the RavenShell completion spec to ~/.config/ravenshell/completions
## (per-user config, so no sudo — kept separate from install-extras)
install-raven-completions: build
@echo "Installing RavenShell completion spec..."
@install -d $(HOME)/.config/ravenshell/completions
@$(TARGET) completions raven > $(HOME)/.config/ravenshell/completions/ivaldi.json
@echo "Installed $(HOME)/.config/ravenshell/completions/ivaldi.json"

## Uninstall ivaldi from $(PREFIX)/bin
uninstall:
@echo "Removing $(BINDIR)/$(BINARY)..."
Expand All @@ -53,6 +61,7 @@ help:
@echo " make test Run all tests"
@echo " make install Install to $(BINDIR) (may need sudo)"
@echo " make install-extras Install man pages and bash/zsh/fish completions (may need sudo)"
@echo " make install-raven-completions Install RavenShell completion spec (no sudo)"
@echo " make uninstall Remove from $(BINDIR) (may need sudo)"
@echo " make clean Clean build artifacts"
@echo ""
Expand Down
53 changes: 52 additions & 1 deletion docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,63 @@ For the GitLab device flow specifically, see [`gitlab.md`](gitlab.md).
1. **Ivaldi OAuth token** (`~/.config/ivaldi/auth.json`) — highest priority
2. **Environment variable** (`GITHUB_TOKEN` / `GITLAB_TOKEN`)
3. **`.netrc` file**
4. **Platform CLI** (`gh auth login` / `glab auth login`)
4. **Platform CLI** (`gh` / `glab`)

For GitHub, the platform-CLI source asks `gh auth token` directly rather than
parsing `~/.config/gh/hosts.yml`. By default `gh` stores its token in the OS
keyring (macOS Keychain / libsecret) and only writes `hosts.yml` with
`--insecure-storage`, so the old file-parse silently missed most installs —
which pushed Ivaldi into minting its own competing OAuth token. `hosts.yml`
remains a fallback for older `gh` versions.

## `ivaldi auth login` (GitHub)

The login command is **reuse-aware** so it does not pile up tokens:

1. `--with-token` — read a Personal Access Token from stdin and store it,
skipping the browser device flow. **This is the recommended choice for
multi-device use** (see below).
2. Otherwise, if a usable credential already resolves (a valid `gh` / env /
`.netrc` token, or a prior ivaldi token that still validates against
`GET /user`), Ivaldi reuses it and does **not** mint a new token. A stored
ivaldi token that GitHub now rejects is dropped first, so login falls back
to `gh` instead of adding another token.
3. Only when nothing usable exists does Ivaldi run the OAuth device flow.
4. `--force` skips steps 2–3 and always mints a fresh ivaldi token.

## Multi-device behavior & the 10-token cap

GitHub limits an OAuth App to **10 tokens per user / application / scope**;
minting an 11th **silently revokes the oldest**
([docs](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps)).
Because Ivaldi's device flow uses the GitHub CLI's public OAuth App with a
fixed scope, every `ivaldi auth login` (and every `gh auth login` / refresh)
spends a slot — so after enough logins across machines, an *older* device gets
logged out. That is the "cross-device auth ping-pong", and refresh tokens do
**not** fix it (OAuth Apps can't issue them, and refreshing rotates one
device's own chain without freeing a slot).

Mitigations, in order of robustness:

- **Personal Access Token** (`ivaldi auth login --with-token`): a PAT is
independent of the OAuth-App cap. Paste the **same** fine-grained or classic
PAT (with `repo` scope) on every device and none of them ever evict another.
- **Reuse `gh`**: on machines where `gh` is logged in, Ivaldi now reuses that
one token instead of minting its own, so it stops contributing to the churn.
- **Your own GitHub App** (advanced): register a GitHub App with the device
flow enabled and point Ivaldi at it via `IVALDI_GITHUB_CLIENT_ID`. GitHub
Apps issue expiring access tokens + refresh tokens (no client secret needed
for the device flow), giving each device an independent, self-refreshing
credential — though the 10-token cap still applies beyond 10 devices.

## Token Storage

Location: `~/.config/ivaldi/auth.json` (permissions: 0600)

The file is written **atomically with 0600 from creation** (temp file created
mode 0600, then renamed over the target), so the token is never momentarily
world-readable as a plain write-then-chmod would leave it.

```json
{
"github": {
Expand Down
81 changes: 71 additions & 10 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,10 @@ impl TokenStore {

let data = serde_json::to_string_pretty(&storage).map_err(AuthError::Json)?;

// Write with restricted permissions
fs::write(&self.config_path, &data).map_err(AuthError::Io)?;

// Set file permissions to 0600 (owner read/write only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(&self.config_path, perms).map_err(AuthError::Io)?;
}
// Write atomically with 0600 perms from creation, so the token (and any
// long-lived refresh token) is never world-readable, not even in the
// brief window between create and chmod that a plain write+chmod leaves.
write_secret_file(&self.config_path, data.as_bytes()).map_err(AuthError::Io)?;

Ok(())
}
Expand Down Expand Up @@ -292,6 +286,47 @@ fn home_dir() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}

/// Write `bytes` to `path` atomically with owner-only (0600) permissions.
///
/// The bytes go to a temp file in the same directory, created with mode 0600
/// up front (so the secret is never briefly world-readable as a plain
/// `write` + later `chmod` would allow), then renamed over `path`. Readers see
/// either the old or the new contents, never a partial file.
fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;

let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("auth");
let tmp = parent.join(format!(".{}.tmp.{}", file_name, std::process::id()));

// A leftover temp from a crashed run could have the wrong perms; start clean
// so the `create_new` below always makes a fresh 0600 file.
let _ = fs::remove_file(&tmp);

let result = (|| -> std::io::Result<()> {
#[cfg(unix)]
let mut f = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?
};
#[cfg(not(unix))]
let mut f = fs::File::create(&tmp)?;

f.write_all(bytes)?;
f.sync_all()?;
fs::rename(&tmp, path)
})();

if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}

fn read_netrc_token(machine: &str) -> Option<String> {
let content = fs::read_to_string(home_dir()?.join(".netrc")).ok()?;
let mut in_machine = false;
Expand All @@ -309,6 +344,17 @@ fn read_netrc_token(machine: &str) -> Option<String> {
}

fn read_gh_cli_token() -> Option<String> {
// Prefer asking the gh CLI itself. By default gh stores its token in the OS
// keyring (macOS Keychain / libsecret), and only writes it to hosts.yml
// with `--insecure-storage`. Reading the file therefore misses most real
// installs, which previously left ivaldi unable to reuse gh's login and
// pushed it to mint its own competing OAuth token. `gh auth token` prints
// the active token regardless of where gh stored it.
if let Some(token) = gh_auth_token() {
return Some(token);
}

// Fallback for older gh versions / `--insecure-storage`: parse hosts.yml.
let content = fs::read_to_string(home_dir()?.join(".config/gh/hosts.yml")).ok()?;
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
Expand All @@ -322,6 +368,21 @@ fn read_gh_cli_token() -> Option<String> {
None
}

/// Ask the gh CLI for the active GitHub token, if gh is installed and
/// authenticated. Returns `None` on any failure (gh missing, not logged in,
/// non-zero exit), so callers fall through to other credential sources.
fn gh_auth_token() -> Option<String> {
let output = std::process::Command::new("gh")
.args(["auth", "token", "--hostname", "github.com"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let token = String::from_utf8(output.stdout).ok()?.trim().to_string();
if token.is_empty() { None } else { Some(token) }
}

fn read_glab_cli_token() -> Option<String> {
let content = fs::read_to_string(home_dir()?.join(".config/glab-cli/config.yml")).ok()?;
let lines: Vec<&str> = content.lines().collect();
Expand Down
Loading
Loading