package vip.mate.llm.oauth; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.sun.net.httpserver.HttpServer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; import vip.mate.exception.MateClawException; import vip.mate.llm.model.ModelProviderEntity; import vip.mate.llm.repository.ModelProviderMapper; import vip.mate.llm.service.ModelProviderService; import java.io.OutputStream; import java.net.InetSocketAddress; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Base64; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; /** * OpenAI OAuth service — supports three flow modes for the same Codex CLI client_id. * *
Mode selection: *
Heuristic: if Host is localhost / 127.0.0.1 / ::1, the user is hitting * 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 * 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. */ 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 ("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 } if (requestHost == null || requestHost.isBlank()) { return OAuthFlowMode.LOCAL; } String host = requestHost.toLowerCase(); int colon = host.lastIndexOf(':'); if (colon > 0 && host.charAt(0) != '[') { // not IPv6 host = host.substring(0, colon); } if ("localhost".equals(host) || "127.0.0.1".equals(host) || "::1".equals(host) || "[::1]".equals(host)) { return OAuthFlowMode.LOCAL; } return OAuthFlowMode.DEVICE_CODE; } /** * Manual-paste fallback: user copies the (failed-to-load) callback URL from * their browser's address bar back into MateClaw. We parse code + state and * complete the token exchange. * * @param pastedUrl e.g. {@code http://localhost:1455/auth/callback?code=XXX&state=YYY} * — anything from {@code ?} onward is parsed; the host part * is ignored. Trailing fragments / encoding tolerated. */ public void completeFromPastedUrl(String pastedUrl) { if (pastedUrl == null || pastedUrl.isBlank()) { throw new MateClawException("err.llm.oauth_paste_empty", "粘贴的 URL 为空,请回到浏览器地址栏复制完整 URL"); } String trimmed = pastedUrl.trim(); int q = trimmed.indexOf('?'); if (q < 0) { throw new MateClawException("err.llm.oauth_paste_invalid", "粘贴的 URL 没有查询参数,请确认包含 ?code=... 部分"); } // Strip fragment if any (the # part) String query = trimmed.substring(q + 1); int hash = query.indexOf('#'); if (hash >= 0) query = query.substring(0, hash); String code = extractParam(query, "code"); String state = extractParam(query, "state"); if (code == null || code.isBlank()) { throw new MateClawException("err.llm.oauth_paste_no_code", "粘贴的 URL 中缺少 code 参数,登录可能未完成"); } if (state == null || state.isBlank()) { throw new MateClawException("err.llm.oauth_paste_no_state", "粘贴的 URL 中缺少 state 参数"); } log.info("OAuth manual-paste completion: state prefix={}", state.substring(0, Math.min(8, state.length()))); exchangeToken(code, state); } /** * Start the temporary HTTP callback server on localhost:1455. * * @return {@code true} if bound successfully (caller proceeds with LOCAL mode); * {@code false} if bind failed (port in use OR not on a host that can * bind 127.0.0.1 — caller should fall back to MANUAL_PASTE). */ private boolean startCallbackServer(String expectedState) { // 关闭上一次可能残留的回调服务器 stopActiveCallbackServer(); // Try to bind synchronously up front so callers can detect failure. HttpServer server; String bindHost = resolveCallbackBindHost(); try { server = HttpServer.create(new InetSocketAddress(bindHost, CALLBACK_PORT), 0); } catch (java.net.BindException e) { log.warn("OAuth callback bind failed on {}:{} (in-use or restricted): {}", bindHost, CALLBACK_PORT, e.getMessage()); pendingStates.remove(expectedState); return false; } catch (java.io.IOException e) { log.warn("OAuth callback HttpServer.create IO error on {}:{}: {}", bindHost, CALLBACK_PORT, e.getMessage()); pendingStates.remove(expectedState); return false; } final HttpServer boundServer = server; CompletableFuture.runAsync(() -> { try { final HttpServer srv = boundServer; server.createContext("/auth/callback", exchange -> { try { String query = exchange.getRequestURI().getQuery(); String code = extractParam(query, "code"); String state = extractParam(query, "state"); if (!expectedState.equals(state)) { String errorHtml = "
OAuth state 不匹配,请重试。
"; byte[] bytes = errorHtml.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); exchange.sendResponseHeaders(400, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } return; } if (code == null || code.isBlank()) { String errorHtml = "缺少授权码。
"; byte[] bytes = errorHtml.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); exchange.sendResponseHeaders(400, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } return; } // 交换 token try { exchangeToken(code, state); String successHtml = "OpenAI OAuth 授权完成,您可以关闭此窗口。
" + "" + ""; byte[] bytes = successHtml.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); exchange.sendResponseHeaders(200, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } } catch (Exception e) { log.error("OAuth token 交换失败", e); String errorHtml = "" + e.getMessage() + "
"; byte[] bytes = errorHtml.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); exchange.sendResponseHeaders(500, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } } } finally { // 收到回调后关闭服务器 srv.stop(1); activeCallbackServer = null; log.info("OAuth 回调服务器已关闭"); } }); boundServer.start(); activeCallbackServer = boundServer; log.info("OAuth 回调服务器已启动,监听 {}:{},浏览器回调地址 {}", bindHost, CALLBACK_PORT, REDIRECT_URI); // 3 分钟超时自动关闭 CompletableFuture.delayedExecutor(3, TimeUnit.MINUTES).execute(() -> { try { boundServer.stop(0); if (activeCallbackServer == boundServer) { activeCallbackServer = null; } pendingStates.remove(expectedState); log.info("OAuth 回调服务器超时关闭"); } catch (Exception ignored) {} }); } catch (Exception e) { // bind 已经成功(同步阶段处理过 BindException),这里捕获 createContext / // start 等运行时错误。 log.error("OAuth 回调服务器运行时错误", e); pendingStates.remove(expectedState); try { boundServer.stop(0); } catch (Exception ignored) {} } }); return true; } /** * 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,可能已过期或重复使用"); } exchangeTokenWithVerifier(code, codeVerifier, REDIRECT_URI); } /** * 刷新 access_token */ public void refreshToken() { ModelProviderEntity provider = getProvider(); if (!StringUtils.hasText(provider.getOauthRefreshToken())) { throw new MateClawException("err.llm.oauth_no_refresh", "无 refresh_token,请重新登录"); } String body = "grant_type=refresh_token" + "&refresh_token=" + enc(provider.getOauthRefreshToken()) + "&client_id=" + enc(CLIENT_ID); JsonNode tokenResponse = postTokenRequest(body); saveTokens(tokenResponse); } /** * 确保 access_token 有效(过期时自动刷新) */ public String ensureValidAccessToken() { ModelProviderEntity provider = getProvider(); if (!StringUtils.hasText(provider.getOauthAccessToken())) { throw new MateClawException("err.llm.oauth_not_connected", "未连接 OpenAI OAuth,请先登录"); } // 提前 5 分钟刷新 if (provider.getOauthExpiresAt() != null && System.currentTimeMillis() > provider.getOauthExpiresAt() - 300_000) { log.info("OpenAI OAuth token 即将过期,自动刷新..."); refreshToken(); provider = getProvider(); } return provider.getOauthAccessToken(); } /** * 获取 account_id(用于请求 header) */ public String getAccountId() { ModelProviderEntity provider = getProvider(); String accountId = provider.getOauthAccountId(); // 兼容修复:旧版 JWT 解析字段名错误导致 accountId 为空,从现有 token 重新解析 if (!StringUtils.hasText(accountId) && StringUtils.hasText(provider.getOauthAccessToken())) { accountId = extractAccountIdFromJwt(provider.getOauthAccessToken()); if (StringUtils.hasText(accountId)) { provider.setOauthAccountId(accountId); modelProviderMapper.updateById(provider); log.info("从已有 token 重新解析并保存 accountId={}", accountId); } } return accountId; } /** * 清除 OAuth 凭证 */ public void revokeToken() { // MyBatis Plus updateById 默认跳过 null 字段,必须用 LambdaUpdateWrapper 显式置空 modelProviderMapper.update(null, new LambdaUpdateWrapper