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
27 changes: 18 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,35 +278,44 @@ async function storeAuth(
});
}

/** Layered token refresh: keychain → stored refresh → CLI refresh token. */
/** Layered token refresh: valid keychain access → stored refresh → CLI refresh token. */
async function refreshAuth(
auth: AuthType,
client: PluginClient,
): Promise<string | null> {
type Tokens = { access: string; refresh: string; expires: number };
let fresh: Tokens | null = null;
const errors: string[] = [];
const attemptedRefreshTokens = new Set<string>();

// Layer 1: Claude CLI keychain
const tryRefresh = (refreshToken: string | undefined): Tokens | null => {
if (!refreshToken || attemptedRefreshTokens.has(refreshToken)) return null;
attemptedRefreshTokens.add(refreshToken);
return refreshTokens(refreshToken);
};

// Layer 1: Claude CLI keychain, but only if the access token is already
// valid. Expired keychain refresh tokens are often stale after re-login, so
// do not try them before OpenCode's own stored OAuth refresh token.
try {
const kt = getClaudeTokens();
const kt = getClaudeTokens({ refreshExpired: false });
if (kt && kt.expires > Date.now() + 60_000) fresh = kt;
} catch (err) {
errors.push(String(err));
}

// Layer 2: Stored refresh token
if (!fresh && auth.refresh) {
try { fresh = refreshTokens(auth.refresh); }
try { fresh = tryRefresh(auth.refresh); }
catch (err) { errors.push(String(err)); }
}

// Layer 3: CLI refresh token
// Layer 3: CLI refresh token, only if it differs from tokens already tried.
if (!fresh) {
try {
const creds = readClaudeCredentials();
if (creds?.claudeAiOauth?.refreshToken)
fresh = refreshTokens(creds.claudeAiOauth.refreshToken);
fresh = tryRefresh(creds.claudeAiOauth.refreshToken);
} catch (err) {
errors.push(String(err));
}
Expand Down Expand Up @@ -538,7 +547,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => {
// OpenCode builds its provider state. This ensures the loader runs on
// startup so models appear immediately without requiring a restart.
try {
const tokens = getClaudeTokens();
const tokens = getClaudeTokens({ refreshExpired: false });
if (tokens) await storeAuth(client, tokens);
} catch {}

Expand Down Expand Up @@ -576,7 +585,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => {
// Auto-bootstrap from Claude CLI keychain if no OAuth tokens stored
if (auth.type !== "oauth") {
try {
const tokens = getClaudeTokens();
const tokens = getClaudeTokens({ refreshExpired: false });
if (tokens) {
await storeAuth(client, tokens);
auth = { type: "oauth", ...tokens };
Expand Down Expand Up @@ -912,7 +921,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => {
type: "oauth" as const,
authorize: async () => {
// First try: auto-bootstrap from Claude CLI keychain
const tokens = getClaudeTokens();
const tokens = getClaudeTokens({ refreshExpired: false });
if (tokens) {
await storeAuth(client, tokens);
return {
Expand Down
18 changes: 15 additions & 3 deletions src/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ interface KeychainCredentials {
};
}

interface GetClaudeTokensOptions {
refreshExpired?: boolean;
logRefreshFailures?: boolean;
}

/**
* Read Claude CLI credentials from macOS Keychain.
* Falls back to ~/.claude/.credentials.json on other platforms.
Expand Down Expand Up @@ -49,7 +54,9 @@ export function readClaudeCredentials(): KeychainCredentials | null {
* Get valid OAuth tokens from Claude CLI.
* If expired, attempts to refresh via curl.
*/
export function getClaudeTokens(): OAuthTokens | null {
export function getClaudeTokens(options: GetClaudeTokensOptions = {}): OAuthTokens | null {
const refreshExpired = options.refreshExpired ?? true;
const logRefreshFailures = options.logRefreshFailures ?? true;
const creds = readClaudeCredentials();
if (!creds?.claudeAiOauth) return null;

Expand All @@ -64,13 +71,18 @@ export function getClaudeTokens(): OAuthTokens | null {
};
}

// Expired — try refresh
// Expired — try refresh only when the caller explicitly wants side effects.
// Login/bootstrap paths use this in read-only mode so stale Claude CLI
// keychain refresh tokens do not produce invalid_grant noise before a fresh
// OAuth login starts.
if (!refreshExpired) return null;

if (refreshToken) {
try {
console.error("[opencode-oauth] Claude CLI token expired, refreshing...");
return refreshTokens(refreshToken);
} catch (err) {
console.error(`[opencode-oauth] Keychain refresh failed: ${err}`);
if (logRefreshFailures) console.error(`[opencode-oauth] Keychain refresh failed: ${err}`);
}
}

Expand Down