feat(llm): OAuth device authorization grant for ChatGPT remote deploy

This commit is contained in:
matevip 2026-05-05 10:39:47 +08:00
parent eb64556636
commit be2235a493
11 changed files with 740 additions and 44 deletions

View File

@ -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<DeviceCodeStartResult> deviceStart() {
return R.ok(deviceCodeService.start());
}
@Operation(summary = "Device flow: poll for completion")
@PostMapping("/device/poll")
public R<DeviceCodePollResult> devicePoll(@RequestBody DeviceRequest request) {
return R.ok(deviceCodeService.poll(request.deviceAuthId()));
}
@Operation(summary = "Device flow: cancel a pending session")
@PostMapping("/device/cancel")
public R<Void> 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<Void> refresh() {

View File

@ -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.
*
* <p>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}.
*
* <p>Flow:
* <ol>
* <li>{@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.</li>
* <li>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}.</li>
* <li>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.</li>
* </ol>
*
* <p>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<String, DeviceCodeSession> 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); }
}
}

View File

@ -28,26 +28,36 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
/**
* OpenAI OAuth 服务 基于 PKCE OAuth 2.0 流程
* <p>
* <strong>双模式</strong>issue: server 部署回调失败 Linux 部署后浏览器无法连
* localhost:1455 因为它不是 server localhost
* OpenAI OAuth service supports three flow modes for the same Codex CLI client_id.
*
* <ul>
* <li><b>LOCAL</b>桌面 / 本机部署 localhost:1455 启动临时 HTTP 服务器浏览器
* 自动 callbackOpenAI Codex CLI client_id 注册的 redirect_uri 就是这个
* 所以无法换成公网 URL</li>
* <li><b>MANUAL_PASTE</b>远程 server 部署不启动 localhost server浏览器登录后
* 会跳到 localhost:1455/auth/callback 但因为没人监听会报 ERR_CONNECTION_REFUSED
* 此时 URL 栏里已经有 ?code=...&state=... 参数让用户复制整个 URL 粘贴回 MateClaw
* UI后端通过 {@link #completeFromPastedUrl} 解析 code 完成 token 交换</li>
* <li><b>LOCAL</b>: 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.</li>
* <li><b>DEVICE_CODE</b>: 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}.</li>
* <li><b>MANUAL_PASTE</b>: 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.</li>
* </ul>
*
* <p>模式选择
* <p>Mode selection:
* <ol>
* <li>config {@code mateclaw.oauth.openai.deployment-mode = local | server | auto}默认 auto</li>
* <li>auto 模式按 Host header 判定localhost / 127.0.0.1 / ::1 LOCAL其它 SERVER</li>
* <li>LOCAL 模式 bind 失败时自动降级到 MANUAL_PASTE</li>
* <li>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.</li>
* <li>{@code auto} dispatches by Host header: localhost / 127.0.0.1 / ::1 LOCAL,
* any other host DEVICE_CODE.</li>
* <li>LOCAL bind failure degrades to MANUAL_PASTE so the user always has a path
* forward.</li>
* </ol>
*/
@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).
*
* <p>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);
}
/**

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.',

View File

@ -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-062Claude Code OAuth订阅复用
claudeCodeOauthDetect: '检测 Claude Code 登录态',
claudeCodeOauthHint: '复用本地 Claude Code Pro/Max 订阅。请先在 Claude Code 客户端中登录,再点击"检测"读取凭据。',

View File

@ -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<ProviderInfo[]>
}
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<DeviceCodeDialogState>({
visible: false,
userCode: '',
verificationUrl: '',
verificationUrlComplete: null,
expiresAt: 0,
})
let devicePollTimer: ReturnType<typeof setTimeout> | 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,
}
}

View File

@ -172,6 +172,15 @@
:enable-provider="enableProvider"
@close="closeDrawer"
/>
<DeviceCodeDialog
:visible="deviceCodeDialog.visible"
:user-code="deviceCodeDialog.userCode"
:verification-url="deviceCodeDialog.verificationUrl"
:verification-url-complete="deviceCodeDialog.verificationUrlComplete"
:expires-at="deviceCodeDialog.expiresAt"
@close="closeDeviceCodeDialog"
/>
</div>
</template>
@ -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,

View File

@ -0,0 +1,206 @@
<template>
<div v-if="visible" class="modal-overlay" @click.self="$emit('close')">
<div class="modal device-modal">
<div class="modal-header">
<h2>{{ t('settings.model.oauthDeviceTitle') }}</h2>
<button class="modal-close" @click="$emit('close')">×</button>
</div>
<div class="modal-body device-body">
<p class="device-step">{{ t('settings.model.oauthDeviceStep1') }}</p>
<a
class="device-link"
:href="verificationUrlComplete || verificationUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ verificationUrl }}
</a>
<p class="device-step">{{ t('settings.model.oauthDeviceStep2') }}</p>
<div class="device-code-row">
<code class="device-code">{{ userCode }}</code>
<button class="btn-copy" type="button" @click="copyCode">
{{ copied ? t('settings.model.copied') : t('settings.model.copy') }}
</button>
</div>
<p class="device-step device-countdown">
{{ t('settings.model.oauthDeviceStep3', { seconds: remainingSeconds }) }}
</p>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="$emit('close')">
{{ t('common.cancel') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
const props = defineProps<{
visible: boolean
userCode: string
verificationUrl: string
verificationUrlComplete: string | null
/** Epoch ms when the device authorization expires. */
expiresAt: number
}>()
defineEmits<{ (e: 'close'): void }>()
const { t } = useI18n()
const copied = ref(false)
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | null = null
const remainingSeconds = computed(() => {
const left = Math.max(0, Math.floor((props.expiresAt - now.value) / 1000))
return left
})
watch(
() => props.visible,
(v) => {
if (v) {
now.value = Date.now()
timer = setInterval(() => { now.value = Date.now() }, 1000)
} else if (timer) {
clearInterval(timer)
timer = null
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
async function copyCode() {
try {
await navigator.clipboard.writeText(props.userCode)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
ElMessage.warning(t('settings.model.copyFailed'))
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(124, 63, 30, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 50;
}
.modal {
background: var(--mc-bg-elevated);
border-radius: 16px;
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
}
.modal-header,
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid var(--mc-border-light);
}
.modal-header h2 {
margin: 0;
font-size: 18px;
color: var(--mc-text-primary);
}
.modal-footer {
border-top: 1px solid var(--mc-border-light);
border-bottom: none;
justify-content: flex-end;
}
.modal-close {
background: transparent;
border: none;
font-size: 22px;
color: var(--mc-text-secondary);
cursor: pointer;
line-height: 1;
}
.device-body {
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.device-step {
margin: 0;
font-size: 13px;
color: var(--mc-text-secondary);
}
.device-link {
display: inline-block;
font-family: var(--mc-mono, monospace);
font-size: 14px;
color: var(--mc-primary);
word-break: break-all;
}
.device-code-row {
display: flex;
align-items: center;
gap: 12px;
}
.device-code {
font-family: var(--mc-mono, ui-monospace, SFMono-Regular, monospace);
font-size: 28px;
letter-spacing: 6px;
font-weight: 700;
padding: 14px 20px;
background: var(--mc-bg-sunken);
border-radius: 10px;
color: var(--mc-text-primary);
user-select: all;
flex: 1;
text-align: center;
}
.btn-copy {
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
}
.btn-copy:hover {
background: var(--mc-bg-sunken);
}
.device-countdown {
margin-top: 4px;
font-size: 12px;
color: var(--mc-text-tertiary);
}
.btn-secondary {
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
border-radius: 10px;
padding: 9px 14px;
font-size: 14px;
cursor: pointer;
}
.btn-secondary:hover {
background: var(--mc-bg-sunken);
}
</style>