1
0
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:
Cita
2026-03-17 19:11:36 +08:00
committed by GitHub
4 changed files with 243 additions and 48 deletions
+108
View File
@@ -1,4 +1,5 @@
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs'; import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
@@ -427,6 +428,113 @@ describe('oauth routes', { timeout: 15_000 }, () => {
expect(accounts).toEqual([]); 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 () => { it('rejects malformed manual callback submissions', async () => {
const startResponse = await app.inject({ const startResponse = await app.inject({
method: 'POST', method: 'POST',
+1 -1
View File
@@ -177,7 +177,7 @@ async function findExistingOauthAccount(input: {
if (byKey) return byKey; if (byKey) return byKey;
} }
if (email) { if (!accountKey && email) {
const byEmail = await db.select().from(schema.accounts).where(and( const byEmail = await db.select().from(schema.accounts).where(and(
eq(schema.accounts.oauthProvider, input.provider), eq(schema.accounts.oauthProvider, input.provider),
eq(schema.accounts.username, email), eq(schema.accounts.username, email),
+8 -1
View File
@@ -95,6 +95,7 @@ describe('OAuthManagement page', () => {
accountId: 7, accountId: 7,
provider: 'codex', provider: 'codex',
email: 'codex-user@example.com', email: 'codex-user@example.com',
accountKey: 'chatgpt-account-123',
planType: 'plus', planType: 'plus',
modelCount: 3, modelCount: 3,
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'], 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('codex-user@example.com');
expect(text).toContain('plus'); expect(text).toContain('plus');
expect(text).toContain('3 个模型'); expect(text).toContain('3 个模型');
expect(text).toContain('chatgpt-account-123');
}); });
} finally { } finally {
root?.unmount(); root?.unmount();
@@ -167,6 +169,7 @@ describe('OAuthManagement page', () => {
accountId: 7, accountId: 7,
provider: 'codex', provider: 'codex',
email: 'codex-user@example.com', email: 'codex-user@example.com',
accountKey: 'chatgpt-account-123',
planType: 'plus', planType: 'plus',
modelCount: 3, modelCount: 3,
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'], modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
@@ -236,6 +239,8 @@ describe('OAuthManagement page', () => {
'oauth-codex', 'oauth-codex',
expect.stringContaining('width=540'), 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'); expect(collectText(root!.root)).toContain('ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22');
await act(async () => { await act(async () => {
@@ -334,10 +339,11 @@ describe('OAuthManagement page', () => {
await vi.waitFor(async () => { await vi.waitFor(async () => {
await flushMicrotasks(); await flushMicrotasks();
expect(collectText(root!.root)).toContain('提交回调 URL'); expect(collectText(root!.root)).toContain('提交回调 URL');
expect(collectText(root!.root)).toContain('手动回调');
}); });
const textInput = root!.root.find((node) => ( const textInput = root!.root.find((node) => (
node.type === 'input' node.type === 'textarea'
&& node.props.value !== undefined && node.props.value !== undefined
)); ));
@@ -362,6 +368,7 @@ describe('OAuthManagement page', () => {
'oauth-state-456', 'oauth-state-456',
'http://localhost:54545/callback?code=test-code&state=oauth-state-456', 'http://localhost:54545/callback?code=test-code&state=oauth-state-456',
); );
expect(collectText(root!.root)).toContain('如果浏览器停在 localhost 错误页');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
+105 -25
View File
@@ -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'; import { api, type OAuthConnectionInfo, type OAuthProviderInfo, type OAuthStartInstructions } from '../api.js';
const POLL_INTERVAL_MS = 1500; const POLL_INTERVAL_MS = 1500;
@@ -34,6 +34,46 @@ function resolveConnectionStatusLabel(status?: string): string {
return status === 'abnormal' ? '异常' : '正常'; 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() { export default function OAuthManagement() {
const [providers, setProviders] = useState<OAuthProviderInfo[]>([]); const [providers, setProviders] = useState<OAuthProviderInfo[]>([]);
const [connections, setConnections] = useState<OAuthConnectionInfo[]>([]); const [connections, setConnections] = useState<OAuthConnectionInfo[]>([]);
@@ -218,35 +258,69 @@ export default function OAuthManagement() {
{activeSession && ( {activeSession && (
<div className="card" style={{ padding: 20, marginBottom: 16 }}> <div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权指引</div> <div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权指引</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', display: 'grid', gap: 10 }}> <div style={{ display: 'grid', gap: 12 }}>
<div>回调地址固定为 {activeSession.instructions.redirectUri}</div> <div style={{
<div> padding: 12,
打开授权页后,如果你在云端部署 metapi,请先在本地运行 SSH 隧道,再继续登录。 borderRadius: 'var(--radius-md)',
</div> border: '1px solid var(--color-border)',
{activeSession.instructions.sshTunnelCommand && ( background: 'color-mix(in srgb, var(--color-primary) 5%, var(--color-bg-card))',
<div> }}
<div style={{ marginBottom: 4 }}>SSH 隧道命令</div> >
<code>{activeSession.instructions.sshTunnelCommand}</code> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>固定回调地址</div>
{renderCodeBlock(activeSession.instructions.redirectUri)}
</div> </div>
{renderGuideCard(
'本地部署',
'metapi 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 metapi。
</div>,
)} )}
{activeSession.instructions.sshTunnelKeyCommand && (
<div> {activeSession.instructions.sshTunnelCommand
<div style={{ marginBottom: 4 }}>SSH Key 隧道命令</div> ? renderGuideCard(
<code>{activeSession.instructions.sshTunnelKeyCommand}</code> '云端部署',
</div> 'metapi 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
)}
<div>
如果授权完成后浏览器停在 localhost 错误页,复制完整地址,等待 {Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。
</div>
{manualCallbackVisible ? (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
<input <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>常规 SSH 隧道</div>
type="text" {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} value={manualCallbackUrl}
onChange={(event) => setManualCallbackUrl(event.target.value)} onChange={(event) => setManualCallbackUrl(event.target.value)}
placeholder="粘贴完整的 callback URL" 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 }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
@@ -265,7 +339,8 @@ export default function OAuthManagement() {
</div> </div>
</div> </div>
) : ( ) : (
<div>手动回调入口将在几秒后可用。</div> <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>手动回调入口将在几秒后可用。</div>
),
)} )}
</div> </div>
</div> </div>
@@ -317,6 +392,11 @@ export default function OAuthManagement() {
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{connection.planType || 'unknown'} · {connection.modelCount} 个模型 {connection.planType || 'unknown'} · {connection.modelCount} 个模型
</div> </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 && ( {connection.projectId && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
Project: {connection.projectId} Project: {connection.projectId}