forked from boos/BoosAPI
Merge pull request #140 from cita-777/feat/oauth-loopback-bridge
fix: preserve codex workspaces and clarify oauth guidance
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -427,6 +428,113 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
expect(accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps multiple codex team workspaces with the same email as separate oauth connections', async () => {
|
||||
const buildTokenExchange = (accountId: string) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: `oauth-access-token-${accountId}`,
|
||||
refresh_token: `oauth-refresh-token-${accountId}`,
|
||||
id_token: buildJwt({
|
||||
email: 'team-user@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: accountId,
|
||||
chatgpt_plan_type: 'team',
|
||||
},
|
||||
}),
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
const buildModelDiscovery = (modelId: string) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: [{ id: modelId }],
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(buildTokenExchange('chatgpt-team-account-a'))
|
||||
.mockResolvedValueOnce(buildModelDiscovery('gpt-5.4'))
|
||||
.mockResolvedValueOnce(buildTokenExchange('chatgpt-team-account-b'))
|
||||
.mockResolvedValueOnce(buildModelDiscovery('gpt-5.4'));
|
||||
|
||||
const startFirstResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/codex/start',
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
const firstSession = startFirstResponse.json() as { state: string };
|
||||
|
||||
const submitFirstCallback = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/oauth/sessions/${encodeURIComponent(firstSession.state)}/manual-callback`,
|
||||
payload: {
|
||||
callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(firstSession.state)}&code=oauth-code-team-a`,
|
||||
},
|
||||
});
|
||||
expect(submitFirstCallback.statusCode).toBe(200);
|
||||
|
||||
const startSecondResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/codex/start',
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
const secondSession = startSecondResponse.json() as { state: string };
|
||||
|
||||
const submitSecondCallback = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/oauth/sessions/${encodeURIComponent(secondSession.state)}/manual-callback`,
|
||||
payload: {
|
||||
callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(secondSession.state)}&code=oauth-code-team-b`,
|
||||
},
|
||||
});
|
||||
expect(submitSecondCallback.statusCode).toBe(200);
|
||||
|
||||
const accounts = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.oauthProvider, 'codex'))
|
||||
.orderBy(schema.accounts.id)
|
||||
.all();
|
||||
|
||||
expect(accounts).toHaveLength(2);
|
||||
expect(accounts.map((account) => account.oauthAccountKey)).toEqual([
|
||||
'chatgpt-team-account-a',
|
||||
'chatgpt-team-account-b',
|
||||
]);
|
||||
|
||||
const connectionsResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/oauth/connections',
|
||||
});
|
||||
|
||||
expect(connectionsResponse.statusCode).toBe(200);
|
||||
expect(connectionsResponse.json()).toMatchObject({
|
||||
total: 2,
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: 'codex',
|
||||
email: 'team-user@example.com',
|
||||
accountKey: 'chatgpt-team-account-a',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: 'codex',
|
||||
email: 'team-user@example.com',
|
||||
accountKey: 'chatgpt-team-account-b',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed manual callback submissions', async () => {
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -177,7 +177,7 @@ async function findExistingOauthAccount(input: {
|
||||
if (byKey) return byKey;
|
||||
}
|
||||
|
||||
if (email) {
|
||||
if (!accountKey && email) {
|
||||
const byEmail = await db.select().from(schema.accounts).where(and(
|
||||
eq(schema.accounts.oauthProvider, input.provider),
|
||||
eq(schema.accounts.username, email),
|
||||
|
||||
@@ -95,6 +95,7 @@ describe('OAuthManagement page', () => {
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
email: 'codex-user@example.com',
|
||||
accountKey: 'chatgpt-account-123',
|
||||
planType: 'plus',
|
||||
modelCount: 3,
|
||||
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
|
||||
@@ -126,6 +127,7 @@ describe('OAuthManagement page', () => {
|
||||
expect(text).toContain('codex-user@example.com');
|
||||
expect(text).toContain('plus');
|
||||
expect(text).toContain('3 个模型');
|
||||
expect(text).toContain('chatgpt-account-123');
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
@@ -167,6 +169,7 @@ describe('OAuthManagement page', () => {
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
email: 'codex-user@example.com',
|
||||
accountKey: 'chatgpt-account-123',
|
||||
planType: 'plus',
|
||||
modelCount: 3,
|
||||
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
|
||||
@@ -236,6 +239,8 @@ describe('OAuthManagement page', () => {
|
||||
'oauth-codex',
|
||||
expect.stringContaining('width=540'),
|
||||
);
|
||||
expect(collectText(root!.root)).toContain('本地部署');
|
||||
expect(collectText(root!.root)).toContain('云端部署');
|
||||
expect(collectText(root!.root)).toContain('ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22');
|
||||
|
||||
await act(async () => {
|
||||
@@ -334,10 +339,11 @@ describe('OAuthManagement page', () => {
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
expect(collectText(root!.root)).toContain('提交回调 URL');
|
||||
expect(collectText(root!.root)).toContain('手动回调');
|
||||
});
|
||||
|
||||
const textInput = root!.root.find((node) => (
|
||||
node.type === 'input'
|
||||
node.type === 'textarea'
|
||||
&& node.props.value !== undefined
|
||||
));
|
||||
|
||||
@@ -362,6 +368,7 @@ describe('OAuthManagement page', () => {
|
||||
'oauth-state-456',
|
||||
'http://localhost:54545/callback?code=test-code&state=oauth-state-456',
|
||||
);
|
||||
expect(collectText(root!.root)).toContain('如果浏览器停在 localhost 错误页');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { api, type OAuthConnectionInfo, type OAuthProviderInfo, type OAuthStartInstructions } from '../api.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
@@ -34,6 +34,46 @@ function resolveConnectionStatusLabel(status?: string): string {
|
||||
return status === 'abnormal' ? '异常' : '正常';
|
||||
}
|
||||
|
||||
function renderCodeBlock(value: string) {
|
||||
return (
|
||||
<code style={{
|
||||
display: 'block',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'color-mix(in srgb, var(--color-primary) 6%, var(--color-bg))',
|
||||
border: '1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border))',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGuideCard(title: string, description: string, children?: ReactNode) {
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--color-bg)',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 4 }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OAuthManagement() {
|
||||
const [providers, setProviders] = useState<OAuthProviderInfo[]>([]);
|
||||
const [connections, setConnections] = useState<OAuthConnectionInfo[]>([]);
|
||||
@@ -218,54 +258,89 @@ export default function OAuthManagement() {
|
||||
{activeSession && (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权指引</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', display: 'grid', gap: 10 }}>
|
||||
<div>回调地址固定为 {activeSession.instructions.redirectUri}</div>
|
||||
<div>
|
||||
打开授权页后,如果你在云端部署 metapi,请先在本地运行 SSH 隧道,再继续登录。
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div style={{
|
||||
padding: 12,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'color-mix(in srgb, var(--color-primary) 5%, var(--color-bg-card))',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>固定回调地址</div>
|
||||
{renderCodeBlock(activeSession.instructions.redirectUri)}
|
||||
</div>
|
||||
{activeSession.instructions.sshTunnelCommand && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>SSH 隧道命令</div>
|
||||
<code>{activeSession.instructions.sshTunnelCommand}</code>
|
||||
</div>
|
||||
|
||||
{renderGuideCard(
|
||||
'本地部署',
|
||||
'metapi 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
|
||||
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 metapi。
|
||||
</div>,
|
||||
)}
|
||||
{activeSession.instructions.sshTunnelKeyCommand && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>SSH Key 隧道命令</div>
|
||||
<code>{activeSession.instructions.sshTunnelKeyCommand}</code>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
如果授权完成后浏览器停在 localhost 错误页,复制完整地址,等待 {Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。
|
||||
</div>
|
||||
{manualCallbackVisible ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualCallbackUrl}
|
||||
onChange={(event) => setManualCallbackUrl(event.target.value)}
|
||||
placeholder="粘贴完整的 callback URL"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmitManualCallback}
|
||||
disabled={manualCallbackSubmitting}
|
||||
>
|
||||
{manualCallbackSubmitting ? '提交中...' : '提交回调 URL'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => openOAuthPopup(activeSession.provider, activeSession.authorizationUrl)}
|
||||
>
|
||||
重新打开授权页
|
||||
</button>
|
||||
|
||||
{activeSession.instructions.sshTunnelCommand
|
||||
? renderGuideCard(
|
||||
'云端部署',
|
||||
'metapi 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>常规 SSH 隧道</div>
|
||||
{renderCodeBlock(activeSession.instructions.sshTunnelCommand)}
|
||||
{activeSession.instructions.sshTunnelKeyCommand && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>SSH Key 隧道</div>
|
||||
{renderCodeBlock(activeSession.instructions.sshTunnelKeyCommand)}
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
)
|
||||
: renderGuideCard(
|
||||
'云端部署',
|
||||
'当前没有检测到远程主机地址。如果你实际是云端部署,请用能访问服务器 127.0.0.1 回调端口的 SSH 隧道方式完成授权。',
|
||||
)}
|
||||
|
||||
{renderGuideCard(
|
||||
'手动回调',
|
||||
`如果浏览器停在 localhost 错误页,复制浏览器地址栏里的完整 URL,等待 ${Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。`,
|
||||
manualCallbackVisible ? (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<textarea
|
||||
value={manualCallbackUrl}
|
||||
onChange={(event) => setManualCallbackUrl(event.target.value)}
|
||||
placeholder="粘贴完整的 callback URL,例如 http://localhost:1455/auth/callback?code=..."
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg-card)',
|
||||
color: 'var(--color-text-primary)',
|
||||
resize: 'vertical',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmitManualCallback}
|
||||
disabled={manualCallbackSubmitting}
|
||||
>
|
||||
{manualCallbackSubmitting ? '提交中...' : '提交回调 URL'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => openOAuthPopup(activeSession.provider, activeSession.authorizationUrl)}
|
||||
>
|
||||
重新打开授权页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>手动回调入口将在几秒后可用。</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>手动回调入口将在几秒后可用。</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,6 +392,11 @@ export default function OAuthManagement() {
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
||||
</div>
|
||||
{connection.accountKey && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
连接标识: <span style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', color: 'var(--color-text-primary)' }}>{connection.accountKey}</span>
|
||||
</div>
|
||||
)}
|
||||
{connection.projectId && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
Project: {connection.projectId}
|
||||
|
||||
Reference in New Issue
Block a user