feat(anthropic): Claude Code OAuth credential plumbing

This commit is contained in:
matevip 2026-04-26 08:34:08 +08:00
parent 9187aed273
commit 8539fb9407
7 changed files with 977 additions and 0 deletions

View File

@ -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.
*
* <p>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).
*
* <h2>Header reference</h2>
*
* <table>
* <caption>Header set sent on OAuth requests</caption>
* <tr><th>Header</th><th>Value</th><th>Why</th></tr>
* <tr><td>{@code Authorization}</td><td>{@code Bearer <accessToken>}</td><td>OAuth path uses Bearer; non-OAuth uses {@code x-api-key}</td></tr>
* <tr><td>{@code User-Agent}</td><td>{@code claude-cli/<version> (external, cli)}</td><td>Anthropic routes OAuth by UA; spoof identity</td></tr>
* <tr><td>{@code x-app}</td><td>{@code cli}</td><td>Claude Code identity flag</td></tr>
* <tr><td>{@code anthropic-beta}</td><td>(comma-joined list see {@link #allBetas()})</td><td>OAuth-only + common feature betas</td></tr>
* </table>
*/
@Component
@RequiredArgsConstructor
public class ClaudeCodeApiHeaders {
/** Beta headers required for any OAuth request. Anthropic's infra
* intermittently 500s OAuth traffic without them. */
static final List<String> 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 &lt;= 4.5 compat the headers are accepted as no-ops on newer
* models so it's safe to send always. */
static final List<String> 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}.
* <p>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/<version> (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 <T> List<T> concat(List<T> a, List<T> b) {
java.util.ArrayList<T> out = new java.util.ArrayList<>(a.size() + b.size());
out.addAll(a);
out.addAll(b);
return out;
}
}

View File

@ -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}.
*
* <p>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 &gt;= 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();
}
}

View File

@ -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.
*
* <p>Two sources, in priority order:
* <ol>
* <li><b>macOS Keychain</b> entry {@code Claude Code-credentials} (Claude Code
* &gt;= 2.1.114 stores here). Read via the {@code security} CLI tool.</li>
* <li><b>JSON file</b> at {@code ~/.claude/.credentials.json} (legacy + Linux/Win).</li>
* </ol>
*
* <p>Both sources contain the same JSON shape:
* <pre>
* {
* "claudeAiOauth": {
* "accessToken": "...",
* "refreshToken": "...",
* "expiresAt": 1234567890123,
* "scopes": ["user:inference", ...]
* }
* }
* </pre>
*
* <p>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<ClaudeCodeCredentials> 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<ClaudeCodeCredentials> 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<ClaudeCodeCredentials> 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<ClaudeCodeCredentials> readFromJsonFile() {
return readFromJsonFile(JSON_CREDENTIALS_PATH);
}
/** Test seam: read from a custom path. Package-private. */
Optional<ClaudeCodeCredentials> 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<ClaudeCodeCredentials> 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");
}
}

View File

@ -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.
*
* <p>Two write targets, mirroring {@link ClaudeCodeCredentialsReader}:
* <ol>
* <li>macOS Keychain entry {@code Claude Code-credentials} via the
* {@code security add-generic-password -U} CLI.</li>
* <li>{@code ~/.claude/.credentials.json} JSON file written atomically
* (temp file + rename) with {@code 0600} permissions on POSIX.</li>
* </ol>
*
* <h2>Concurrent-write defence</h2>
* Claude Code itself may rewrite the credentials file while MateClaw is doing
* a refresh. The flow is:
* <ol>
* <li>Re-read the file just before write.</li>
* <li>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.</li>
* <li>Otherwise merge our refreshed fields into the existing JSON so we
* preserve {@code scopes} (Claude Code &gt;= 2.1.81 requires
* {@code user:inference}) and any future fields we don't know about.</li>
* </ol>
*
* <p>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<PosixFilePermission> 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");
}
}

View File

@ -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.
*
* <p>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.
*
* <h2>Behavior</h2>
* <ol>
* <li>Read whichever local source exists (Keychain on macOS, JSON file
* elsewhere).</li>
* <li>If the access token is still valid (with a 1-minute safety buffer),
* return it directly no network call.</li>
* <li>Otherwise refresh via Anthropic's token endpoints and persist the
* fresh credential back to the same source.</li>
* <li>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.</li>
* </ol>
*
* <p>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.
*
* <p>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<ClaudeCodeCredentials> 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
) {}
}

View File

@ -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.
*
* <p>Anthropic exposes the same refresh endpoint at two hostnames:
* <ul>
* <li>{@code https://platform.claude.com/v1/oauth/token} primary, used by
* Claude Code &gt;= 2.1.114.</li>
* <li>{@code https://console.anthropic.com/v1/oauth/token} legacy alias,
* still active. Used as a fallback for transient platform.claude.com
* outages.</li>
* </ul>
*
* <p>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.
*
* <p>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<String> 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<String, String> 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<String, String> params) {
StringBuilder sb = new StringBuilder();
boolean first = true;
for (Map.Entry<String, String> 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();
}
}

View File

@ -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.
*
* <p>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).
*
* <p>Reference: hermes-agent {@code anthropic_adapter._detect_claude_code_version}
* (line 239) + {@code _CLAUDE_CODE_VERSION_FALLBACK} (line 235).
*
* <p>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<String> 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 <cmd> --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;
}
}