forked from boos/BoosAPI
Merge pull request #139 from cita-777/feat/oauth-loopback-bridge
feat: align oauth loopback flow with cliproxyapi
This commit is contained in:
@@ -101,14 +101,28 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
provider: string;
|
provider: string;
|
||||||
state: string;
|
state: string;
|
||||||
authorizationUrl: string;
|
authorizationUrl: string;
|
||||||
|
instructions?: {
|
||||||
|
redirectUri: string;
|
||||||
|
callbackPort: number;
|
||||||
|
callbackPath: string;
|
||||||
|
manualCallbackDelayMs: number;
|
||||||
|
sshTunnelCommand?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
expect(startBody.provider).toBe('codex');
|
expect(startBody.provider).toBe('codex');
|
||||||
expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/);
|
expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/);
|
||||||
expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?');
|
expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?');
|
||||||
expect(startBody.authorizationUrl).toContain('client_id=');
|
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(`state=${encodeURIComponent(startBody.state)}`);
|
||||||
expect(startBody.authorizationUrl).toContain('code_challenge=');
|
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({
|
const sessionResponse = await app.inject({
|
||||||
method: 'GET',
|
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'));
|
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({
|
const jwt = buildJwt({
|
||||||
email: 'codex-user@example.com',
|
email: 'codex-user@example.com',
|
||||||
'https://api.openai.com/auth': {
|
'https://api.openai.com/auth': {
|
||||||
@@ -187,17 +201,16 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
const startBody = startResponse.json() as { state: string };
|
const startBody = startResponse.json() as { state: string };
|
||||||
|
|
||||||
const callbackResponse = await app.inject({
|
const callbackResponse = await app.inject({
|
||||||
method: 'GET',
|
method: 'POST',
|
||||||
url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`,
|
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
|
||||||
headers: {
|
payload: {
|
||||||
host: 'metapi.example',
|
callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`,
|
||||||
'x-forwarded-proto': 'https',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(callbackResponse.statusCode).toBe(200);
|
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(
|
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({
|
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']);
|
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({
|
const jwt = buildJwt({
|
||||||
email: 'codex-fail@example.com',
|
email: 'codex-fail@example.com',
|
||||||
'https://api.openai.com/auth': {
|
'https://api.openai.com/auth': {
|
||||||
@@ -291,15 +304,16 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
const startBody = startResponse.json() as { state: string };
|
const startBody = startResponse.json() as { state: string };
|
||||||
|
|
||||||
const callbackResponse = await app.inject({
|
const callbackResponse = await app.inject({
|
||||||
method: 'GET',
|
method: 'POST',
|
||||||
url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`,
|
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
|
||||||
headers: {
|
payload: {
|
||||||
host: 'metapi.example',
|
callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`,
|
||||||
'x-forwarded-proto': 'https',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(callbackResponse.statusCode).toBe(200);
|
expect(callbackResponse.statusCode).toBe(500);
|
||||||
expect(callbackResponse.body).toContain('OAuth authorization failed');
|
expect(callbackResponse.json()).toMatchObject({
|
||||||
|
message: expect.stringContaining('HTTP 403'),
|
||||||
|
});
|
||||||
|
|
||||||
const sessionResponse = await app.inject({
|
const sessionResponse = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -412,4 +426,29 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
const accounts = await db.select().from(schema.accounts).all();
|
const accounts = await db.select().from(schema.accounts).all();
|
||||||
expect(accounts).toEqual([]);
|
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'),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
listOauthProviders,
|
listOauthProviders,
|
||||||
startOauthProviderFlow,
|
startOauthProviderFlow,
|
||||||
startOauthRebindFlow,
|
startOauthRebindFlow,
|
||||||
|
submitOauthManualCallback,
|
||||||
} from '../../services/oauth/service.js';
|
} from '../../services/oauth/service.js';
|
||||||
|
|
||||||
const limitOauthProviderRead = createRateLimitGuard({
|
const limitOauthProviderRead = createRateLimitGuard({
|
||||||
@@ -28,6 +29,12 @@ const limitOauthSessionRead = createRateLimitGuard({
|
|||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const limitOauthSessionMutate = createRateLimitGuard({
|
||||||
|
bucket: 'oauth-session-mutate',
|
||||||
|
max: 30,
|
||||||
|
windowMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
const limitOauthConnectionRead = createRateLimitGuard({
|
const limitOauthConnectionRead = createRateLimitGuard({
|
||||||
bucket: 'oauth-connection-read',
|
bucket: 'oauth-connection-read',
|
||||||
max: 60,
|
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 } }>(
|
app.get<{ Querystring: { limit?: string; offset?: string } }>(
|
||||||
'/api/oauth/connections',
|
'/api/oauth/connections',
|
||||||
{ preHandler: [limitOauthConnectionRead] },
|
{ preHandler: [limitOauthConnectionRead] },
|
||||||
|
|||||||
@@ -20,32 +20,6 @@ function requireCodexClientId(): string {
|
|||||||
return CODEX_CLIENT_ID;
|
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 = {
|
type CodexJwtClaims = {
|
||||||
email?: unknown;
|
email?: unknown;
|
||||||
'https://api.openai.com/auth'?: {
|
'https://api.openai.com/auth'?: {
|
||||||
@@ -210,7 +184,6 @@ export const codexOauthProvider: OAuthProviderDefinition = {
|
|||||||
path: CODEX_LOOPBACK_CALLBACK_PATH,
|
path: CODEX_LOOPBACK_CALLBACK_PATH,
|
||||||
redirectUri: CODEX_LOOPBACK_REDIRECT_URI,
|
redirectUri: CODEX_LOOPBACK_REDIRECT_URI,
|
||||||
},
|
},
|
||||||
resolveRedirectUri: ({ requestOrigin }) => resolveCodexRedirectUri(requestOrigin),
|
|
||||||
buildAuthorizationUrl: ({ state, redirectUri, codeVerifier }) => buildCodexAuthorizationUrl({
|
buildAuthorizationUrl: ({ state, redirectUri, codeVerifier }) => buildCodexAuthorizationUrl({
|
||||||
state,
|
state,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
|
|||||||
@@ -18,6 +18,89 @@ import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
|
|||||||
import { buildCodexOauthInfo } from './codexAccount.js';
|
import { buildCodexOauthInfo } from './codexAccount.js';
|
||||||
|
|
||||||
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[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 <path_to_your_key> -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 {
|
function asNonEmptyString(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') return undefined;
|
if (typeof value !== 'string') return undefined;
|
||||||
@@ -209,12 +292,9 @@ export async function startOauthProviderFlow(input: {
|
|||||||
if (!definition) {
|
if (!definition) {
|
||||||
throw new Error(`unsupported oauth provider: ${input.provider}`);
|
throw new Error(`unsupported oauth provider: ${input.provider}`);
|
||||||
}
|
}
|
||||||
const redirectUri = definition.resolveRedirectUri?.({
|
const redirectUri = definition.loopback.redirectUri;
|
||||||
requestOrigin: input.requestOrigin,
|
|
||||||
}) || definition.loopback.redirectUri;
|
|
||||||
const callbackServerState = getOAuthLoopbackCallbackServerState(input.provider);
|
const callbackServerState = getOAuthLoopbackCallbackServerState(input.provider);
|
||||||
const usesLoopbackCallback = redirectUri === definition.loopback.redirectUri;
|
if (callbackServerState.attempted && !callbackServerState.ready) {
|
||||||
if (usesLoopbackCallback && callbackServerState.attempted && !callbackServerState.ready) {
|
|
||||||
throw new Error(`${input.provider} oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
|
throw new Error(`${input.provider} oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
|
||||||
}
|
}
|
||||||
const session = createOauthSession({
|
const session = createOauthSession({
|
||||||
@@ -232,6 +312,7 @@ export async function startOauthProviderFlow(input: {
|
|||||||
codeVerifier: session.codeVerifier,
|
codeVerifier: session.codeVerifier,
|
||||||
projectId: session.projectId,
|
projectId: session.projectId,
|
||||||
}),
|
}),
|
||||||
|
instructions: buildLoopbackInstructions(definition, input.requestOrigin),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +392,32 @@ export async function handleOauthCallback(input: {
|
|||||||
return { accountId: account.id, siteId: site.id };
|
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: {
|
export async function listOauthConnections(options: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
|||||||
+22
-2
@@ -258,6 +258,22 @@ export type OAuthProviderInfo = {
|
|||||||
supportsNativeProxy: boolean;
|
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 = {
|
export type OAuthSessionInfo = {
|
||||||
provider: string;
|
provider: string;
|
||||||
state: 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`, {
|
startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data || {}),
|
body: JSON.stringify(data || {}),
|
||||||
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
}) as Promise<OAuthStartResponse>,
|
||||||
getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise<OAuthSessionInfo>,
|
getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise<OAuthSessionInfo>,
|
||||||
|
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 }) =>
|
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
||||||
request(`/api/oauth/connections${buildQueryString(params)}`) as Promise<OAuthConnectionsResponse>,
|
request(`/api/oauth/connections${buildQueryString(params)}`) as Promise<OAuthConnectionsResponse>,
|
||||||
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
}) as Promise<OAuthStartResponse>,
|
||||||
deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, {
|
deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}) as Promise<{ success: true }>,
|
}) as Promise<{ success: true }>,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(()
|
|||||||
getOAuthConnections: vi.fn(),
|
getOAuthConnections: vi.fn(),
|
||||||
startOAuthProvider: vi.fn(),
|
startOAuthProvider: vi.fn(),
|
||||||
getOAuthSession: vi.fn(),
|
getOAuthSession: vi.fn(),
|
||||||
|
submitOAuthManualCallback: vi.fn(),
|
||||||
rebindOAuthConnection: vi.fn(),
|
rebindOAuthConnection: vi.fn(),
|
||||||
deleteOAuthConnection: vi.fn(),
|
deleteOAuthConnection: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -180,6 +181,13 @@ describe('OAuthManagement page', () => {
|
|||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
state: 'oauth-state-123',
|
state: 'oauth-state-123',
|
||||||
authorizationUrl: 'https://auth.openai.com/oauth/authorize?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
|
apiMock.getOAuthSession
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -228,6 +236,7 @@ describe('OAuthManagement page', () => {
|
|||||||
'oauth-codex',
|
'oauth-codex',
|
||||||
expect.stringContaining('width=540'),
|
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 () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(1600);
|
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<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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 () => {
|
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
||||||
apiMock.getOAuthProviders.mockResolvedValue({
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 POLL_INTERVAL_MS = 1500;
|
||||||
const CONNECTION_PAGE_LIMIT = 100;
|
const CONNECTION_PAGE_LIMIT = 100;
|
||||||
@@ -7,6 +7,8 @@ const CONNECTION_PAGE_LIMIT = 100;
|
|||||||
type ActiveSession = {
|
type ActiveSession = {
|
||||||
provider: string;
|
provider: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
authorizationUrl: string;
|
||||||
|
instructions: OAuthStartInstructions;
|
||||||
};
|
};
|
||||||
|
|
||||||
function openOAuthPopup(provider: string, authorizationUrl: string) {
|
function openOAuthPopup(provider: string, authorizationUrl: string) {
|
||||||
@@ -39,6 +41,9 @@ export default function OAuthManagement() {
|
|||||||
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
||||||
const [sessionMessage, setSessionMessage] = useState('');
|
const [sessionMessage, setSessionMessage] = useState('');
|
||||||
const [actionLoadingKey, setActionLoadingKey] = useState('');
|
const [actionLoadingKey, setActionLoadingKey] = useState('');
|
||||||
|
const [manualCallbackVisible, setManualCallbackVisible] = useState(false);
|
||||||
|
const [manualCallbackUrl, setManualCallbackUrl] = useState('');
|
||||||
|
const [manualCallbackSubmitting, setManualCallbackSubmitting] = useState(false);
|
||||||
|
|
||||||
const loadConnections = async () => {
|
const loadConnections = async () => {
|
||||||
const response = await api.getOAuthConnections({
|
const response = await api.getOAuthConnections({
|
||||||
@@ -107,6 +112,25 @@ export default function OAuthManagement() {
|
|||||||
};
|
};
|
||||||
}, [activeSession]);
|
}, [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 handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
|
||||||
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||||||
setActionLoadingKey(actionKey);
|
setActionLoadingKey(actionKey);
|
||||||
@@ -129,6 +153,8 @@ export default function OAuthManagement() {
|
|||||||
setActiveSession({
|
setActiveSession({
|
||||||
provider: started.provider,
|
provider: started.provider,
|
||||||
state: started.state,
|
state: started.state,
|
||||||
|
authorizationUrl: started.authorizationUrl,
|
||||||
|
instructions: started.instructions,
|
||||||
});
|
});
|
||||||
openOAuthPopup(provider.provider, started.authorizationUrl);
|
openOAuthPopup(provider.provider, started.authorizationUrl);
|
||||||
} catch (error: any) {
|
} 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) => {
|
const handleDelete = async (accountId: number) => {
|
||||||
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
||||||
const confirmed = window.confirm('确定要删除这个 OAuth 连接吗?');
|
const confirmed = window.confirm('确定要删除这个 OAuth 连接吗?');
|
||||||
@@ -171,6 +215,62 @@ export default function OAuthManagement() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
{activeSession.instructions.sshTunnelCommand && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 4 }}>SSH 隧道命令</div>
|
||||||
|
<code>{activeSession.instructions.sshTunnelCommand}</code>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>手动回调入口将在几秒后可用。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<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={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user