From be2235a493acd36de55553f3d5502305189f5f04 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 5 May 2026 10:39:47 +0800 Subject: [PATCH] feat(llm): OAuth device authorization grant for ChatGPT remote deploy --- .../mate/llm/controller/OAuthController.java | 26 ++ .../llm/oauth/OpenAIDeviceCodeService.java | 286 ++++++++++++++++++ .../mate/llm/oauth/OpenAIOAuthService.java | 115 ++++--- .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_en.properties | 1 + mateclaw-ui/src/api/index.ts | 9 + mateclaw-ui/src/i18n/locales/en-US.ts | 10 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 10 + .../Models/composables/useProviderOAuth.ts | 108 ++++++- .../src/views/Settings/Models/index.vue | 12 + .../Models/modals/DeviceCodeDialog.vue | 206 +++++++++++++ 11 files changed, 740 insertions(+), 44 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIDeviceCodeService.java create mode 100644 mateclaw-ui/src/views/Settings/Models/modals/DeviceCodeDialog.vue diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java index ac9dcbfd..cde8c96c 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java @@ -6,6 +6,9 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; +import vip.mate.llm.oauth.OpenAIDeviceCodeService; +import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodePollResult; +import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodeStartResult; import vip.mate.llm.oauth.OpenAIOAuthService; import vip.mate.llm.oauth.OpenAIOAuthService.OAuthAuthorizeResult; import vip.mate.llm.oauth.OpenAIOAuthService.OAuthStatusResult; @@ -17,6 +20,7 @@ import vip.mate.llm.oauth.OpenAIOAuthService.OAuthStatusResult; public class OAuthController { private final OpenAIOAuthService oauthService; + private final OpenAIDeviceCodeService deviceCodeService; @Operation(summary = "获取 OAuth 授权 URL(自动选 LOCAL / MANUAL_PASTE 模式)") @GetMapping("/authorize") @@ -46,6 +50,28 @@ public class OAuthController { /** Request body for {@link #callbackPaste(PasteRequest)}. */ public record PasteRequest(String callbackUrl) {} + @Operation(summary = "Device flow: start — request user_code") + @PostMapping("/device/start") + public R deviceStart() { + return R.ok(deviceCodeService.start()); + } + + @Operation(summary = "Device flow: poll for completion") + @PostMapping("/device/poll") + public R devicePoll(@RequestBody DeviceRequest request) { + return R.ok(deviceCodeService.poll(request.deviceAuthId())); + } + + @Operation(summary = "Device flow: cancel a pending session") + @PostMapping("/device/cancel") + public R deviceCancel(@RequestBody DeviceRequest request) { + deviceCodeService.cancel(request.deviceAuthId()); + return R.ok(); + } + + /** Request body for {@link #devicePoll} / {@link #deviceCancel}. */ + public record DeviceRequest(String deviceAuthId) {} + @Operation(summary = "手动刷新 Token") @PostMapping("/refresh") public R refresh() { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIDeviceCodeService.java b/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIDeviceCodeService.java new file mode 100644 index 00000000..5fef6032 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIDeviceCodeService.java @@ -0,0 +1,286 @@ +package vip.mate.llm.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; +import vip.mate.exception.MateClawException; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; + +/** + * OAuth 2.0 Device Authorization Grant (RFC 8628) for the OpenAI Codex CLI client_id. + * + *

Used when MateClaw runs on a remote host and the browser cannot reach + * {@code localhost:1455}. Authorization happens entirely on + * {@code auth.openai.com}; we just poll a server-side endpoint until OpenAI hands + * us an authorization code, then delegate the token exchange to + * {@link OpenAIOAuthService#exchangeTokenWithVerifier}. + * + *

Flow: + *

    + *
  1. {@link #start()} POSTs {@code client_id} to {@code /api/accounts/deviceauth/usercode} + * and returns {@code user_code} + verification URL for the user to open in any + * browser.
  2. + *
  3. The frontend polls {@link #poll(String)} every {@code interval} seconds. + * Each call POSTs {@code device_auth_id + user_code} to + * {@code /api/accounts/deviceauth/token}; OpenAI returns + * {@code authorization_pending} until the user authorizes, then returns + * {@code authorization_code + code_verifier}.
  4. + *
  5. On COMPLETED we exchange the code for tokens at {@code /oauth/token} using + * the device redirect URI {@code https://auth.openai.com/deviceauth/callback} + * and persist via the shared save path.
  6. + *
+ * + *

Sessions are kept in-memory; multi-instance deployments need sticky sessions + * until a Redis-backed store is introduced. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OpenAIDeviceCodeService { + + static final String CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; + static final String DEVICE_USERCODE_URL = + "https://auth.openai.com/api/accounts/deviceauth/usercode"; + static final String DEVICE_TOKEN_URL = + "https://auth.openai.com/api/accounts/deviceauth/token"; + static final String DEVICE_REDIRECT_URI = + "https://auth.openai.com/deviceauth/callback"; + static final String DEFAULT_VERIFICATION_URL = + "https://auth.openai.com/codex/device"; + static final String DEFAULT_USER_AGENT = "codex_cli_rs/0.7.0"; + + private final OpenAIOAuthService oauthService; + private final ObjectMapper objectMapper; + + private RestClient restClient = RestClient.create(); + + @Value("${mateclaw.oauth.openai.device.poll-min-interval-ms:3000}") + private long pollMinIntervalMs; + + @Value("${mateclaw.oauth.openai.device.session-ttl-seconds:900}") + private long defaultSessionTtlSeconds; + + @Value("${mateclaw.oauth.openai.device.user-agent:" + DEFAULT_USER_AGENT + "}") + private String userAgent; + + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + /** Test seam — replace the RestClient (e.g. with a WireMock-pointed instance). */ + void setRestClient(RestClient restClient) { + this.restClient = restClient; + } + + /** Step 1: request a user_code and device_auth_id. */ + public DeviceCodeStartResult start() { + String body = "client_id=" + enc(CLIENT_ID); + JsonNode resp; + try { + String raw = restClient.post() + .uri(DEVICE_USERCODE_URL) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .header(HttpHeaders.USER_AGENT, userAgent) + .body(body) + .retrieve() + .body(String.class); + resp = objectMapper.readTree(raw); + } catch (Exception e) { + log.error("Device code start failed: {}", e.getMessage()); + throw new MateClawException("err.llm.device_code_start_failed", + "Device code 申请失败: " + e.getMessage()); + } + + String deviceAuthId = resp.path("device_auth_id").asText(null); + String userCode = resp.path("user_code").asText(null); + int interval = resp.path("interval").asInt(5); + int expiresIn = resp.path("expires_in").asInt((int) defaultSessionTtlSeconds); + String verificationUrl = resp.path("verification_uri").asText(DEFAULT_VERIFICATION_URL); + String verificationUrlComplete = resp.path("verification_uri_complete").asText(null); + + if (deviceAuthId == null || userCode == null) { + throw new MateClawException("err.llm.device_code_start_failed", + "Device code 响应缺少必要字段"); + } + + long now = System.currentTimeMillis(); + sessions.put(deviceAuthId, new DeviceCodeSession( + deviceAuthId, userCode, + now + expiresIn * 1000L, + now)); + + log.info("Device code session started: deviceAuthId prefix={}, expires_in={}s", + deviceAuthId.substring(0, Math.min(8, deviceAuthId.length())), expiresIn); + + return new DeviceCodeStartResult(deviceAuthId, userCode, verificationUrl, + verificationUrlComplete, interval, expiresIn); + } + + /** Step 2: poll OpenAI for completion. Returns PENDING / COMPLETED / EXPIRED. */ + public DeviceCodePollResult poll(String deviceAuthId) { + if (deviceAuthId == null || deviceAuthId.isBlank()) { + return DeviceCodePollResult.expired(); + } + DeviceCodeSession session = sessions.get(deviceAuthId); + if (session == null) { + return DeviceCodePollResult.expired(); + } + + long now = System.currentTimeMillis(); + if (now > session.expiresAt()) { + sessions.remove(deviceAuthId); + return DeviceCodePollResult.expired(); + } + + // Rate-limit: refuse to hammer OpenAI faster than the configured floor. + if (now - session.lastPollAt() < pollMinIntervalMs) { + return DeviceCodePollResult.pending(); + } + sessions.put(deviceAuthId, session.withLastPollAt(now)); + + String body = "device_auth_id=" + enc(deviceAuthId) + + "&user_code=" + enc(session.userCode()); + + String responseBody; + try { + responseBody = restClient.post() + .uri(DEVICE_TOKEN_URL) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .header(HttpHeaders.USER_AGENT, userAgent) + .body(body) + .retrieve() + .body(String.class); + } catch (RestClientResponseException e) { + return classifyError(deviceAuthId, e.getStatusCode().value(), e.getResponseBodyAsString()); + } catch (Exception e) { + log.warn("Device code poll transport failure (will retry): {}", e.getMessage()); + return DeviceCodePollResult.pending(); + } + + JsonNode resp; + try { + resp = objectMapper.readTree(responseBody); + } catch (Exception e) { + log.warn("Device code poll: malformed JSON, treating as pending"); + return DeviceCodePollResult.pending(); + } + + String authorizationCode = resp.path("authorization_code").asText(null); + String codeVerifier = resp.path("code_verifier").asText(null); + if (authorizationCode == null) { + // Some responses use 200 with an inline {error: authorization_pending}. + String error = resp.path("error").asText(null); + if ("expired_token".equals(error) || "access_denied".equals(error)) { + sessions.remove(deviceAuthId); + return DeviceCodePollResult.expired(); + } + return DeviceCodePollResult.pending(); + } + if (codeVerifier == null) { + log.warn("Device code response missing code_verifier; cannot exchange token"); + sessions.remove(deviceAuthId); + return DeviceCodePollResult.expired(); + } + + try { + oauthService.exchangeTokenWithVerifier(authorizationCode, codeVerifier, DEVICE_REDIRECT_URI); + sessions.remove(deviceAuthId); + log.info("Device code session completed: deviceAuthId prefix={}", + deviceAuthId.substring(0, Math.min(8, deviceAuthId.length()))); + return DeviceCodePollResult.completed(); + } catch (Exception e) { + sessions.remove(deviceAuthId); + throw e; + } + } + + /** Caller-driven cancellation: drop the session so we stop polling. */ + public void cancel(String deviceAuthId) { + if (deviceAuthId != null) { + sessions.remove(deviceAuthId); + } + } + + /** Sweep expired sessions every 5 minutes. */ + @Scheduled(fixedDelay = 300_000L) + void cleanupExpiredSessions() { + long now = System.currentTimeMillis(); + sessions.entrySet().removeIf(entry -> now > entry.getValue().expiresAt()); + } + + private DeviceCodePollResult classifyError(String deviceAuthId, int status, String body) { + String error = extractErrorCode(body); + if ("authorization_pending".equals(error) || "slow_down".equals(error)) { + return DeviceCodePollResult.pending(); + } + if ("expired_token".equals(error) || "access_denied".equals(error)) { + sessions.remove(deviceAuthId); + return DeviceCodePollResult.expired(); + } + log.warn("Device code poll unexpected error: status={}, error={}, body={}", + status, error, body); + // Conservative: keep the session alive so the user can retry; expiry will catch it. + return DeviceCodePollResult.pending(); + } + + private String extractErrorCode(String body) { + if (body == null || body.isBlank()) return null; + try { + JsonNode node = objectMapper.readTree(body); + String error = node.path("error").asText(null); + if (error != null) return error; + } catch (Exception ignored) { + } + // Fallback for non-JSON bodies — match well-known error tokens defensively. + if (body.contains("authorization_pending")) return "authorization_pending"; + if (body.contains("slow_down")) return "slow_down"; + if (body.contains("expired_token")) return "expired_token"; + if (body.contains("access_denied")) return "access_denied"; + return null; + } + + private static String enc(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + int activeSessionCount() { + return sessions.size(); + } + + private record DeviceCodeSession( + String deviceAuthId, + String userCode, + long expiresAt, + long lastPollAt) { + + DeviceCodeSession withLastPollAt(long now) { + return new DeviceCodeSession(deviceAuthId, userCode, expiresAt, now); + } + } + + public record DeviceCodeStartResult( + String deviceAuthId, + String userCode, + String verificationUrl, + String verificationUrlComplete, + int intervalSeconds, + int expiresInSeconds) { + } + + public record DeviceCodePollResult(Status status) { + public enum Status { PENDING, COMPLETED, EXPIRED } + + public static DeviceCodePollResult pending() { return new DeviceCodePollResult(Status.PENDING); } + public static DeviceCodePollResult completed() { return new DeviceCodePollResult(Status.COMPLETED); } + public static DeviceCodePollResult expired() { return new DeviceCodePollResult(Status.EXPIRED); } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java b/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java index 2225dcc0..b21c8119 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/oauth/OpenAIOAuthService.java @@ -28,26 +28,36 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; /** - * OpenAI OAuth 服务 — 基于 PKCE 的 OAuth 2.0 流程。 - *

- * 双模式(issue: server 部署回调失败 — Linux 部署后浏览器无法连 - * localhost:1455 因为它不是 server 的 localhost): + * OpenAI OAuth service — supports three flow modes for the same Codex CLI client_id. * *

    - *
  • LOCAL(桌面 / 本机部署):在 localhost:1455 启动临时 HTTP 服务器,浏览器 - * 自动 callback。OpenAI 的 Codex CLI client_id 注册的 redirect_uri 就是这个, - * 所以无法换成公网 URL。
  • - *
  • MANUAL_PASTE(远程 server 部署):不启动 localhost server。浏览器登录后 - * 会跳到 localhost:1455/auth/callback 但因为没人监听会报 ERR_CONNECTION_REFUSED; - * 此时 URL 栏里已经有 ?code=...&state=... 参数。让用户复制整个 URL 粘贴回 MateClaw - * UI,后端通过 {@link #completeFromPastedUrl} 解析 code 完成 token 交换。
  • + *
  • LOCAL: Authorization Code + PKCE with a temporary HTTP server bound on + * 127.0.0.1:1455 to receive the callback. Used when MateClaw is reached via + * localhost — the browser can hit our loopback callback. The Codex CLI client_id + * only accepts {@code http://localhost:1455/auth/callback} as redirect_uri, so + * this path is impossible for remote deployments.
  • + *
  • DEVICE_CODE: OAuth 2.0 Device Authorization Grant (RFC 8628) — the + * authorization happens entirely inside {@code auth.openai.com}; no callback + * server is needed. This is the default for remote deployments. Token exchange + * still goes through {@link #exchangeTokenWithVerifier} so downstream + * persistence and refresh logic stay identical to the PKCE path. Driven by + * {@code OpenAIDeviceCodeService}.
  • + *
  • MANUAL_PASTE: graceful fallback when LOCAL bind fails or DEVICE_CODE + * endpoints are unavailable. The user copies the (unreachable) callback URL + * from the browser address bar and pastes it back; we parse {@code code} and + * {@code state} from the query string and complete the exchange.
  • *
* - *

模式选择: + *

Mode selection: *

    - *
  1. config {@code mateclaw.oauth.openai.deployment-mode = local | server | auto}(默认 auto)
  2. - *
  3. auto 模式按 Host header 判定(localhost / 127.0.0.1 / ::1 → LOCAL,其它 → SERVER)
  4. - *
  5. LOCAL 模式 bind 失败时自动降级到 MANUAL_PASTE
  6. + *
  7. Config override {@code mateclaw.oauth.openai.deployment-mode}: one of + * {@code local} / {@code device_code} / {@code manual_paste} / {@code auto} + * (default). Alias: {@code server} maps to {@code device_code} for + * compatibility with older configs that pre-date device code support.
  8. + *
  9. {@code auto} dispatches by Host header: localhost / 127.0.0.1 / ::1 → LOCAL, + * any other host → DEVICE_CODE.
  10. + *
  11. LOCAL bind failure degrades to MANUAL_PASTE so the user always has a path + * forward.
  12. *
*/ @Slf4j @@ -75,12 +85,14 @@ public class OpenAIOAuthService { private volatile HttpServer activeCallbackServer; /** - * OAuth flow 模式 — 决定是自动 callback 还是用户手动粘贴 URL 完成。 + * OAuth flow mode — selects how the authorization code reaches the backend. */ public enum OAuthFlowMode { - /** 启动 localhost:1455 临时 server,浏览器自动 callback */ + /** Authorization Code + PKCE with a temporary localhost:1455 callback server. */ LOCAL, - /** 不启动 server;用户手动复制 callback URL 回粘到 UI */ + /** Device Authorization Grant (RFC 8628) — no callback server, polling-based. */ + DEVICE_CODE, + /** User pastes the callback URL back into the UI. Last-resort fallback. */ MANUAL_PASTE } @@ -92,19 +104,22 @@ public class OpenAIOAuthService { * @param requestHost 来自 controller 的 Host header(可空 → 默认 LOCAL 行为) */ public OAuthAuthorizeResult buildAuthorizeUrl(String requestHost) { + OAuthFlowMode mode = resolveFlowMode(requestHost); + + // DEVICE_CODE flow does not produce an authorize URL or PKCE state here — the + // frontend sees mode=DEVICE_CODE and calls OpenAIDeviceCodeService directly. + if (mode == OAuthFlowMode.DEVICE_CODE) { + return new OAuthAuthorizeResult("", "", mode); + } + String codeVerifier = generateCodeVerifier(); String codeChallenge = generateCodeChallenge(codeVerifier); String state = generateState(); - pendingStates.put(state, codeVerifier); - // 决定 flow mode + 尝试启动 localhost server - OAuthFlowMode mode = resolveFlowMode(requestHost); - boolean serverStarted = false; if (mode == OAuthFlowMode.LOCAL) { - serverStarted = startCallbackServer(state); + boolean serverStarted = startCallbackServer(state); if (!serverStarted) { - // bind 失败 → 优雅降级到 MANUAL_PASTE 而非整体失败 log.warn("Callback server bind failed on port {} — degrading to MANUAL_PASTE flow", CALLBACK_PORT); mode = OAuthFlowMode.MANUAL_PASTE; @@ -139,26 +154,27 @@ public class OpenAIOAuthService { * MateClaw on the same machine they'll do the OAuth login on — LOCAL works. * Any other host (a domain, a public IP, a private LAN IP) means the user's * browser cannot resolve {@code localhost:1455} to MateClaw's server, so we - * must use MANUAL_PASTE. + * use DEVICE_CODE (browser-agnostic, no callback server needed). + * + *

Config override values: {@code local} / {@code device_code} / + * {@code manual_paste} / {@code auto}. {@code server} is kept as an alias for + * {@code device_code} so older configs do not break. */ - private OAuthFlowMode resolveFlowMode(String requestHost) { - // Explicit config override + OAuthFlowMode resolveFlowMode(String requestHost) { String configMode = System.getProperty("mateclaw.oauth.openai.deployment-mode", System.getenv("MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE")); if (configMode != null) { String norm = configMode.trim().toLowerCase(); if ("local".equals(norm)) return OAuthFlowMode.LOCAL; - if ("server".equals(norm) || "manual_paste".equals(norm)) return OAuthFlowMode.MANUAL_PASTE; + if ("device_code".equals(norm) || "server".equals(norm)) return OAuthFlowMode.DEVICE_CODE; + if ("manual_paste".equals(norm)) return OAuthFlowMode.MANUAL_PASTE; // "auto" / unknown → fall through to heuristic } - // Heuristic from Host header if (requestHost == null || requestHost.isBlank()) { - // No host info available — assume LOCAL (matches legacy behaviour) return OAuthFlowMode.LOCAL; } String host = requestHost.toLowerCase(); - // Strip port if present int colon = host.lastIndexOf(':'); if (colon > 0 && host.charAt(0) != '[') { // not IPv6 host = host.substring(0, colon); @@ -169,7 +185,7 @@ public class OpenAIOAuthService { || "[::1]".equals(host)) { return OAuthFlowMode.LOCAL; } - return OAuthFlowMode.MANUAL_PASTE; + return OAuthFlowMode.DEVICE_CODE; } /** @@ -321,22 +337,37 @@ public class OpenAIOAuthService { } /** - * 用 authorization code 换取 token(内部调用,由回调服务器触发) + * Exchange an authorization code (from either PKCE callback or device flow) for + * tokens at {@code /oauth/token}. Shared by both flows so persistence and JWT + * parsing stay in one place. + * + * @param code authorization code + * @param codeVerifier PKCE verifier — for LOCAL/MANUAL_PASTE this is the value + * stashed in {@link #pendingStates} during authorize-URL + * generation; for DEVICE_CODE this comes back as part of + * the device-auth poll response + * @param redirectUri redirect_uri presented during authorize — must match the + * value the original authorize call used. {@link #REDIRECT_URI} + * for PKCE; {@code https://auth.openai.com/deviceauth/callback} + * for device flow. */ + void exchangeTokenWithVerifier(String code, String codeVerifier, String redirectUri) { + String body = "grant_type=authorization_code" + + "&client_id=" + enc(CLIENT_ID) + + "&code=" + enc(code) + + "&code_verifier=" + enc(codeVerifier) + + "&redirect_uri=" + enc(redirectUri); + JsonNode tokenResponse = postTokenRequest(body); + saveTokens(tokenResponse); + } + + /** PKCE callback path — looks up the verifier by state and delegates. */ private void exchangeToken(String code, String state) { String codeVerifier = pendingStates.remove(state); if (codeVerifier == null) { throw new MateClawException("err.llm.oauth_state_invalid", "无效的 OAuth state,可能已过期或重复使用"); } - - String body = "grant_type=authorization_code" - + "&client_id=" + enc(CLIENT_ID) - + "&code=" + enc(code) - + "&code_verifier=" + enc(codeVerifier) - + "&redirect_uri=" + enc(REDIRECT_URI); - - JsonNode tokenResponse = postTokenRequest(body); - saveTokens(tokenResponse); + exchangeTokenWithVerifier(code, codeVerifier, REDIRECT_URI); } /** diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index 51fb2682..44084e22 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -230,6 +230,7 @@ err.llm.oauth_exchange_failed=OAuth token \u4ea4\u6362\u5931\u8d25 err.llm.oauth_no_token=OAuth \u54cd\u5e94\u4e2d\u7f3a\u5c11 access_token err.llm.chatgpt_not_configured=ChatGPT provider \u672a\u914d\u7f6e err.llm.pkce_failed=PKCE \u751f\u6210\u5931\u8d25 +err.llm.device_code_start_failed=Device code \u7533\u8bf7\u5931\u8d25 err.llm.chatgpt_stream_failed=ChatGPT \u6d41\u5f0f\u8c03\u7528\u5931\u8d25 err.llm.chatgpt_error=ChatGPT \u8fd4\u56de\u9519\u8bef err.llm.chatgpt_account_missing=chatgpt-account-id \u7f3a\u5931 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 3802bce9..8b6ff068 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -242,6 +242,7 @@ err.llm.oauth_exchange_failed=OAuth token exchange failed err.llm.oauth_no_token=access_token missing in OAuth response err.llm.chatgpt_not_configured=ChatGPT provider not configured, check database initialization err.llm.pkce_failed=PKCE code_challenge generation failed +err.llm.device_code_start_failed=Device code request failed err.llm.chatgpt_stream_failed=ChatGPT streaming call failed err.llm.chatgpt_error=ChatGPT returned an error err.llm.chatgpt_account_missing=chatgpt-account-id missing, disconnect and re-login via OAuth diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 5951721e..84b85847 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -472,6 +472,15 @@ export const oauthApi = { status: () => http.get('/oauth/openai/status'), refresh: () => http.post('/oauth/openai/refresh'), revoke: () => http.delete('/oauth/openai/revoke'), + callbackPaste: (callbackUrl: string) => + http.post('/oauth/openai/callback-paste', { callbackUrl }), + // Device Authorization Grant — used when MateClaw runs on a remote host so the + // browser cannot reach localhost:1455 for the PKCE callback. + deviceStart: () => http.post('/oauth/openai/device/start'), + devicePoll: (deviceAuthId: string) => + http.post('/oauth/openai/device/poll', { deviceAuthId }), + deviceCancel: (deviceAuthId: string) => + http.post('/oauth/openai/device/cancel', { deviceAuthId }), } // RFC-062: Claude Code OAuth piggybacks on the user's local Claude Code diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 4a254fcb..8a97308c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -599,6 +599,16 @@ export default { oauthHint: 'Sign in with your OpenAI account to use ChatGPT Plus/Pro member quota (not API credits).', oauthLoginSuccess: 'OpenAI OAuth login successful', oauthRevokeSuccess: 'OpenAI OAuth disconnected', + // Device Authorization Grant (used for remote deployments where the browser + // cannot reach localhost:1455 for the PKCE callback). + oauthDeviceTitle: 'Sign in to ChatGPT (Device Code)', + oauthDeviceStep1: '1. Open this link in any browser or on your phone:', + oauthDeviceStep2: '2. Enter this verification code:', + oauthDeviceStep3: 'This page will connect automatically once you finish ({seconds}s remaining)', + oauthDeviceExpired: 'The device code expired. Please try again.', + copy: 'Copy', + copied: 'Copied', + copyFailed: 'Clipboard refused — please select and copy manually', // RFC-062: Claude Code OAuth (subscription piggyback) claudeCodeOauthDetect: 'Detect Claude Code Login', claudeCodeOauthHint: 'Reuses your local Claude Code Pro/Max subscription. Sign in via the Claude Code app first, then click "Detect" to pick up the credentials.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index c8b8f75b..d9b2be0e 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -485,6 +485,16 @@ export default { oauthHint: '通过 OAuth 登录 OpenAI 账号,使用 ChatGPT Plus/Pro 会员额度(非 API 额度)。', oauthLoginSuccess: 'OpenAI OAuth 登录成功', oauthRevokeSuccess: '已断开 OpenAI OAuth 连接', + // Device Authorization Grant (used for remote deployments where the browser + // cannot reach localhost:1455 for the PKCE callback). + oauthDeviceTitle: '使用设备授权登录 ChatGPT', + oauthDeviceStep1: '1. 在任意浏览器或手机打开下面的链接:', + oauthDeviceStep2: '2. 输入下方验证码完成授权:', + oauthDeviceStep3: '完成后此页面将自动连接({seconds} 秒内有效)', + oauthDeviceExpired: '设备授权码已过期,请重试', + copy: '复制', + copied: '已复制', + copyFailed: '浏览器拒绝复制,请手动选取', // RFC-062:Claude Code OAuth(订阅复用) claudeCodeOauthDetect: '检测 Claude Code 登录态', claudeCodeOauthHint: '复用本地 Claude Code Pro/Max 订阅。请先在 Claude Code 客户端中登录,再点击"检测"读取凭据。', diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderOAuth.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderOAuth.ts index adb6ef32..99244cc4 100644 --- a/mateclaw-ui/src/views/Settings/Models/composables/useProviderOAuth.ts +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderOAuth.ts @@ -1,4 +1,4 @@ -import { type Ref } from 'vue' +import { ref, type Ref } from 'vue' import { useI18n } from 'vue-i18n' import { ElMessage } from 'element-plus' import { claudeCodeOAuthApi, oauthApi } from '@/api' @@ -15,6 +15,14 @@ interface ListDeps { providers: Ref } +export interface DeviceCodeDialogState { + visible: boolean + userCode: string + verificationUrl: string + verificationUrlComplete: string | null + expiresAt: number +} + /** * RFC-074 PR-1: OAuth flows. Two distinct shapes: * - openai-chatgpt: pop a real authorize window, poll status, refresh on success. @@ -24,6 +32,17 @@ interface ListDeps { export function useProviderOAuth(deps: FormDeps & ListDeps) { const { t } = useI18n() + const deviceCodeDialog = ref({ + visible: false, + userCode: '', + verificationUrl: '', + verificationUrlComplete: null, + expiresAt: 0, + }) + + let devicePollTimer: ReturnType | null = null + let activeDeviceAuthId: string | null = null + /** After a load that may have changed OAuth state, keep the editing modal in sync. */ async function reloadProvidersAndSync() { await deps.loadProviders() @@ -33,6 +52,80 @@ export function useProviderOAuth(deps: FormDeps & ListDeps) { } } + function stopDevicePolling() { + if (devicePollTimer != null) { + clearTimeout(devicePollTimer) + devicePollTimer = null + } + } + + function closeDeviceCodeDialog() { + deviceCodeDialog.value.visible = false + stopDevicePolling() + if (activeDeviceAuthId) { + const id = activeDeviceAuthId + activeDeviceAuthId = null + oauthApi.deviceCancel(id).catch(() => { /* best-effort */ }) + } + } + + async function runDeviceCodeFlow() { + let start: any + try { + start = await oauthApi.deviceStart() + } catch (e: any) { + ElMessage.error(e.msg || 'Device code request failed') + return + } + const data = start.data + if (!data?.deviceAuthId || !data?.userCode) { + ElMessage.error('Device code response was incomplete') + return + } + + activeDeviceAuthId = data.deviceAuthId + deviceCodeDialog.value = { + visible: true, + userCode: data.userCode, + verificationUrl: data.verificationUrl, + verificationUrlComplete: data.verificationUrlComplete ?? null, + expiresAt: Date.now() + (data.expiresInSeconds ?? 600) * 1000, + } + + const intervalMs = Math.max((data.intervalSeconds ?? 5) * 1000, 3000) + + const tick = async () => { + if (!activeDeviceAuthId || !deviceCodeDialog.value.visible) { + stopDevicePolling() + return + } + if (Date.now() > deviceCodeDialog.value.expiresAt) { + ElMessage.warning(t('settings.model.oauthDeviceExpired')) + closeDeviceCodeDialog() + return + } + try { + const res: any = await oauthApi.devicePoll(activeDeviceAuthId) + const status = res.data?.status + if (status === 'COMPLETED') { + activeDeviceAuthId = null + stopDevicePolling() + deviceCodeDialog.value.visible = false + ElMessage.success(t('settings.model.oauthLoginSuccess')) + await reloadProvidersAndSync() + return + } + if (status === 'EXPIRED') { + ElMessage.warning(t('settings.model.oauthDeviceExpired')) + closeDeviceCodeDialog() + return + } + } catch { /* transient — keep polling */ } + devicePollTimer = setTimeout(tick, intervalMs) + } + devicePollTimer = setTimeout(tick, intervalMs) + } + async function handleOAuthLogin(providerId?: string) { if (providerId === 'anthropic-claude-code') { try { @@ -50,7 +143,16 @@ export function useProviderOAuth(deps: FormDeps & ListDeps) { } try { const res: any = await oauthApi.authorize() - const { authorizeUrl } = res.data + const { authorizeUrl, mode } = res.data || {} + + if (mode === 'DEVICE_CODE') { + await runDeviceCodeFlow() + return + } + + // LOCAL / MANUAL_PASTE both produce an authorize URL the user opens; success + // is detected by polling /status (LOCAL completes via the loopback server, + // MANUAL_PASTE relies on the user pasting the URL back via callbackPaste). const authWindow = window.open(authorizeUrl, '_blank', 'width=600,height=700') const pollInterval = setInterval(async () => { try { @@ -89,5 +191,7 @@ export function useProviderOAuth(deps: FormDeps & ListDeps) { return { handleOAuthLogin, handleOAuthRevoke, + deviceCodeDialog, + closeDeviceCodeDialog, } } diff --git a/mateclaw-ui/src/views/Settings/Models/index.vue b/mateclaw-ui/src/views/Settings/Models/index.vue index ef5a7c40..28f8f1ea 100644 --- a/mateclaw-ui/src/views/Settings/Models/index.vue +++ b/mateclaw-ui/src/views/Settings/Models/index.vue @@ -172,6 +172,15 @@ :enable-provider="enableProvider" @close="closeDrawer" /> + + @@ -192,6 +201,7 @@ const ProviderConfigModal = defineAsyncComponent(() => import('./modals/Provider const ManageModelsModal = defineAsyncComponent(() => import('./modals/ManageModelsModal.vue')) // RFC-074 PR-2: drawer for browsing the catalog and opting into hidden built-ins. const AddProviderDrawer = defineAsyncComponent(() => import('./AddProviderDrawer.vue')) +const DeviceCodeDialog = defineAsyncComponent(() => import('./modals/DeviceCodeDialog.vue')) const { t } = useI18n() const savedTip = ref('') @@ -251,6 +261,8 @@ const { onIconError, handleOAuthLogin, handleOAuthRevoke, + deviceCodeDialog, + closeDeviceCodeDialog, // RFC-074 PR-2 — enablement / drawer catalog, drawerOpen, diff --git a/mateclaw-ui/src/views/Settings/Models/modals/DeviceCodeDialog.vue b/mateclaw-ui/src/views/Settings/Models/modals/DeviceCodeDialog.vue new file mode 100644 index 00000000..0b5e8319 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Models/modals/DeviceCodeDialog.vue @@ -0,0 +1,206 @@ + + + + +