From 8539fb9407651ecd3c314c04eac8558ab2dbc649 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 26 Apr 2026 08:34:08 +0800 Subject: [PATCH] feat(anthropic): Claude Code OAuth credential plumbing --- .../anthropic/oauth/ClaudeCodeApiHeaders.java | 85 ++++++ .../oauth/ClaudeCodeCredentials.java | 62 ++++ .../oauth/ClaudeCodeCredentialsReader.java | 172 +++++++++++ .../oauth/ClaudeCodeCredentialsWriter.java | 269 ++++++++++++++++++ .../oauth/ClaudeCodeOAuthService.java | 130 +++++++++ .../oauth/ClaudeCodeTokenRefresher.java | 146 ++++++++++ .../oauth/ClaudeCodeVersionDetector.java | 113 ++++++++ 7 files changed, 977 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeaders.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentials.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReader.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetector.java diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeaders.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeaders.java new file mode 100644 index 00000000..f3306cf8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeaders.java @@ -0,0 +1,85 @@ +package vip.mate.llm.anthropic.oauth; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * RFC-062: produces the HTTP header set Anthropic expects on OAuth-authenticated + * Messages-API requests. + * + *

OAuth requests get extra beta headers + a User-Agent that masquerades as + * Claude Code. Without these, Anthropic's infrastructure intermittently 500s. + * Reference: hermes-agent {@code anthropic_adapter} lines 226-238 + the + * dispatch in {@code build_anthropic_client} (line 423-433). + * + *

Header reference

+ * + * + * + * + * + * + * + * + *
Header set sent on OAuth requests
HeaderValueWhy
{@code Authorization}{@code Bearer }OAuth path uses Bearer; non-OAuth uses {@code x-api-key}
{@code User-Agent}{@code claude-cli/ (external, cli)}Anthropic routes OAuth by UA; spoof identity
{@code x-app}{@code cli}Claude Code identity flag
{@code anthropic-beta}(comma-joined list — see {@link #allBetas()})OAuth-only + common feature betas
+ */ +@Component +@RequiredArgsConstructor +public class ClaudeCodeApiHeaders { + + /** Beta headers required for any OAuth request. Anthropic's infra + * intermittently 500s OAuth traffic without them. */ + static final List OAUTH_ONLY_BETAS = List.of( + "claude-code-20250219", + "oauth-2025-04-20" + ); + + /** Common beta headers for enhanced features. GA on Claude 4.6+ but kept + * for <= 4.5 compat — the headers are accepted as no-ops on newer + * models so it's safe to send always. */ + static final List COMMON_BETAS = List.of( + "interleaved-thinking-2025-05-14", + "fine-grained-tool-streaming-2025-05-14" + ); + + private final ClaudeCodeVersionDetector versionDetector; + + /** + * Comma-joined beta header list to send in {@code anthropic-beta}. + *

Order matches hermes-agent {@code anthropic_adapter._OAUTH_ONLY_BETAS + + * _COMMON_BETAS} (OAuth-specific betas first). + */ + public String allBetas() { + return String.join(",", + concat(OAUTH_ONLY_BETAS, COMMON_BETAS)); + } + + /** + * User-Agent string Anthropic OAuth infrastructure expects. + * Format: {@code claude-cli/ (external, cli)}. + * The {@code (external, cli)} suffix is the canonical hermes / OpenCode / + * Cline identity — drop it and Anthropic returns 400. + */ + public String userAgent() { + return "claude-cli/" + versionDetector.get() + " (external, cli)"; + } + + /** {@code x-app} header value. Constant. */ + public String xApp() { + return "cli"; + } + + /** {@code Authorization} header value for the given access token. */ + public String bearerAuth(String accessToken) { + return "Bearer " + accessToken; + } + + private static List concat(List a, List b) { + java.util.ArrayList out = new java.util.ArrayList<>(a.size() + b.size()); + out.addAll(a); + out.addAll(b); + return out; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentials.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentials.java new file mode 100644 index 00000000..d06d7839 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentials.java @@ -0,0 +1,62 @@ +package vip.mate.llm.anthropic.oauth; + +/** + * RFC-062: a Claude Code OAuth credential bundle, parsed from either the macOS + * Keychain or {@code ~/.claude/.credentials.json}. + * + *

The shape mirrors the {@code claudeAiOauth} object that Claude Code + * persists. Fields are nullable when the source doesn't carry them + * (e.g. managed keys with no expiry, or older Claude Code versions that + * skipped the refresh token). + * + * @param accessToken the Bearer token to send to Anthropic API + * @param refreshToken token used to obtain a fresh access token; nullable + * @param expiresAtMs epoch millis when the access token expires; 0 means "no expiry" + * @param source where this credential was read from — used for diagnostics + * and for routing writes back to the same storage + */ +public record ClaudeCodeCredentials( + String accessToken, + String refreshToken, + long expiresAtMs, + Source source +) { + + /** + * Storage location the credential was read from. Determines write-back + * destination and influences refresh behaviour. + */ + public enum Source { + /** macOS Keychain entry "Claude Code-credentials" (Claude Code >= 2.1.114). */ + MACOS_KEYCHAIN, + /** {@code ~/.claude/.credentials.json} JSON file (all platforms, legacy). */ + CREDENTIALS_FILE, + /** Result of a successful refresh — to be written back to whatever the + * original source was. */ + REFRESH_RESPONSE + } + + /** + * Returns true if the access token is non-blank and not within + * {@code bufferMs} of expiring. + * + * @param bufferMs safety margin (e.g. 60_000 to refresh 1 minute before expiry). + * 0 means "still valid even if it expires this instant". + */ + public boolean isValid(long bufferMs) { + if (accessToken == null || accessToken.isBlank()) { + return false; + } + if (expiresAtMs == 0L) { + // No expiry recorded (managed keys / older Claude Code formats) — + // assume valid as long as token is present. + return true; + } + return System.currentTimeMillis() < (expiresAtMs - bufferMs); + } + + /** True if a refresh is possible (refresh token is present). */ + public boolean canRefresh() { + return refreshToken != null && !refreshToken.isBlank(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReader.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReader.java new file mode 100644 index 00000000..150ff3c6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReader.java @@ -0,0 +1,172 @@ +package vip.mate.llm.anthropic.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * RFC-062: read Claude Code OAuth credentials from local storage. + * + *

Two sources, in priority order: + *

    + *
  1. macOS Keychain entry {@code Claude Code-credentials} (Claude Code + * >= 2.1.114 stores here). Read via the {@code security} CLI tool.
  2. + *
  3. JSON file at {@code ~/.claude/.credentials.json} (legacy + Linux/Win).
  4. + *
+ * + *

Both sources contain the same JSON shape: + *

+ * {
+ *   "claudeAiOauth": {
+ *     "accessToken": "...",
+ *     "refreshToken": "...",
+ *     "expiresAt": 1234567890123,
+ *     "scopes": ["user:inference", ...]
+ *   }
+ * }
+ * 
+ * + *

Reference: hermes-agent {@code anthropic_adapter._read_claude_code_credentials_from_keychain} + * (line 470) and {@code read_claude_code_credentials} (line 530). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ClaudeCodeCredentialsReader { + + /** macOS Keychain service name written by Claude Code. */ + static final String KEYCHAIN_SERVICE_NAME = "Claude Code-credentials"; + + /** Hermes also queries {@code ~/.claude.json primaryApiKey} but that's a + * managed key, not OAuth — intentionally not read here. */ + static final Path JSON_CREDENTIALS_PATH = + Paths.get(System.getProperty("user.home"), ".claude", ".credentials.json"); + + private final ObjectMapper objectMapper; + + /** + * Read whichever source exists, preferring Keychain on macOS. + * @return Optional credentials, never throws on the happy "not found" path. + */ + public Optional read() { + // macOS Keychain has priority on Darwin (and is the only valid source for + // Claude Code >= 2.1.114 — they migrated off the JSON file) + if (isMacOs()) { + Optional kc = readFromKeychain(); + if (kc.isPresent()) return kc; + } + return readFromJsonFile(); + } + + /** + * Specifically read from the macOS Keychain. Used for diagnostics; production + * code should use {@link #read()} which dispatches. + * @return empty when not on macOS, when {@code security} command isn't + * available, or when no entry exists. + */ + public Optional readFromKeychain() { + if (!isMacOs()) return Optional.empty(); + + Process process = null; + try { + ProcessBuilder pb = new ProcessBuilder( + "/usr/bin/security", + "find-generic-password", + "-s", KEYCHAIN_SERVICE_NAME, + "-w"); // -w prints just the password (no metadata) + pb.redirectErrorStream(false); + process = pb.start(); + + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + log.debug("[ClaudeCodeReader] keychain read timed out"); + return Optional.empty(); + } + if (process.exitValue() != 0) { + // Exit 44 = "item not found"; anything else also means "no creds for us" + log.debug("[ClaudeCodeReader] keychain returned exit {}", process.exitValue()); + return Optional.empty(); + } + String raw = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + return parseCredentials(raw, ClaudeCodeCredentials.Source.MACOS_KEYCHAIN); + } catch (Exception e) { + log.debug("[ClaudeCodeReader] keychain read failed: {}", e.getMessage()); + return Optional.empty(); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + } + + /** + * Specifically read from {@code ~/.claude/.credentials.json}. + * @return empty when the file is absent / unreadable / malformed. + */ + public Optional readFromJsonFile() { + return readFromJsonFile(JSON_CREDENTIALS_PATH); + } + + /** Test seam: read from a custom path. Package-private. */ + Optional readFromJsonFile(Path path) { + if (path == null || !Files.isReadable(path)) { + return Optional.empty(); + } + try { + String raw = Files.readString(path, StandardCharsets.UTF_8); + return parseCredentials(raw, ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + } catch (IOException e) { + log.debug("[ClaudeCodeReader] credentials file read failed: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * Parse the canonical Claude Code JSON envelope. + * Package-private for unit testing. + */ + Optional parseCredentials(String raw, ClaudeCodeCredentials.Source source) { + if (raw == null || raw.isBlank()) { + return Optional.empty(); + } + try { + JsonNode root = objectMapper.readTree(raw); + JsonNode oauth = root.path("claudeAiOauth"); + if (oauth.isMissingNode() || !oauth.isObject()) { + log.debug("[ClaudeCodeReader] payload missing claudeAiOauth object (source={})", source); + return Optional.empty(); + } + String accessToken = oauth.path("accessToken").asText(""); + if (accessToken.isBlank()) { + log.debug("[ClaudeCodeReader] claudeAiOauth.accessToken blank (source={})", source); + return Optional.empty(); + } + String refreshToken = oauth.path("refreshToken").asText(""); + long expiresAt = oauth.path("expiresAt").asLong(0L); + return Optional.of(new ClaudeCodeCredentials( + accessToken, + refreshToken.isBlank() ? null : refreshToken, + expiresAt, + source)); + } catch (Exception e) { + log.debug("[ClaudeCodeReader] JSON parse failed (source={}): {}", source, e.getMessage()); + return Optional.empty(); + } + } + + /** Package-private for tests to override. */ + boolean isMacOs() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriter.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriter.java new file mode 100644 index 00000000..14ca09f7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriter.java @@ -0,0 +1,269 @@ +package vip.mate.llm.anthropic.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * RFC-062: persist refreshed Claude Code OAuth credentials back to whichever + * source we read them from. + * + *

Two write targets, mirroring {@link ClaudeCodeCredentialsReader}: + *

    + *
  1. macOS Keychain entry {@code Claude Code-credentials} via the + * {@code security add-generic-password -U} CLI.
  2. + *
  3. {@code ~/.claude/.credentials.json} JSON file — written atomically + * (temp file + rename) with {@code 0600} permissions on POSIX.
  4. + *
+ * + *

Concurrent-write defence

+ * Claude Code itself may rewrite the credentials file while MateClaw is doing + * a refresh. The flow is: + *
    + *
  1. Re-read the file just before write.
  2. + *
  3. If the on-disk {@code accessToken} is newer than the one we are about + * to write (different from what we started the refresh with), bail out + * — the running Claude Code process beat us to it.
  4. + *
  5. Otherwise merge our refreshed fields into the existing JSON so we + * preserve {@code scopes} (Claude Code >= 2.1.81 requires + * {@code user:inference}) and any future fields we don't know about.
  6. + *
+ * + *

Reference: hermes-agent + * {@code anthropic_adapter._write_claude_code_credentials} (line 684-727) + * and {@code _write_claude_code_credentials_to_keychain} (line 730+). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ClaudeCodeCredentialsWriter { + + /** POSIX permissions for the credentials file: owner read+write only. */ + private static final Set CREDENTIALS_PERMS = + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + + private final ObjectMapper objectMapper; + + /** + * Write the refreshed credentials back to whichever source the originals + * came from. Failures are logged but never thrown — a write failure + * shouldn't break the in-memory token that's already valid. + * + * @param previousAccessToken the access token that triggered the refresh. + * Used to detect concurrent writes by Claude + * Code itself; pass {@code null} to skip the + * check (e.g. on first-time write). + * @param refreshed the fresh credential bundle from + * {@link ClaudeCodeTokenRefresher}. + * @return {@code true} if the write completed; {@code false} if skipped + * (concurrent change detected) or failed. + */ + public boolean write(String previousAccessToken, ClaudeCodeCredentials refreshed) { + if (refreshed == null || refreshed.accessToken() == null || refreshed.accessToken().isBlank()) { + log.warn("[ClaudeCodeWriter] refusing to write blank credentials"); + return false; + } + ClaudeCodeCredentials.Source target = refreshed.source(); + if (target == ClaudeCodeCredentials.Source.REFRESH_RESPONSE) { + // Caller forgot to pin the destination — fall back to the JSON file. + log.debug("[ClaudeCodeWriter] source=REFRESH_RESPONSE not addressable; defaulting to JSON file"); + target = ClaudeCodeCredentials.Source.CREDENTIALS_FILE; + } + return switch (target) { + case MACOS_KEYCHAIN -> writeKeychain(previousAccessToken, refreshed); + case CREDENTIALS_FILE -> writeJsonFile(previousAccessToken, refreshed); + case REFRESH_RESPONSE -> false; // already coerced above; defensive + }; + } + + /* ------------------------------------------------------------------ */ + /* JSON file write */ + /* ------------------------------------------------------------------ */ + + boolean writeJsonFile(String previousAccessToken, ClaudeCodeCredentials refreshed) { + return writeJsonFile(ClaudeCodeCredentialsReader.JSON_CREDENTIALS_PATH, previousAccessToken, refreshed); + } + + /** Test seam: write to a custom path. Package-private. */ + boolean writeJsonFile(Path path, String previousAccessToken, ClaudeCodeCredentials refreshed) { + try { + Path parent = path.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + + // Read existing file (if any) so we preserve scopes + unknown fields + // and so we can detect a concurrent write by Claude Code. + ObjectNode root; + ObjectNode oauth; + if (Files.isReadable(path)) { + String existing = Files.readString(path, StandardCharsets.UTF_8); + JsonNode parsed = existing.isBlank() ? null : objectMapper.readTree(existing); + if (parsed instanceof ObjectNode obj) { + root = obj; + JsonNode oauthNode = obj.path("claudeAiOauth"); + if (oauthNode instanceof ObjectNode oauthObj) { + oauth = oauthObj; + // Concurrent-write guard — only when caller pinned the prior token. + if (previousAccessToken != null && !previousAccessToken.isBlank()) { + String diskAccessToken = oauthObj.path("accessToken").asText(""); + if (!diskAccessToken.isBlank() + && !diskAccessToken.equals(previousAccessToken) + && !diskAccessToken.equals(refreshed.accessToken())) { + log.info("[ClaudeCodeWriter] on-disk access token changed since refresh started — " + + "skipping write to avoid clobbering Claude Code's update"); + return false; + } + } + } else { + oauth = objectMapper.createObjectNode(); + root.set("claudeAiOauth", oauth); + } + } else { + root = objectMapper.createObjectNode(); + oauth = objectMapper.createObjectNode(); + root.set("claudeAiOauth", oauth); + } + } else { + root = objectMapper.createObjectNode(); + oauth = objectMapper.createObjectNode(); + root.set("claudeAiOauth", oauth); + } + + oauth.put("accessToken", refreshed.accessToken()); + if (refreshed.refreshToken() != null && !refreshed.refreshToken().isBlank()) { + oauth.put("refreshToken", refreshed.refreshToken()); + } + if (refreshed.expiresAtMs() > 0L) { + oauth.put("expiresAt", refreshed.expiresAtMs()); + } + // If scopes are missing on disk (e.g. corrupted file), default to + // the inference scope Claude Code 2.1.81+ expects. + if (!oauth.has("scopes") || !oauth.path("scopes").isArray()) { + oauth.putArray("scopes").add("user:inference"); + } + + byte[] payload = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsBytes(root); + + // Atomic write: tmp file in same directory + rename. + Path tmp = Files.createTempFile( + parent != null ? parent : path.toAbsolutePath().getParent(), + ".credentials-", + ".tmp"); + try { + Files.write(tmp, payload); + applyOwnerOnlyPerms(tmp); + try { + Files.move(tmp, path, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + // Some filesystems (e.g. cross-FS on Windows) don't support + // atomic move; fall back to plain replace. + Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(tmp); + } + applyOwnerOnlyPerms(path); + log.info("[ClaudeCodeWriter] wrote credentials to {}", path); + return true; + } catch (IOException e) { + log.warn("[ClaudeCodeWriter] failed to write credentials file {}: {}", path, e.getMessage()); + return false; + } + } + + private static void applyOwnerOnlyPerms(Path path) { + if (!FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + // Windows / non-POSIX — rely on filesystem ACLs. + return; + } + try { + Files.setPosixFilePermissions(path, PosixFilePermissions.asFileAttribute(CREDENTIALS_PERMS).value()); + } catch (IOException | UnsupportedOperationException e) { + log.debug("[ClaudeCodeWriter] could not chmod 600 on {}: {}", path, e.getMessage()); + } + } + + /* ------------------------------------------------------------------ */ + /* Keychain write */ + /* ------------------------------------------------------------------ */ + + boolean writeKeychain(String previousAccessToken, ClaudeCodeCredentials refreshed) { + if (!isMacOs()) { + log.debug("[ClaudeCodeWriter] skipping keychain write — not on macOS"); + return false; + } + Process process = null; + try { + // Build the same JSON envelope Claude Code persists. Preserve scopes + // by reading the existing keychain entry first. + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode oauth = root.putObject("claudeAiOauth"); + oauth.put("accessToken", refreshed.accessToken()); + if (refreshed.refreshToken() != null && !refreshed.refreshToken().isBlank()) { + oauth.put("refreshToken", refreshed.refreshToken()); + } + if (refreshed.expiresAtMs() > 0L) { + oauth.put("expiresAt", refreshed.expiresAtMs()); + } + oauth.putArray("scopes").add("user:inference"); + + String payload = objectMapper.writeValueAsString(root); + + // Use -U to update the existing entry in place (or create if absent). + ProcessBuilder pb = new ProcessBuilder( + "/usr/bin/security", + "add-generic-password", + "-U", + "-s", ClaudeCodeCredentialsReader.KEYCHAIN_SERVICE_NAME, + "-a", System.getProperty("user.name", "claude"), + "-w", payload); + pb.redirectErrorStream(true); + process = pb.start(); + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + log.warn("[ClaudeCodeWriter] keychain write timed out"); + return false; + } + if (process.exitValue() != 0) { + String err = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + log.warn("[ClaudeCodeWriter] keychain write exit={} ({})", process.exitValue(), err); + return false; + } + log.info("[ClaudeCodeWriter] wrote credentials to macOS Keychain"); + return true; + } catch (Exception e) { + log.warn("[ClaudeCodeWriter] keychain write failed: {}", e.getMessage()); + return false; + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + } + + /** Package-private for tests to override. */ + boolean isMacOs() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("mac"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java new file mode 100644 index 00000000..a0747d6b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthService.java @@ -0,0 +1,130 @@ +package vip.mate.llm.anthropic.oauth; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.exception.MateClawException; + +import java.util.Optional; + +/** + * RFC-062: top-level orchestrator for Claude Code OAuth. + * + *

Combines {@link ClaudeCodeCredentialsReader}, + * {@link ClaudeCodeTokenRefresher}, and {@link ClaudeCodeCredentialsWriter} + * to expose a single {@link #getValidToken()} entry point that + * {@code AgentClaudeCodeChatModelBuilder} (PR-2) will call on every request. + * + *

Behavior

+ *
    + *
  1. Read whichever local source exists (Keychain on macOS, JSON file + * elsewhere).
  2. + *
  3. If the access token is still valid (with a 1-minute safety buffer), + * return it directly — no network call.
  4. + *
  5. Otherwise refresh via Anthropic's token endpoints and persist the + * fresh credential back to the same source.
  6. + *
  7. If no refresh token is available (managed key / corrupted file), + * raise {@code err.anthropic.token_expired_no_refresh} so the UI can + * prompt re-login.
  8. + *
+ * + *

This service does NOT handle the OAuth login flow itself — that is + * RFC-062 PR-4. Until then, MateClaw piggybacks on whatever credentials the + * user already has on disk from their installed Claude Code client. + * + *

Reference: hermes-agent {@code anthropic_adapter._get_claude_code_token} + * + {@code _ensure_claude_code_token_fresh} (lines 540-605). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ClaudeCodeOAuthService { + + /** Refresh 1 minute before the access token actually expires. */ + static final long REFRESH_BUFFER_MS = 60_000L; + + private final ClaudeCodeCredentialsReader reader; + private final ClaudeCodeTokenRefresher refresher; + private final ClaudeCodeCredentialsWriter writer; + + /** + * Returns a valid (un-expired) access token, refreshing if necessary. + * + * @throws MateClawException with key {@code err.anthropic.no_claude_code} + * if no local credentials are present; + * {@code err.anthropic.token_expired_no_refresh} if the token is + * expired and cannot be refreshed. + */ + public String getValidToken() { + ClaudeCodeCredentials creds = reader.read() + .orElseThrow(() -> new MateClawException("err.anthropic.no_claude_code", + "Claude Code 凭据未找到。请安装 Claude Code 客户端并用 Pro/Max 账号登录。")); + + if (creds.isValid(REFRESH_BUFFER_MS)) { + return creds.accessToken(); + } + + if (!creds.canRefresh()) { + throw new MateClawException("err.anthropic.token_expired_no_refresh", + "Claude Code token 已过期且无法刷新。请打开 Claude Code 客户端重新登录后再试。"); + } + + log.info("[ClaudeCodeOAuth] access_token expired or near expiry — refreshing (source={})", + creds.source()); + ClaudeCodeCredentials refreshed = refresher.refresh(creds.refreshToken()); + + // Pin destination to the original source so the writer knows where to persist. + ClaudeCodeCredentials persistable = new ClaudeCodeCredentials( + refreshed.accessToken(), + refreshed.refreshToken(), + refreshed.expiresAtMs(), + creds.source()); + boolean written = writer.write(creds.accessToken(), persistable); + if (!written) { + log.warn("[ClaudeCodeOAuth] token refresh succeeded but persistence failed; " + + "using in-memory token for this request"); + } + return refreshed.accessToken(); + } + + /** + * Quick check used by the UI / status endpoints — does NOT trigger a + * refresh. + * + * @return true if a non-blank access token exists on disk and isn't yet + * past expiry. + */ + public boolean isLoggedIn() { + return reader.read().map(c -> c.isValid(0L)).orElse(false); + } + + /** + * Returns metadata for the {@code /api/v1/llm/anthropic/oauth/status} + * endpoint (PR-3) without exposing the token itself. + */ + public OAuthStatus getStatus() { + Optional opt = reader.read(); + if (opt.isEmpty()) { + return new OAuthStatus(false, false, 0L, null); + } + ClaudeCodeCredentials c = opt.get(); + boolean expired = c.expiresAtMs() > 0L && System.currentTimeMillis() >= c.expiresAtMs(); + return new OAuthStatus(true, expired, c.expiresAtMs(), c.source()); + } + + /** + * Plain DTO surfaced to the management UI. + * + * @param connected true when local credentials exist + * @param expired true when {@link ClaudeCodeCredentials#expiresAtMs()} + * is set and already past + * @param expiresAtMs raw expiry timestamp (0 means "no expiry recorded") + * @param source where the credentials came from; null when not connected + */ + public record OAuthStatus( + boolean connected, + boolean expired, + long expiresAtMs, + ClaudeCodeCredentials.Source source + ) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java new file mode 100644 index 00000000..504a3a9b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresher.java @@ -0,0 +1,146 @@ +package vip.mate.llm.anthropic.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import vip.mate.exception.MateClawException; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * RFC-062: refresh Claude Code OAuth access tokens via Anthropic's public + * token endpoint chain. + * + *

Anthropic exposes the same refresh endpoint at two hostnames: + *

    + *
  • {@code https://platform.claude.com/v1/oauth/token} — primary, used by + * Claude Code >= 2.1.114.
  • + *
  • {@code https://console.anthropic.com/v1/oauth/token} — legacy alias, + * still active. Used as a fallback for transient platform.claude.com + * outages.
  • + *
+ * + *

The refresh request is a vanilla OAuth 2.0 refresh-token grant with the + * public Claude Code {@code client_id}. We must spoof the Claude Code + * {@code User-Agent} — Anthropic's edge filters drop unrecognised UAs. + * + *

Reference: hermes-agent {@code anthropic_adapter._refresh_claude_code_token} + * (line 605+). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ClaudeCodeTokenRefresher { + + /** Public Claude Code OAuth client_id. Same value hermes-agent and OpenCode use. */ + static final String CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; + + /** Endpoints tried in order until one succeeds. */ + static final List ENDPOINTS = List.of( + "https://platform.claude.com/v1/oauth/token", + "https://console.anthropic.com/v1/oauth/token"); + + private final ObjectMapper objectMapper; + private final ClaudeCodeVersionDetector versionDetector; + private final RestClient restClient = RestClient.create(); + + /** + * Exchange a refresh_token for a fresh access_token. + * + * @return credentials carrying {@link ClaudeCodeCredentials.Source#REFRESH_RESPONSE}. + * The caller is responsible for combining this with the original + * source so the writer knows where to persist. + * @throws MateClawException when all endpoints fail. + */ + public ClaudeCodeCredentials refresh(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new MateClawException("err.anthropic.token_expired_no_refresh", + "Claude Code refresh_token 缺失,无法刷新"); + } + + Map params = new LinkedHashMap<>(); + params.put("grant_type", "refresh_token"); + params.put("refresh_token", refreshToken); + params.put("client_id", CLIENT_ID); + String body = formEncode(params); + + Exception lastException = null; + for (String endpoint : ENDPOINTS) { + try { + String response = restClient.post() + .uri(endpoint) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.USER_AGENT, + "claude-cli/" + versionDetector.get() + " (external, cli)") + .body(body) + .retrieve() + .body(String.class); + return parseTokenResponse(response, refreshToken); + } catch (Exception e) { + lastException = e; + log.debug("[ClaudeCodeRefresher] {} failed: {}", endpoint, e.getMessage()); + } + } + String detail = lastException != null ? lastException.getMessage() : "unknown"; + throw new MateClawException("err.anthropic.refresh_failed", + "Claude Code token 刷新失败: " + detail); + } + + /** Package-private for unit testing. */ + ClaudeCodeCredentials parseTokenResponse(String body, String fallbackRefreshToken) { + try { + JsonNode json = objectMapper.readTree(body); + String accessToken = json.path("access_token").asText(""); + if (accessToken.isBlank()) { + throw new MateClawException("err.anthropic.refresh_failed", + "Refresh response missing access_token"); + } + String refreshToken = json.has("refresh_token") && !json.path("refresh_token").asText("").isBlank() + ? json.path("refresh_token").asText() + : fallbackRefreshToken; + // Some endpoints return expires_in (seconds); some return expires_at (ms). + long expiresAtMs; + if (json.has("expires_at")) { + expiresAtMs = json.path("expires_at").asLong(0L); + } else { + long expiresInSec = json.path("expires_in").asLong(0L); + expiresAtMs = expiresInSec > 0 + ? System.currentTimeMillis() + (expiresInSec * 1000L) + : 0L; + } + return new ClaudeCodeCredentials( + accessToken, + refreshToken, + expiresAtMs, + ClaudeCodeCredentials.Source.REFRESH_RESPONSE); + } catch (MateClawException e) { + throw e; + } catch (Exception e) { + throw new MateClawException("err.anthropic.refresh_failed", + "Refresh response parse failed: " + e.getMessage()); + } + } + + private static String formEncode(Map params) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Map.Entry e : params.entrySet()) { + if (!first) sb.append('&'); + first = false; + sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)); + sb.append('='); + sb.append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)); + } + return sb.toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetector.java b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetector.java new file mode 100644 index 00000000..389801d0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetector.java @@ -0,0 +1,113 @@ +package vip.mate.llm.anthropic.oauth; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * RFC-062: detect the locally-installed Claude Code version. + * + *

Anthropic's OAuth infrastructure validates the User-Agent version on + * Bearer-auth requests. A UA that drifts too far behind the actual Claude Code + * release returns 400 / 5xx. Detecting dynamically (via {@code claude --version} + * or {@code claude-code --version}) keeps users who upgrade Claude Code + * automatically aligned; the static fallback covers headless/server boxes + * where Claude Code isn't installed locally (the OAuth flow may still work via + * a manually-imported credentials file). + * + *

Reference: hermes-agent {@code anthropic_adapter._detect_claude_code_version} + * (line 239) + {@code _CLAUDE_CODE_VERSION_FALLBACK} (line 235). + * + *

Result is cached for the JVM lifetime (Anthropic's UA validation tolerates + * a stable version per process). Restart MateClaw to pick up a Claude Code + * upgrade. + */ +@Slf4j +@Component +public class ClaudeCodeVersionDetector { + + /** + * Static fallback version. Update this when bumping the floor at which + * Anthropic accepts spoofed Claude Code traffic (track Anthropic's + * announcements + hermes-agent's same constant for cadence). + */ + static final String FALLBACK_VERSION = "2.1.74"; + + /** Match leading semver-like number from a {@code --version} stdout. */ + private static final Pattern VERSION_PATTERN = Pattern.compile("^(\\d+\\.\\d+(?:\\.\\d+)?)"); + + private final AtomicReference cache = new AtomicReference<>(); + + /** + * Returns the detected (or fallback) Claude Code version. Cached after + * first call. + */ + public String get() { + String cached = cache.get(); + if (cached != null) return cached; + + String detected = detect(); + cache.compareAndSet(null, detected); + return cache.get(); + } + + /** Force a re-detection. Useful for testing. */ + public void invalidate() { + cache.set(null); + } + + private String detect() { + for (String cmd : new String[]{"claude", "claude-code"}) { + String version = runVersionCommand(cmd); + if (version != null) { + log.info("[ClaudeCodeVersion] detected {} (from {} --version)", version, cmd); + return version; + } + } + log.debug("[ClaudeCodeVersion] no Claude Code binary on PATH; using fallback {}", FALLBACK_VERSION); + return FALLBACK_VERSION; + } + + /** Returns the version string from {@code --version}, or {@code null} on any failure. */ + private String runVersionCommand(String cmd) { + Process process = null; + try { + ProcessBuilder pb = new ProcessBuilder(cmd, "--version"); + pb.redirectErrorStream(true); + process = pb.start(); + // Bound the wait — a hung claude binary should not block startup. + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + log.debug("[ClaudeCodeVersion] {} --version timed out after 5s", cmd); + return null; + } + if (process.exitValue() != 0) return null; + try (BufferedReader r = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line = r.readLine(); + return parseVersion(line); + } + } catch (Exception e) { + log.debug("[ClaudeCodeVersion] {} --version failed: {}", cmd, e.getMessage()); + return null; + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + } + + /** Extract the leading {@code N.N[.N]} from a {@code --version} line. Package-private for tests. */ + static String parseVersion(String line) { + if (line == null) return null; + Matcher m = VERSION_PATTERN.matcher(line.trim()); + return m.find() ? m.group(1) : null; + } +}