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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ GitHits requires authentication. There are two options:
npx githits login
```

Opens your browser for secure OAuth authentication. Tokens are stored locally and refreshed automatically on next use. If a refresh fails (e.g., after an extended idle period), run `githits login` again.
Opens your browser for secure OAuth authentication. Tokens are stored in the system keychain by default and refreshed automatically on next use. If a refresh fails (e.g., after an extended idle period), run `githits login` again.

Useful flags:

Expand All @@ -148,6 +148,18 @@ For CI or environments where browser login isn't practical, set an environment v
export GITHITS_API_TOKEN=ghi-your-token-here
```

For machines without a usable keychain, OAuth file storage must be explicit:

```toml
# ~/.config/githits/config.toml on Linux
[auth]
storage = "file"
```

File storage is plaintext on disk. Prefer `GITHITS_API_TOKEN` for CI and automation.

You can also opt in for one process with `GITHITS_AUTH_STORAGE=file`. Use file storage only on machines where local file access is trusted.

## Commands

```
Expand Down Expand Up @@ -177,6 +189,7 @@ githits code ... Dependency source inspection: search, files, read, grep
| Variable | Purpose | Default |
|---|---|---|
| `GITHITS_API_TOKEN` | API token for authentication | — |
| `GITHITS_AUTH_STORAGE` | Override OAuth storage mode (`keychain` or `file`) | `keychain` |
| `GITHITS_MCP_URL` | Override MCP server URL | `https://mcp.githits.com` |
| `GITHITS_API_URL` | Override REST API URL | `https://api.githits.com` |
| `GITHITS_CODE_NAV_URL` | Override package/source service URL | `https://pkgseer.dev` |
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 40 additions & 13 deletions docs/implementation/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The login command (`src/commands/login.ts`) orchestrates a 9-step OAuth flow (ma
6. **Open browser or print URL** — Navigate user to auth URL, or print it if `--no-browser` is set
7. **Verify state** — CSRF protection check on the callback
8. **Exchange code for tokens** — POST to token endpoint with PKCE verifier
9. **Save tokens** — Store to system keychain (or fallback file storage)
9. **Save tokens** — Store to the configured auth store

The flow has a 5-minute timeout. The callback server must start before the browser opens so it's ready to receive the redirect.

Expand All @@ -53,7 +53,9 @@ To clear tokens manually, use `githits logout`. This removes stored tokens for t

## Storage

Credentials are stored in the **system keychain** (macOS Keychain, Windows Credential Manager, Linux Secret Service) via `@napi-rs/keyring`. If the keychain is unavailable (headless Linux, CI), the CLI falls back to file-based storage in `~/.githits/` with a stderr warning.
Credentials are stored in the **system keychain** by default (macOS Keychain, Windows Credential Manager, Linux Secret Service) via `@napi-rs/keyring`. The CLI does not silently downgrade OAuth credentials to plaintext files when the keychain is unavailable.

Machines without a usable keychain can explicitly opt into plaintext OAuth storage with `auth.storage = "file"` in `config.toml` or `GITHITS_AUTH_STORAGE=file`. `GITHITS_API_TOKEN` remains the preferred automation/CI path because it avoids storing OAuth refresh credentials.

### Keychain storage (primary)

Expand Down Expand Up @@ -84,36 +86,58 @@ Values under 1200 characters are stored directly with no sentinel, maintaining f

The `getStorageLocation()` method returns a platform-specific label: "macOS Keychain (githits)" on macOS, "Windows Credential Manager (githits)" on Windows, and "System keychain (githits)" on Linux.

### File storage (fallback)
### File storage (explicit)

When the keychain is unavailable, auth data is stored in `~/.githits/` with two files:
When `auth.storage = "file"`, auth data is stored under the platform config auth directory with two files:

| File | Content | Structure |
|---|---|---|
| `auth.json` | OAuth tokens | `{ version: 1, tokens: { [mcpUrl]: { accessToken, refreshToken, expiresAt (string\|null), createdAt } } }` |
| `client.json` | DCR client registration | `{ version: 1, clients: { [mcpUrl]: { clientId, clientSecret, redirectUri, registeredAt } } }` |

Both files use 0600 permissions. The directory uses 0700.
Both files use 0600 permissions. The directory uses 0700. Rewrites use `FileSystemService.atomicWriteFile()` so a crash does not leave half-written JSON. This protects against other local users but does not encrypt credentials at rest.

Typical Linux layout:

```text
~/.config/githits/
config.toml
auth/
auth.json
client.json
```

The legacy `~/.githits/auth.json` and `~/.githits/client.json` path is still read for migration and cleared by logout, but new file-mode writes go to the platform config auth directory.

### Migration

On first use after upgrading, the `MigratingAuthStorage` decorator transparently migrates credentials from files to the keychain:
On first use after upgrading, `MigratingAuthStorage` transparently migrates credentials according to the configured storage mode:

Keychain mode:

1. Check keychain — if found, return it
2. Check file — if found, write to keychain, delete from file, return it
2. Check new file path, then legacy `~/.githits` — if found, write to keychain, delete the migrated plaintext entry, return it
3. Both empty — return null

Keychain write must succeed before the file entry is deleted. Tokens and client registrations migrate independently.
File mode:

1. Check new file path — if found, return it
2. Check legacy `~/.githits` — if found, write to new file path, delete legacy entry, return it
3. Check keychain only as a last-resort migration source, then warn before exporting encrypted credentials to plaintext

The configured target write must succeed before the source entry is deleted. Tokens and client registrations migrate independently. If both plaintext paths contain entries, the newer timestamp wins; ambiguous ties prefer the new file path and leave the other entry intact with a warning.

### Architecture

```
Container (createAuthStorage)
└─ MigratingAuthStorage (decorator)
├─ KeychainAuthStorage (primary)
├─ KeychainAuthStorage
│ └─ ChunkingKeyringService (Windows only, decorator)
│ └─ KeyringServiceImpl ← @napi-rs/keyring
└─ AuthStorageImpl (legacy) ← file-based
├─ ModeAwareFileAuthStorage
│ └─ AuthStorageImpl ← platform config auth path
└─ AuthStorageImpl ← legacy ~/.githits path
```

All credential types are keyed by normalized MCP base URL (trailing slashes stripped), supporting multiple environments simultaneously.
Expand Down Expand Up @@ -147,7 +171,7 @@ The `hasValidToken` flag is checked by `requireAuth()` in `src/commands/mcp.ts`
- **Port conflicts on login** — The callback server uses the port from the stored client registration. On first login, a random port (8000–9999) is chosen and saved. Use `--port <port>` to change it (triggers re-registration).
- **Token refresh fails silently** — By design. The container clears stale auth and `hasValidToken` becomes false, prompting re-login.
- **Clearing auth** — Run `githits logout` to remove stored tokens and client registration for the current environment.
- **Keychain unavailable warning** — If the system keychain is not accessible (headless Linux, CI), the CLI falls back to file storage in `~/.githits/` and prints a warning to stderr.
- **System keychain unavailable** — In default keychain mode, OAuth login/refresh fails rather than writing plaintext credentials. Use `GITHITS_API_TOKEN`, fix/unlock the keychain, or explicitly configure `auth.storage = "file"` if plaintext local storage is acceptable.
- **Windows "password encoded as UTF-16 is longer than platform limit"** — The Windows Credential Manager limits credential blobs to 2560 bytes (`CRED_MAX_CREDENTIAL_BLOB_SIZE`). Since passwords are stored as UTF-16 (2 bytes per char), the effective limit is 1280 characters. The `ChunkingKeyringService` decorator handles this automatically by splitting large values across multiple entries. If this error occurs on an older CLI version, upgrade to get chunked storage support.

## Key Reference Files
Expand All @@ -156,17 +180,20 @@ The `hasValidToken` flag is checked by `requireAuth()` in `src/commands/mcp.ts`
|---|---|
| `src/commands/login.ts` | Full OAuth PKCE flow orchestration |
| `src/commands/logout.ts` | Token and client removal and storage cleanup |
| `src/container.ts` | Dependency wiring, keychain probe with fallback |
| `src/container.ts` | Dependency wiring and auth-command container without eager token refresh |
| `src/services/token-manager.ts` | `TokenProvider` interface, `TokenManager` (proactive refresh, coalescing) |
| `src/services/refreshing-githits-service.ts` | `GitHitsService` decorator with token refresh and 401 retry |
| `src/services/execute-with-token-refresh.ts` | Shared helper for token-authenticated retry-on-refresh flows |
| `src/services/code-navigation-service.ts` | Package/source service client using the shared refresh helper |
| `src/services/auth-service.ts` | OAuth operations (DCR, PKCE, token exchange, callback server) |
| `src/services/auth-storage.ts` | `AuthStorage` interface and file-based implementation |
| `src/services/auth-config.ts` | `config.toml` and `GITHITS_AUTH_STORAGE` parsing |
| `src/services/app-config-paths.ts` | Platform-specific config/auth path resolution |
| `src/services/mode-aware-file-auth-storage.ts` | File-write guard for `auth.storage` policy |
| `src/services/keyring-service.ts` | `KeyringService` interface wrapping `@napi-rs/keyring` |
| `src/services/chunking-keyring-service.ts` | `KeyringService` decorator for chunked storage (Windows 2560-char limit) |
| `src/services/keychain-auth-storage.ts` | `AuthStorage` implementation backed by system keychain |
| `src/services/migrating-auth-storage.ts` | Migration decorator (keychain primary + file legacy) |
| `src/services/migrating-auth-storage.ts` | Mode-aware migration across keychain, config file storage, and legacy file storage |
| `src/services/filesystem-service.ts` | File system abstraction for testable storage |
| `src/auth/pkce.ts` | PKCE cryptographic primitives |
| `src/services/config.ts` | URL and API token configuration |
50 changes: 41 additions & 9 deletions docs/implementation/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ GitHits separates its MCP server (which handles OAuth discovery and the MCP prot

> **These are different services.** Override every URL that differs from production when pointing to a non-production backend.

The MCP URL is also used as the storage key for tokens and client registrations in `~/.githits/` (trailing slashes are stripped for consistent key matching). This means tokens from one environment don't leak into another.
The MCP URL is also used as the storage key for tokens and client registrations (trailing slashes are stripped for consistent key matching). This means tokens from one environment don't leak into another.

## Authentication Modes

The container (`src/container.ts`) resolves authentication in priority order:

1. **`GITHITS_API_TOKEN`** — If set, uses this token directly. No OAuth flow needed. Quick to set up for CI and automation environments.

2. **Stored OAuth JWT** — Loaded from `~/.githits/auth.json`. If expired, the container automatically attempts a refresh using the stored refresh token. If refresh fails, auth is cleared silently.
2. **Stored OAuth JWT** — Loaded from the configured auth store. If expired, the container automatically attempts a refresh using the stored refresh token. If refresh fails, auth is cleared silently.

3. **Unauthenticated** — No token available. Auth-required CLI commands fail on use, and the MCP server can start but every authenticated tool call will fail. Commands like `auth status` still work to help the user diagnose the issue.

Expand All @@ -48,19 +48,47 @@ Package/source access uses the package/source service URL from `GITHITS_CODE_NAV
| `GITHITS_API_URL` | Override REST API URL | `http://localhost:8000` |
| `GITHITS_CODE_NAV_URL` | Override package/source service URL | `http://localhost:4000` |
| `GITHITS_API_TOKEN` | API token for authentication | `ghi-abc123...` |
| `GITHITS_AUTH_STORAGE` | Override OAuth credential storage for the current process (`keychain` or `file`) | `file` |
| `GITHITS_TELEMETRY` | Emit end-of-run timing spans to stderr for local profiling | `1` |

## Local Storage

Authentication state lives in `~/.githits/`:
GitHits config uses the platform config directory:

```toml
# ~/.config/githits/config.toml on Linux
[auth]
storage = "keychain"
```
~/.githits/ (0700)
auth.json (0600) — OAuth tokens keyed by MCP URL
client.json (0600) — DCR client registrations keyed by MCP URL

| `auth.storage` | Meaning |
|---|---|
| `keychain` | Default. Store OAuth tokens and DCR client secrets in the system keychain only. |
| `file` | Store OAuth tokens and DCR client secrets as plaintext JSON under the platform config auth path. |

Invalid `GITHITS_AUTH_STORAGE` or `auth.storage` values fail fast with a message that includes the expected values. Runtime keychain-unavailable errors include the exact config file path and the `[auth] storage = "file"` snippet, plus a plaintext-storage warning.

When file mode is enabled, storage uses secure file permissions but is not encrypted:

```text
~/.config/githits/ (0700 on Linux)
config.toml (0600 when written by GitHits)
auth/ (0700)
auth.json (0600) — OAuth tokens keyed by MCP URL
client.json (0600) — DCR client registrations keyed by MCP URL
```

The secure file permissions prevent other users from reading tokens. When writing new files to `~/.githits/`, use `FileSystemService` rather than `node:fs` directly — this enables testing via mock implementations from `src/services/test-helpers.ts`.
Platform roots:

| Platform | Config root |
|---|---|
| Linux/Unix | `$XDG_CONFIG_HOME/githits`, or `~/.config/githits` |
| macOS | `~/Library/Application Support/githits` |
| Windows | `%APPDATA%\githits`, or `~/AppData/Roaming/githits` |

Legacy `~/.githits/auth.json` and `~/.githits/client.json` are still read for migration and cleared by logout, but new plaintext writes use the platform config auth path.

When writing config or auth files, use `FileSystemService` rather than `node:fs` directly — this enables testing via mock implementations from `src/services/test-helpers.ts`. Auth credential rewrites should use `atomicWriteFile()` to avoid truncated JSON on crashes.

Non-secret update-check state uses the XDG config location:

Expand All @@ -76,12 +104,13 @@ eligibility rules.
## How Config Flows Through the System

```
Environment variables
└─ src/services/config.ts (getMcpUrl, getApiUrl, getCodeNavigationUrl, getEnvApiToken)
Environment variables + config.toml
└─ src/services/config.ts / auth-config.ts
└─ src/container.ts (createContainer)
├─ mcpUrl → passed to auth commands, used as storage key
├─ apiUrl → passed to GitHitsServiceImpl constructor
├─ codeNavigationUrl → passed to CodeNavigationServiceImpl and PackageIntelligenceServiceImpl
├─ auth.storage → controls OAuth credential persistence
├─ apiToken → resolved from env var or OAuth storage
└─ hasValidToken → gates authenticated commands
```
Expand All @@ -93,6 +122,7 @@ Commands receive the full `Dependencies` object. Services receive only what they
- **"Authentication required" despite having a token** — Token may be expired and refresh failed. Run `githits login` to re-authenticate.
- **Custom environment not working** — Make sure both `GITHITS_MCP_URL` and `GITHITS_API_URL` are set. They point to different services.
- **Tokens from wrong environment** — Tokens are stored per MCP URL. If you switched `GITHITS_MCP_URL`, you need to re-authenticate for the new URL.
- **System keychain unavailable** — Default keychain mode fails rather than writing plaintext OAuth credentials. Use `GITHITS_API_TOKEN`, fix/unlock the keychain, or set `auth.storage = "file"` / `GITHITS_AUTH_STORAGE=file` if unencrypted file storage is acceptable.

### Init config parsing behavior

Expand All @@ -107,6 +137,8 @@ Commands receive the full `Dependencies` object. Services receive only what they
| File | What it demonstrates |
|---|---|
| `src/services/config.ts` | URL and token resolution from environment |
| `src/services/auth-config.ts` | `config.toml` and `GITHITS_AUTH_STORAGE` auth storage mode parsing |
| `src/services/app-config-paths.ts` | Platform config path resolution |
| `src/container.ts` | Auth priority logic and dependency wiring |
| `src/services/auth-storage.ts` | File-based token storage with secure permissions |
| `src/services/filesystem-service.ts` | File system abstraction for testable storage |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"jsonc-parser": "^3.3.1",
"open": "^11.0.0",
"semver": "^7.7.4",
"smol-toml": "^1.6.1",
"zod": "^4.1.13"
},
"devDependencies": {
Expand Down
25 changes: 20 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
registerPkgCommandGroup,
registerUnifiedSearchCommands,
} from "./commands/index.js";
import { AuthConfigError, AuthStoragePolicyError } from "./services/index.js";
import {
FileSystemServiceImpl,
NpmRegistryUpdateCheckService,
Expand Down Expand Up @@ -133,11 +134,25 @@ const authCommand = program
.description("Manage authentication with GitHits.");
registerAuthStatusCommand(authCommand);

await runWithUpdateCheckFlush(
() => withTelemetrySpan("cli.parse", () => program.parseAsync()),
updateCheckTask,
{ stderr: process.stderr },
);
try {
await runWithUpdateCheckFlush(
() => withTelemetrySpan("cli.parse", () => program.parseAsync()),
updateCheckTask,
{ stderr: process.stderr },
);
} catch (error) {
if (isUserFacingError(error)) {
console.error(`${error.message}\n`);
process.exit(1);
}
throw error;
}

function isUserFacingError(error: unknown): error is Error {
return (
error instanceof AuthConfigError || error instanceof AuthStoragePolicyError
);
}

/**
* Commander supports root options before subcommands, e.g.
Expand Down
Loading
Loading