mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(anthropic): Claude Code OAuth credential plumbing
This commit is contained in:
parent
9187aed273
commit
8539fb9407
@ -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 <= 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;
|
||||
}
|
||||
}
|
||||
@ -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 >= 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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
* >= 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");
|
||||
}
|
||||
}
|
||||
@ -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 >= 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");
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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 >= 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user