Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions server/src/routes/agent-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { requireAuth } from '../middleware/auth.js';
import { AgentContextDatabase } from '../db/agent-context-db.js';
import { getWorkos } from '../auth/workos-client.js';
import { createWebOAuthAdapters, AgentOAuthPendingFlowStore } from './helpers/web-oauth-stores.js';
import { oauthSafeFetch } from '../utils/oauth-safe-fetch.js';
import { validateExternalUrl } from '../utils/url-security.js';

const logger = createLogger('agent-oauth');
Expand Down Expand Up @@ -72,7 +73,11 @@ async function durableOAuthScopeHintForAgent(agentUrl: string): Promise<string |
let resourceScopes: readonly string[] | undefined;
let authorizationServerUrl: string | undefined;
try {
const metadata = await discoverOAuthProtectedResourceMetadata(agentUrl);
const metadata = await discoverOAuthProtectedResourceMetadata(
agentUrl,
undefined,
oauthSafeFetch,
);
if (
metadata.resource &&
!checkResourceAllowed({
Expand All @@ -94,7 +99,10 @@ async function durableOAuthScopeHintForAgent(agentUrl: string): Promise<string |
if (authorizationServerUrl && !validateExternalUrl(authorizationServerUrl)) {
return undefined;
}
const asMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl ?? agentUrl);
const asMetadata = await discoverAuthorizationServerMetadata(
authorizationServerUrl ?? agentUrl,
{ fetchFn: oauthSafeFetch },
);
const supportedScopes = asMetadata?.scopes_supported;
const allowOfflineAccess = !supportedScopes || supportedScopes.includes(OFFLINE_ACCESS_SCOPE);
return buildDurableOAuthScopeHint(resourceScopes, allowOfflineAccess);
Expand Down Expand Up @@ -301,6 +309,7 @@ export function createAgentOAuthRouter(): Router {
redirectUri,
pendingFlowStore,
agentStorage,
fetch: oauthSafeFetch,
carry,
...(scopeHint && { scopeHint }),
...(scopeHint && { clientMetadata: { scope: scopeHint } }),
Expand Down Expand Up @@ -405,6 +414,7 @@ export function createAgentOAuthRouter(): Router {
code,
pendingFlowStore: guardedPendingFlowStore,
agentStorage,
fetch: oauthSafeFetch,
expectedState,
});

Expand Down Expand Up @@ -519,7 +529,9 @@ export function createAgentOAuthRouter(): Router {
}
const agentContext = access.agentContext;

const metadata = await discoverOAuthMetadata(agentContext.agent_url);
const metadata = await discoverOAuthMetadata(agentContext.agent_url, {
fetch: oauthSafeFetch,
});
const agentSupportsOAuth = !!metadata;
const hasValidTokens = agentContextDb.hasValidOAuthTokens(agentContext);

Expand Down
3 changes: 2 additions & 1 deletion server/src/services/oauth-client-credentials-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import type { OAuthClientCredentials } from '../db/agent-context-db.js';
import { createLogger } from '../logger.js';
import { oauthSafeFetch } from '../utils/oauth-safe-fetch.js';

const logger = createLogger('oauth-client-credentials-exchange');

Expand Down Expand Up @@ -100,7 +101,7 @@ export async function exchangeClientCredentials(
headers['Authorization'] = `Basic ${encoded}`;
}

const fetchImpl = options.fetchImpl ?? fetch;
const fetchImpl = options.fetchImpl ?? oauthSafeFetch;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), EXCHANGE_TIMEOUT_MS);

Expand Down
48 changes: 48 additions & 0 deletions server/src/utils/oauth-safe-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { safeFetch } from './url-security.js';

/**
* Fetch adapter for server-side OAuth requests. OAuth discovery, dynamic
* registration, and token exchange only use GET and POST requests, so keep
* this surface deliberately narrow while routing every request and redirect
* hop through the SSRF-safe fetcher.
*/
export const oauthSafeFetch: typeof fetch = async (input, init) => {
if (typeof input !== 'string' && !(input instanceof URL)) {
throw new Error('OAuth safe fetch only supports string and URL inputs');
}

const method = (init?.method ?? 'GET').toUpperCase();
if (method !== 'GET' && method !== 'POST') {
throw new Error(`OAuth safe fetch does not support ${method} requests`);
}

const headers = Object.fromEntries(new Headers(init?.headers).entries());
const rawBody = init?.body;
let body: string | Uint8Array | undefined;

if (rawBody === undefined || rawBody === null) {
body = undefined;
} else if (typeof rawBody === 'string' || rawBody instanceof Uint8Array) {
body = rawBody;
} else if (rawBody instanceof URLSearchParams) {
body = rawBody.toString();
} else {
throw new Error(
'OAuth safe fetch only supports string, Uint8Array, and URLSearchParams request bodies',
);
}

if (method === 'GET' && body !== undefined) {
throw new Error('OAuth safe fetch GET requests cannot carry a body');
}
if (method === 'POST' && body === undefined) {
throw new Error('OAuth safe fetch POST requests require a body');
}

return safeFetch(input.toString(), {
method,
headers,
...(body !== undefined && { body }),
...(init?.signal && { signal: init.signal }),
});
};
68 changes: 64 additions & 4 deletions server/tests/unit/agent-oauth-membership-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const agentContextDbMocks = vi.hoisted(() => ({
getOAuthClient: vi.fn(),
clearOAuthClient: vi.fn(),
removeOAuthTokens: vi.fn(),
hasValidOAuthTokens: vi.fn(),
},
}));
const adapterMocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -106,14 +107,21 @@ import {
createAgentOAuthRouter,
createMembershipGuardedPendingFlowStore,
} from '../../src/routes/agent-oauth.js';
import { oauthSafeFetch } from '../../src/utils/oauth-safe-fetch.js';

const TEST_USER_ID = 'user_123';
const TEST_ORG_ID = 'org_123';
const AGENT_CONTEXT_ID = '11111111-1111-4111-8111-111111111111';
const TEST_AGENT_URL = 'https://agent.example.com/mcp';

function makeApp() {
function makeApp(stateCookie?: string) {
const app = express();
if (stateCookie) {
app.use((req, _res, next) => {
req.cookies = { adcp_oauth_state: stateCookie };
next();
});
}
app.use('/api/oauth/agent', createAgentOAuthRouter());
return app;
}
Expand All @@ -130,6 +138,7 @@ beforeEach(() => {
agentContextDbMocks.instance.getOAuthClient.mockReset();
agentContextDbMocks.instance.clearOAuthClient.mockReset();
agentContextDbMocks.instance.removeOAuthTokens.mockReset();
agentContextDbMocks.instance.hasValidOAuthTokens.mockReset();
adapterMocks.pendingFlowStore.put.mockReset();
adapterMocks.pendingFlowStore.consume.mockReset();
adapterMocks.agentStorage.loadAgent.mockReset();
Expand All @@ -144,6 +153,7 @@ beforeEach(() => {
agent_url: TEST_AGENT_URL,
});
agentContextDbMocks.instance.getOAuthClient.mockResolvedValue(null);
agentContextDbMocks.instance.hasValidOAuthTokens.mockReturnValue(false);
adapterMocks.createWebOAuthAdapters.mockReturnValue({
pendingFlowStore: adapterMocks.pendingFlowStore,
agentStorage: adapterMocks.agentStorage,
Expand Down Expand Up @@ -242,10 +252,18 @@ describe('GET /api/oauth/agent/start durable scope hint', () => {

expect(res.status).toBe(302);
expect(res.headers.location).toBe('https://auth.example.com/authorize');
expect(mcpAuthMocks.discoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(TEST_AGENT_URL);
expect(mcpAuthMocks.discoverAuthorizationServerMetadata).toHaveBeenCalledWith('https://auth.example.com');
expect(mcpAuthMocks.discoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
TEST_AGENT_URL,
undefined,
oauthSafeFetch,
);
expect(mcpAuthMocks.discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
'https://auth.example.com',
{ fetchFn: oauthSafeFetch },
);
expect(sdkMocks.startWebOAuthFlow).toHaveBeenCalledWith(expect.objectContaining({
agent: expect.objectContaining({ id: AGENT_CONTEXT_ID, agent_uri: TEST_AGENT_URL }),
fetch: oauthSafeFetch,
scopeHint: 'openid profile offline_access',
clientMetadata: { scope: 'openid profile offline_access' },
carry: expect.objectContaining({
Expand Down Expand Up @@ -289,7 +307,10 @@ describe('GET /api/oauth/agent/start durable scope hint', () => {
.query({ agent_context_id: AGENT_CONTEXT_ID })
.expect(302);

expect(mcpAuthMocks.discoverAuthorizationServerMetadata).toHaveBeenCalledWith(TEST_AGENT_URL);
expect(mcpAuthMocks.discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
TEST_AGENT_URL,
{ fetchFn: oauthSafeFetch },
);
expect(sdkMocks.startWebOAuthFlow).toHaveBeenCalledWith(expect.objectContaining({
scopeHint: 'offline_access',
clientMetadata: { scope: 'offline_access' },
Expand All @@ -315,6 +336,45 @@ describe('GET /api/oauth/agent/start durable scope hint', () => {
});
});

describe('agent OAuth safe fetch injection', () => {
it('passes the scoped fetcher to callback token exchange', async () => {
sdkMocks.completeWebOAuthFlow.mockResolvedValueOnce({
agentId: AGENT_CONTEXT_ID,
agentUrl: TEST_AGENT_URL,
tokens: { access_token: 'token', token_type: 'bearer' },
carry: { organization_id: TEST_ORG_ID },
persisted: true,
});

await request(makeApp('state_123'))
.get('/api/oauth/agent/callback')
.query({ code: 'code_123', state: 'state_123' })
.expect(302);

expect(sdkMocks.completeWebOAuthFlow).toHaveBeenCalledWith(expect.objectContaining({
state: 'state_123',
code: 'code_123',
expectedState: 'state_123',
fetch: oauthSafeFetch,
}));
});

it('passes the scoped fetcher to status discovery', async () => {
sdkMocks.discoverOAuthMetadata.mockResolvedValueOnce({
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
});

await request(makeApp())
.get(`/api/oauth/agent/${AGENT_CONTEXT_ID}/status`)
.expect(200);

expect(sdkMocks.discoverOAuthMetadata).toHaveBeenCalledWith(TEST_AGENT_URL, {
fetch: oauthSafeFetch,
});
});
});

describe('buildDurableOAuthScopeHint', () => {
it('preserves advertised scopes and appends offline_access', () => {
expect(buildDurableOAuthScopeHint(['openid', 'profile', 'email'])).toBe('openid profile email offline_access');
Expand Down
72 changes: 52 additions & 20 deletions server/tests/unit/oauth-client-credentials-exchange.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

const oauthFetchMocks = vi.hoisted(() => ({
oauthSafeFetch: vi.fn(),
}));

vi.mock('../../src/utils/oauth-safe-fetch.js', () => ({
oauthSafeFetch: oauthFetchMocks.oauthSafeFetch,
}));

import {
exchangeClientCredentials,
resolveEnvReference,
Expand Down Expand Up @@ -35,6 +44,10 @@ function mockFetch(response: { status: number; body: string | object }) {
});
}

beforeEach(() => {
oauthFetchMocks.oauthSafeFetch.mockReset();
});

describe('resolveEnvReference', () => {
beforeEach(() => {
delete process.env.ADCP_OAUTH_TEST;
Expand Down Expand Up @@ -78,6 +91,30 @@ describe('exchangeClientCredentials', () => {
client_secret: 'secret-xyz',
};

it('uses the SSRF-safe OAuth fetcher by default and preserves the token request', async () => {
oauthFetchMocks.oauthSafeFetch.mockImplementation(mockFetch({
status: 200,
body: { access_token: 'safe-token', expires_in: 900 },
}));

const result = await exchangeClientCredentials(baseCreds);

expect(result).toEqual({ ok: true, access_token: 'safe-token', expires_in: 900 });
expect(oauthFetchMocks.oauthSafeFetch).toHaveBeenCalledWith(
baseCreds.token_endpoint,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
Authorization: expect.stringMatching(/^Basic /),
},
body: 'grant_type=client_credentials',
signal: expect.any(AbortSignal),
},
);
});

it('returns a bearer token from a successful exchange', async () => {
const fetchImpl = mockFetch({
status: 200,
Expand Down Expand Up @@ -203,12 +240,10 @@ describe('adaptAuthForSdk', () => {

it('exchanges oauth_client_credentials and narrows to bearer', async () => {
process.env.ADCP_OAUTH_BRIDGE_TEST = 'sec';
// Swap global fetch for this test only
const original = globalThis.fetch;
globalThis.fetch = mockFetch({
oauthFetchMocks.oauthSafeFetch.mockImplementation(mockFetch({
status: 200,
body: { access_token: 'exchanged-tok', expires_in: 600 },
}) as unknown as typeof fetch;
}));
try {
const result = await adaptAuthForSdk({
type: 'oauth_client_credentials',
Expand All @@ -220,26 +255,23 @@ describe('adaptAuthForSdk', () => {
});
expect(result).toEqual({ type: 'bearer', token: 'exchanged-tok' });
} finally {
globalThis.fetch = original;
delete process.env.ADCP_OAUTH_BRIDGE_TEST;
}
});

it('returns undefined (falls back to unauthenticated) when the exchange fails', async () => {
const original = globalThis.fetch;
globalThis.fetch = mockFetch({ status: 500, body: 'internal' }) as unknown as typeof fetch;
try {
const result = await adaptAuthForSdk({
type: 'oauth_client_credentials',
credentials: {
token_endpoint: 'https://idp.example/token',
client_id: 'c',
client_secret: 's',
},
});
expect(result).toBeUndefined();
} finally {
globalThis.fetch = original;
}
oauthFetchMocks.oauthSafeFetch.mockImplementation(
mockFetch({ status: 500, body: 'internal' }),
);

const result = await adaptAuthForSdk({
type: 'oauth_client_credentials',
credentials: {
token_endpoint: 'https://idp.example/token',
client_id: 'c',
client_secret: 's',
},
});
expect(result).toBeUndefined();
});
});
Loading
Loading