From ebae2846b272601bca7f157ea4424db4cd87a9e4 Mon Sep 17 00:00:00 2001 From: ciat-777 Date: Tue, 17 Mar 2026 17:56:30 +0800 Subject: [PATCH] feat: align oauth loopback flow with cliproxyapi --- src/server/routes/api/oauth.test.ts | 73 ++++++++++--- src/server/routes/api/oauth.ts | 35 ++++++ src/server/services/oauth/codexProvider.ts | 27 ----- src/server/services/oauth/service.ts | 117 ++++++++++++++++++++- src/web/api.ts | 24 ++++- src/web/pages/OAuthManagement.test.tsx | 114 ++++++++++++++++++++ src/web/pages/OAuthManagement.tsx | 102 +++++++++++++++++- 7 files changed, 440 insertions(+), 52 deletions(-) diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index 0828040..cb13c4b 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -101,14 +101,28 @@ describe('oauth routes', { timeout: 15_000 }, () => { provider: string; state: string; authorizationUrl: string; + instructions?: { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + sshTunnelCommand?: string; + }; }; expect(startBody.provider).toBe('codex'); expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/); expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?'); expect(startBody.authorizationUrl).toContain('client_id='); - expect(startBody.authorizationUrl).toContain(encodeURIComponent('https://metapi.example/api/oauth/callback/codex')); + expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback')); expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`); expect(startBody.authorizationUrl).toContain('code_challenge='); + expect(startBody.instructions).toMatchObject({ + redirectUri: 'http://localhost:1455/auth/callback', + callbackPort: 1455, + callbackPath: '/auth/callback', + manualCallbackDelayMs: 15000, + sshTunnelCommand: 'ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22', + }); const sessionResponse = await app.inject({ method: 'GET', @@ -142,7 +156,7 @@ describe('oauth routes', { timeout: 15_000 }, () => { expect(startBody.authorizationUrl).not.toContain(encodeURIComponent('http://localhost:4000/api/oauth/callback/codex')); }); - it('handles codex callback, creates oauth-backed account, and discovers plan models', async () => { + it('handles manual codex callback submission, creates oauth-backed account, and discovers plan models', async () => { const jwt = buildJwt({ email: 'codex-user@example.com', 'https://api.openai.com/auth': { @@ -187,17 +201,16 @@ describe('oauth routes', { timeout: 15_000 }, () => { const startBody = startResponse.json() as { state: string }; const callbackResponse = await app.inject({ - method: 'GET', - url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`, - headers: { - host: 'metapi.example', - 'x-forwarded-proto': 'https', + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`, }, }); expect(callbackResponse.statusCode).toBe(200); - expect(callbackResponse.body).toContain('window.close()'); + expect(callbackResponse.json()).toEqual({ success: true }); expect(String(fetchMock.mock.calls[0]?.[1]?.body || '')).toContain( - 'redirect_uri=https%3A%2F%2Fmetapi.example%2Fapi%2Foauth%2Fcallback%2Fcodex', + 'redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback', ); const sessionResponse = await app.inject({ @@ -252,7 +265,7 @@ describe('oauth routes', { timeout: 15_000 }, () => { expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']); }); - it('marks oauth session as error and avoids creating a connection when codex model discovery fails', async () => { + it('marks oauth session as error and avoids creating a connection when manual codex callback model discovery fails', async () => { const jwt = buildJwt({ email: 'codex-fail@example.com', 'https://api.openai.com/auth': { @@ -291,15 +304,16 @@ describe('oauth routes', { timeout: 15_000 }, () => { const startBody = startResponse.json() as { state: string }; const callbackResponse = await app.inject({ - method: 'GET', - url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`, - headers: { - host: 'metapi.example', - 'x-forwarded-proto': 'https', + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`, }, }); - expect(callbackResponse.statusCode).toBe(200); - expect(callbackResponse.body).toContain('OAuth authorization failed'); + expect(callbackResponse.statusCode).toBe(500); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('HTTP 403'), + }); const sessionResponse = await app.inject({ method: 'GET', @@ -412,4 +426,29 @@ describe('oauth routes', { timeout: 15_000 }, () => { const accounts = await db.select().from(schema.accounts).all(); expect(accounts).toEqual([]); }); + + it('rejects malformed manual callback submissions', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/claude/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: 'not-a-valid-url', + }, + }); + + expect(callbackResponse.statusCode).toBe(400); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('invalid oauth callback url'), + }); + }); }); diff --git a/src/server/routes/api/oauth.ts b/src/server/routes/api/oauth.ts index 9c97b9f..5eb942e 100644 --- a/src/server/routes/api/oauth.ts +++ b/src/server/routes/api/oauth.ts @@ -8,6 +8,7 @@ import { listOauthProviders, startOauthProviderFlow, startOauthRebindFlow, + submitOauthManualCallback, } from '../../services/oauth/service.js'; const limitOauthProviderRead = createRateLimitGuard({ @@ -28,6 +29,12 @@ const limitOauthSessionRead = createRateLimitGuard({ windowMs: 60_000, }); +const limitOauthSessionMutate = createRateLimitGuard({ + bucket: 'oauth-session-mutate', + max: 30, + windowMs: 60_000, +}); + const limitOauthConnectionRead = createRateLimitGuard({ bucket: 'oauth-connection-read', max: 60, @@ -142,6 +149,34 @@ export async function oauthRoutes(app: FastifyInstance) { }, ); + app.post<{ Params: { state: string }; Body: { callbackUrl?: string } }>( + '/api/oauth/sessions/:state/manual-callback', + { preHandler: [limitOauthSessionMutate] }, + async (request, reply) => { + const callbackUrl = typeof request.body?.callbackUrl === 'string' + ? request.body.callbackUrl.trim() + : ''; + if (!callbackUrl) { + return reply.code(400).send({ message: 'invalid oauth callback url' }); + } + try { + return await submitOauthManualCallback({ + state: request.params.state, + callbackUrl, + }); + } catch (error: any) { + const message = error?.message || 'oauth callback submission failed'; + if (message === 'invalid oauth callback url' || message === 'oauth callback state mismatch') { + return reply.code(400).send({ message }); + } + if (message === 'oauth session not found') { + return reply.code(404).send({ message }); + } + return reply.code(500).send({ message }); + } + }, + ); + app.get<{ Querystring: { limit?: string; offset?: string } }>( '/api/oauth/connections', { preHandler: [limitOauthConnectionRead] }, diff --git a/src/server/services/oauth/codexProvider.ts b/src/server/services/oauth/codexProvider.ts index 08a8c0f..8895abd 100644 --- a/src/server/services/oauth/codexProvider.ts +++ b/src/server/services/oauth/codexProvider.ts @@ -20,32 +20,6 @@ function requireCodexClientId(): string { return CODEX_CLIENT_ID; } -function normalizeOrigin(origin?: string): string | null { - if (typeof origin !== 'string') return null; - const trimmed = origin.trim().replace(/\/+$/, ''); - return trimmed || null; -} - -function isLoopbackHostname(hostname: string): boolean { - const normalized = hostname.trim().toLowerCase(); - return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]'; -} - -export function resolveCodexRedirectUri(requestOrigin?: string): string { - const origin = normalizeOrigin(requestOrigin); - if (!origin) return CODEX_LOOPBACK_REDIRECT_URI; - - try { - const parsed = new URL(origin); - if (isLoopbackHostname(parsed.hostname)) { - return CODEX_LOOPBACK_REDIRECT_URI; - } - return `${origin}${CODEX_CALLBACK_PATH}`; - } catch { - return CODEX_LOOPBACK_REDIRECT_URI; - } -} - type CodexJwtClaims = { email?: unknown; 'https://api.openai.com/auth'?: { @@ -210,7 +184,6 @@ export const codexOauthProvider: OAuthProviderDefinition = { path: CODEX_LOOPBACK_CALLBACK_PATH, redirectUri: CODEX_LOOPBACK_REDIRECT_URI, }, - resolveRedirectUri: ({ requestOrigin }) => resolveCodexRedirectUri(requestOrigin), buildAuthorizationUrl: ({ state, redirectUri, codeVerifier }) => buildCodexAuthorizationUrl({ state, redirectUri, diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts index 23a6521..4332755 100644 --- a/src/server/services/oauth/service.ts +++ b/src/server/services/oauth/service.ts @@ -18,6 +18,89 @@ import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js'; import { buildCodexOauthInfo } from './codexAccount.js'; type OAuthProviderMetadata = ReturnType[number]; +const MANUAL_CALLBACK_DELAY_MS = 15_000; + +type OAuthStartInstructions = { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + sshTunnelCommand?: string; + sshTunnelKeyCommand?: string; +}; + +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return normalized === 'localhost' + || normalized === '127.0.0.1' + || normalized === '::1' + || normalized === '[::1]'; +} + +function resolveSshTunnelHost(requestOrigin?: string): string | undefined { + if (!requestOrigin) return undefined; + try { + const parsed = new URL(requestOrigin); + if (!parsed.hostname || isLoopbackHost(parsed.hostname)) { + return undefined; + } + return parsed.hostname; + } catch { + return undefined; + } +} + +function buildLoopbackInstructions( + definition: OAuthProviderDefinition, + requestOrigin?: string, +): OAuthStartInstructions { + const sshHost = resolveSshTunnelHost(requestOrigin); + return { + redirectUri: definition.loopback.redirectUri, + callbackPort: definition.loopback.port, + callbackPath: definition.loopback.path, + manualCallbackDelayMs: MANUAL_CALLBACK_DELAY_MS, + sshTunnelCommand: sshHost + ? `ssh -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22` + : undefined, + sshTunnelKeyCommand: sshHost + ? `ssh -i -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22` + : undefined, + }; +} + +function parseManualCallbackUrl(input: { + callbackUrl: string; + provider: string; +}) { + const raw = asNonEmptyString(input.callbackUrl); + if (!raw) { + throw new Error('invalid oauth callback url'); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error('invalid oauth callback url'); + } + + const state = asNonEmptyString(parsed.searchParams.get('state')); + const code = asNonEmptyString(parsed.searchParams.get('code')); + const error = asNonEmptyString(parsed.searchParams.get('error')); + const errorDescription = asNonEmptyString(parsed.searchParams.get('error_description')); + if (!state || (!code && !error)) { + throw new Error('invalid oauth callback url'); + } + + return { + state, + code, + error: error + ? (errorDescription ? `${error}: ${errorDescription}` : error) + : undefined, + }; +} function asNonEmptyString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; @@ -209,12 +292,9 @@ export async function startOauthProviderFlow(input: { if (!definition) { throw new Error(`unsupported oauth provider: ${input.provider}`); } - const redirectUri = definition.resolveRedirectUri?.({ - requestOrigin: input.requestOrigin, - }) || definition.loopback.redirectUri; + const redirectUri = definition.loopback.redirectUri; const callbackServerState = getOAuthLoopbackCallbackServerState(input.provider); - const usesLoopbackCallback = redirectUri === definition.loopback.redirectUri; - if (usesLoopbackCallback && callbackServerState.attempted && !callbackServerState.ready) { + if (callbackServerState.attempted && !callbackServerState.ready) { throw new Error(`${input.provider} oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`); } const session = createOauthSession({ @@ -232,6 +312,7 @@ export async function startOauthProviderFlow(input: { codeVerifier: session.codeVerifier, projectId: session.projectId, }), + instructions: buildLoopbackInstructions(definition, input.requestOrigin), }; } @@ -311,6 +392,32 @@ export async function handleOauthCallback(input: { return { accountId: account.id, siteId: site.id }; } +export async function submitOauthManualCallback(input: { + state: string; + callbackUrl: string; +}) { + const session = getOauthSession(input.state); + if (!session) { + throw new Error('oauth session not found'); + } + const parsed = parseManualCallbackUrl({ + callbackUrl: input.callbackUrl, + provider: session.provider, + }); + if (parsed.state !== input.state) { + throw new Error('oauth callback state mismatch'); + } + + await handleOauthCallback({ + provider: session.provider, + state: parsed.state, + code: parsed.code, + error: parsed.error, + }); + + return { success: true }; +} + export async function listOauthConnections(options: { limit?: number; offset?: number; diff --git a/src/web/api.ts b/src/web/api.ts index 42a39cf..8c90f4d 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -258,6 +258,22 @@ export type OAuthProviderInfo = { supportsNativeProxy: boolean; }; +export type OAuthStartInstructions = { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + sshTunnelCommand?: string; + sshTunnelKeyCommand?: string; +}; + +export type OAuthStartResponse = { + provider: string; + state: string; + authorizationUrl: string; + instructions: OAuthStartInstructions; +}; + export type OAuthSessionInfo = { provider: string; state: string; @@ -408,14 +424,18 @@ export const api = { startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, { method: 'POST', body: JSON.stringify(data || {}), - }) as Promise<{ provider: string; state: string; authorizationUrl: string }>, + }) as Promise, getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise, + submitOAuthManualCallback: (state: string, callbackUrl: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}/manual-callback`, { + method: 'POST', + body: JSON.stringify({ callbackUrl }), + }) as Promise<{ success: true }>, getOAuthConnections: (params?: { limit?: number; offset?: number }) => request(`/api/oauth/connections${buildQueryString(params)}`) as Promise, rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, { method: 'POST', body: JSON.stringify({}), - }) as Promise<{ provider: string; state: string; authorizationUrl: string }>, + }) as Promise, deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, { method: 'DELETE', }) as Promise<{ success: true }>, diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx index 727092a..0ef8f24 100644 --- a/src/web/pages/OAuthManagement.test.tsx +++ b/src/web/pages/OAuthManagement.test.tsx @@ -10,6 +10,7 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(() getOAuthConnections: vi.fn(), startOAuthProvider: vi.fn(), getOAuthSession: vi.fn(), + submitOAuthManualCallback: vi.fn(), rebindOAuthConnection: vi.fn(), deleteOAuthConnection: vi.fn(), }, @@ -180,6 +181,13 @@ describe('OAuthManagement page', () => { provider: 'codex', state: 'oauth-state-123', authorizationUrl: 'https://auth.openai.com/oauth/authorize?state=oauth-state-123', + instructions: { + redirectUri: 'http://localhost:1455/auth/callback', + callbackPort: 1455, + callbackPath: '/auth/callback', + manualCallbackDelayMs: 15000, + sshTunnelCommand: 'ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22', + }, }); apiMock.getOAuthSession .mockResolvedValueOnce({ @@ -228,6 +236,7 @@ describe('OAuthManagement page', () => { 'oauth-codex', expect.stringContaining('width=540'), ); + expect(collectText(root!.root)).toContain('ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22'); await act(async () => { vi.advanceTimersByTime(1600); @@ -253,6 +262,111 @@ describe('OAuthManagement page', () => { } }); + it('reveals manual callback input after delay and submits the pasted callback url', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'claude', + label: 'Claude', + platform: 'claude', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + apiMock.startOAuthProvider.mockResolvedValue({ + provider: 'claude', + state: 'oauth-state-456', + authorizationUrl: 'https://claude.ai/oauth/authorize?state=oauth-state-456', + instructions: { + redirectUri: 'http://localhost:54545/callback', + callbackPort: 54545, + callbackPath: '/callback', + manualCallbackDelayMs: 15000, + sshTunnelCommand: 'ssh -L 54545:127.0.0.1:54545 root@metapi.example -p 22', + }, + }); + apiMock.getOAuthSession.mockResolvedValue({ + provider: 'claude', + state: 'oauth-state-456', + status: 'pending', + }); + apiMock.submitOAuthManualCallback.mockResolvedValue({ success: true }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + }); + + const startButton = root!.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('连接 Claude') + )); + + await act(async () => { + await startButton.props.onClick(); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + }); + + expect(collectText(root!.root)).not.toContain('提交回调 URL'); + + await act(async () => { + vi.advanceTimersByTime(15000); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + expect(collectText(root!.root)).toContain('提交回调 URL'); + }); + + const textInput = root!.root.find((node) => ( + node.type === 'input' + && node.props.value !== undefined + )); + + await act(async () => { + textInput.props.onChange({ target: { value: 'http://localhost:54545/callback?code=test-code&state=oauth-state-456' } }); + }); + + const submitButton = root!.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('提交回调 URL') + )); + + await act(async () => { + await submitButton.props.onClick(); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + }); + + expect(apiMock.submitOAuthManualCallback).toHaveBeenCalledWith( + 'oauth-state-456', + 'http://localhost:54545/callback?code=test-code&state=oauth-state-456', + ); + } finally { + root?.unmount(); + } + }); + it('shows oauth connection status metadata and allows deleting a connection', async () => { apiMock.getOAuthProviders.mockResolvedValue({ providers: [ diff --git a/src/web/pages/OAuthManagement.tsx b/src/web/pages/OAuthManagement.tsx index 91a4a94..7fe01a7 100644 --- a/src/web/pages/OAuthManagement.tsx +++ b/src/web/pages/OAuthManagement.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { api, type OAuthConnectionInfo, type OAuthProviderInfo } from '../api.js'; +import { api, type OAuthConnectionInfo, type OAuthProviderInfo, type OAuthStartInstructions } from '../api.js'; const POLL_INTERVAL_MS = 1500; const CONNECTION_PAGE_LIMIT = 100; @@ -7,6 +7,8 @@ const CONNECTION_PAGE_LIMIT = 100; type ActiveSession = { provider: string; state: string; + authorizationUrl: string; + instructions: OAuthStartInstructions; }; function openOAuthPopup(provider: string, authorizationUrl: string) { @@ -39,6 +41,9 @@ export default function OAuthManagement() { const [activeSession, setActiveSession] = useState(null); const [sessionMessage, setSessionMessage] = useState(''); const [actionLoadingKey, setActionLoadingKey] = useState(''); + const [manualCallbackVisible, setManualCallbackVisible] = useState(false); + const [manualCallbackUrl, setManualCallbackUrl] = useState(''); + const [manualCallbackSubmitting, setManualCallbackSubmitting] = useState(false); const loadConnections = async () => { const response = await api.getOAuthConnections({ @@ -107,6 +112,25 @@ export default function OAuthManagement() { }; }, [activeSession]); + useEffect(() => { + if (!activeSession) { + setManualCallbackVisible(false); + setManualCallbackUrl(''); + setManualCallbackSubmitting(false); + return; + } + + setManualCallbackVisible(false); + setManualCallbackUrl(''); + setManualCallbackSubmitting(false); + + const timer = setTimeout(() => { + setManualCallbackVisible(true); + }, Math.max(0, activeSession.instructions.manualCallbackDelayMs || 0)); + + return () => clearTimeout(timer); + }, [activeSession]); + const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => { const actionKey = `start:${provider.provider}:${accountId || 0}`; setActionLoadingKey(actionKey); @@ -129,6 +153,8 @@ export default function OAuthManagement() { setActiveSession({ provider: started.provider, state: started.state, + authorizationUrl: started.authorizationUrl, + instructions: started.instructions, }); openOAuthPopup(provider.provider, started.authorizationUrl); } catch (error: any) { @@ -138,6 +164,24 @@ export default function OAuthManagement() { } }; + const handleSubmitManualCallback = async () => { + if (!activeSession) return; + const callbackUrl = manualCallbackUrl.trim(); + if (!callbackUrl) { + setSessionMessage('请输入完整的回调 URL'); + return; + } + setManualCallbackSubmitting(true); + try { + await api.submitOAuthManualCallback(activeSession.state, callbackUrl); + setSessionMessage('回调已提交,等待授权完成'); + } catch (error: any) { + setSessionMessage(error?.message || '提交回调 URL 失败'); + } finally { + setManualCallbackSubmitting(false); + } + }; + const handleDelete = async (accountId: number) => { if (typeof window !== 'undefined' && typeof window.confirm === 'function') { const confirmed = window.confirm('确定要删除这个 OAuth 连接吗?'); @@ -171,6 +215,62 @@ export default function OAuthManagement() { )} + {activeSession && ( +
+
授权指引
+
+
回调地址固定为 {activeSession.instructions.redirectUri}
+
+ 打开授权页后,如果你在云端部署 metapi,请先在本地运行 SSH 隧道,再继续登录。 +
+ {activeSession.instructions.sshTunnelCommand && ( +
+
SSH 隧道命令
+ {activeSession.instructions.sshTunnelCommand} +
+ )} + {activeSession.instructions.sshTunnelKeyCommand && ( +
+
SSH Key 隧道命令
+ {activeSession.instructions.sshTunnelKeyCommand} +
+ )} +
+ 如果授权完成后浏览器停在 localhost 错误页,复制完整地址,等待 {Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。 +
+ {manualCallbackVisible ? ( +
+ setManualCallbackUrl(event.target.value)} + placeholder="粘贴完整的 callback URL" + /> +
+ + +
+
+ ) : ( +
手动回调入口将在几秒后可用。
+ )} +
+
+ )} +
授权入口