Skip to content

Commit e682565

Browse files
authored
Merge pull request #277 from Rabithua/codex/oauth-client-id-negotiation
[codex] support OAuth client id negotiation
2 parents 8773588 + 8bbdb91 commit e682565

9 files changed

Lines changed: 424 additions & 1 deletion

File tree

server/oauth/clientMetadata.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { findOAuthClient, upsertOAuthClient } from '../utils/dbMethods';
2+
import { assertValidRedirectUri, isPrivateUseRedirectUri } from './utils';
3+
import { normalizeClientScopes } from './flow';
4+
import messages from './messages.json';
5+
6+
const CLIENT_METADATA_MAX_BYTES = 64 * 1024;
7+
const CLIENT_METADATA_FETCH_TIMEOUT_MS = 5000;
8+
9+
type OAuthClientMetadata = {
10+
client_id: string;
11+
client_name: string;
12+
redirect_uris: string[];
13+
client_uri?: string;
14+
logo_uri?: string;
15+
grant_types?: string[];
16+
response_types?: string[];
17+
scope?: string;
18+
token_endpoint_auth_method?: string;
19+
};
20+
21+
function oauthClientMetadataError(message: string): never {
22+
throw new Error(messages.codes.clientMetadataInvalidPrefix + message);
23+
}
24+
25+
function isRecord(value: unknown): value is Record<string, unknown> {
26+
return !!value && typeof value === 'object' && !Array.isArray(value);
27+
}
28+
29+
function assertStringArray(value: unknown, field: string): string[] {
30+
if (
31+
!Array.isArray(value) ||
32+
value.length === 0 ||
33+
!value.every((item) => typeof item === 'string')
34+
) {
35+
oauthClientMetadataError(field);
36+
}
37+
return value;
38+
}
39+
40+
function assertOptionalString(value: unknown, field: string): string | null {
41+
if (value === undefined || value === null) return null;
42+
if (typeof value !== 'string') oauthClientMetadataError(field);
43+
return value;
44+
}
45+
46+
function assertClientMetadataDocumentUrl(clientId: string): URL {
47+
let parsed: URL;
48+
try {
49+
parsed = new URL(clientId);
50+
} catch {
51+
oauthClientMetadataError('client_id_url');
52+
}
53+
54+
if (parsed.protocol !== 'https:') oauthClientMetadataError('client_id_scheme');
55+
if (parsed.username || parsed.password) oauthClientMetadataError('client_id_userinfo');
56+
if (parsed.hash) oauthClientMetadataError('client_id_fragment');
57+
if (!parsed.pathname || parsed.pathname === '/') oauthClientMetadataError('client_id_path');
58+
59+
const rawPath = clientId.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, '').split(/[?#]/)[0];
60+
const hasDotSegment = rawPath.split('/').some((segment) => {
61+
let decoded = segment;
62+
try {
63+
decoded = decodeURIComponent(segment);
64+
} catch {
65+
return true;
66+
}
67+
return decoded === '.' || decoded === '..';
68+
});
69+
if (hasDotSegment) {
70+
oauthClientMetadataError('client_id_path_segments');
71+
}
72+
73+
return parsed;
74+
}
75+
76+
function isNativePublicClientId(clientId: string): boolean {
77+
if (clientId.length > 255) return false;
78+
if (clientId.includes('://')) return false;
79+
const segments = clientId.split('.');
80+
if (segments.length < 3) return false;
81+
return segments.every((segment) => /^[A-Za-z][A-Za-z0-9-]{0,62}$/.test(segment));
82+
}
83+
84+
function nativeClientName(clientId: string): string {
85+
const segments = clientId.split('.');
86+
return segments[segments.length - 1] || clientId;
87+
}
88+
89+
function validateClientMetadata(clientId: string, raw: unknown): OAuthClientMetadata {
90+
if (!isRecord(raw)) oauthClientMetadataError('document');
91+
if (raw.client_id !== clientId) oauthClientMetadataError('client_id_mismatch');
92+
if (typeof raw.client_name !== 'string' || raw.client_name.trim().length === 0) {
93+
oauthClientMetadataError('client_name');
94+
}
95+
96+
const redirectUris = assertStringArray(raw.redirect_uris, 'redirect_uris');
97+
redirectUris.forEach((uri) => assertValidRedirectUri(uri));
98+
99+
if (
100+
raw.grant_types !== undefined &&
101+
!assertStringArray(raw.grant_types, 'grant_types').includes('authorization_code')
102+
) {
103+
oauthClientMetadataError('grant_types');
104+
}
105+
if (
106+
raw.response_types !== undefined &&
107+
!assertStringArray(raw.response_types, 'response_types').includes('code')
108+
) {
109+
oauthClientMetadataError('response_types');
110+
}
111+
if (raw.token_endpoint_auth_method !== undefined && raw.token_endpoint_auth_method !== 'none') {
112+
oauthClientMetadataError('token_endpoint_auth_method');
113+
}
114+
if (raw.scope !== undefined && typeof raw.scope !== 'string') {
115+
oauthClientMetadataError('scope');
116+
}
117+
118+
return {
119+
client_id: clientId,
120+
client_name: raw.client_name.trim(),
121+
redirect_uris: redirectUris,
122+
client_uri: assertOptionalString(raw.client_uri, 'client_uri') || undefined,
123+
logo_uri: assertOptionalString(raw.logo_uri, 'logo_uri') || undefined,
124+
grant_types: Array.isArray(raw.grant_types) ? (raw.grant_types as string[]) : undefined,
125+
response_types: Array.isArray(raw.response_types)
126+
? (raw.response_types as string[])
127+
: undefined,
128+
scope: typeof raw.scope === 'string' ? raw.scope : undefined,
129+
token_endpoint_auth_method:
130+
typeof raw.token_endpoint_auth_method === 'string'
131+
? raw.token_endpoint_auth_method
132+
: undefined,
133+
};
134+
}
135+
136+
async function readClientMetadataJson(response: Response): Promise<unknown> {
137+
if (!response.ok) oauthClientMetadataError('fetch_status');
138+
const contentLength = response.headers.get('content-length');
139+
if (contentLength && Number(contentLength) > CLIENT_METADATA_MAX_BYTES) {
140+
oauthClientMetadataError('document_too_large');
141+
}
142+
143+
const bytes = await response.arrayBuffer();
144+
if (bytes.byteLength > CLIENT_METADATA_MAX_BYTES) {
145+
oauthClientMetadataError('document_too_large');
146+
}
147+
148+
try {
149+
return JSON.parse(new TextDecoder().decode(bytes));
150+
} catch {
151+
oauthClientMetadataError('json');
152+
}
153+
}
154+
155+
export function isClientMetadataDocumentClientId(clientId: string): boolean {
156+
try {
157+
assertClientMetadataDocumentUrl(clientId);
158+
return true;
159+
} catch {
160+
return false;
161+
}
162+
}
163+
164+
export async function fetchOAuthClientMetadataDocument(clientId: string) {
165+
assertClientMetadataDocumentUrl(clientId);
166+
const response = await fetch(clientId, {
167+
headers: { accept: 'application/json' },
168+
signal: AbortSignal.timeout(CLIENT_METADATA_FETCH_TIMEOUT_MS),
169+
});
170+
return validateClientMetadata(clientId, await readClientMetadataJson(response));
171+
}
172+
173+
export async function resolveOAuthClient(clientId: string) {
174+
const existing = await findOAuthClient(clientId);
175+
if (existing) return existing;
176+
if (!isClientMetadataDocumentClientId(clientId)) return null;
177+
178+
const metadata = await fetchOAuthClientMetadataDocument(clientId);
179+
return await upsertOAuthClient({
180+
clientId: metadata.client_id,
181+
clientName: metadata.client_name,
182+
redirectUris: metadata.redirect_uris,
183+
scopes: normalizeClientScopes(metadata.scope),
184+
clientUri: metadata.client_uri || null,
185+
logoUri: metadata.logo_uri || null,
186+
});
187+
}
188+
189+
export async function resolveClientInitiatedOAuthClient(
190+
clientId: string,
191+
options: { redirectUri?: string } = {}
192+
) {
193+
const existing = await resolveOAuthClient(clientId);
194+
if (existing) return existing;
195+
196+
if (
197+
!options.redirectUri ||
198+
!isNativePublicClientId(clientId) ||
199+
!isPrivateUseRedirectUri(options.redirectUri)
200+
) {
201+
return null;
202+
}
203+
204+
assertValidRedirectUri(options.redirectUri);
205+
return await upsertOAuthClient({
206+
clientId,
207+
clientName: nativeClientName(clientId),
208+
redirectUris: [options.redirectUri],
209+
scopes: normalizeClientScopes(undefined),
210+
clientUri: null,
211+
logoUri: null,
212+
});
213+
}
214+
215+
export const clientMetadataTestExports = {
216+
validateClientMetadata,
217+
assertClientMetadataDocumentUrl,
218+
isNativePublicClientId,
219+
};

server/oauth/messages.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"codes": {
4141
"decisionRequired": "oauth_decision_required",
4242
"jwtNotConfigured": "jwt_not_configured",
43+
"clientMetadataInvalidPrefix": "client_metadata_invalid:",
4344
"requestIdRequired": "request_id_required",
4445
"redirectUriFragmentForbidden": "redirect_uri_fragment_forbidden",
4546
"redirectUriInvalid": "redirect_uri_invalid",

server/oauth/utils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,40 @@ export function assertValidRedirectUri(value: string): void {
7979
return;
8080
}
8181

82+
if (isPrivateUseRedirectUri(value)) {
83+
return;
84+
}
85+
8286
throw new Error(messages.codes.redirectUriSchemeForbidden);
8387
}
8488

89+
export function isPrivateUseRedirectUri(value: string): boolean {
90+
let parsed: URL;
91+
try {
92+
parsed = new URL(value);
93+
} catch {
94+
return false;
95+
}
96+
97+
if (parsed.hash) return false;
98+
const scheme = parsed.protocol.slice(0, -1).toLowerCase();
99+
const blockedSchemes = new Set([
100+
'about',
101+
'blob',
102+
'data',
103+
'file',
104+
'ftp',
105+
'http',
106+
'https',
107+
'javascript',
108+
'mailto',
109+
]);
110+
if (blockedSchemes.has(scheme)) return false;
111+
if (!/^[a-z][a-z0-9+.-]{2,63}$/.test(scheme)) return false;
112+
113+
return Boolean(parsed.hostname || parsed.pathname);
114+
}
115+
85116
export async function parseFormBody(c: HonoContext): Promise<Record<string, string>> {
86117
const contentType = c.req.header('content-type') || '';
87118
if (contentType.includes('application/x-www-form-urlencoded')) {

server/route/oauthMetadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ oauthMetadataRouter.get('/oauth-authorization-server', (c: HonoContext) =>
3535
grant_types_supported: ['authorization_code', 'refresh_token'],
3636
code_challenge_methods_supported: ['S256'],
3737
token_endpoint_auth_methods_supported: ['none'],
38+
client_id_metadata_document_supported: true,
3839
scopes_supported: OAUTH_MCP_SCOPES,
3940
resource_documentation: getMcpResource(c),
4041
})

server/route/v2/mcpOAuth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { randomUUID } from 'crypto';
22
import { Hono } from 'hono';
33
import mainJson from '../../json/main.json';
4+
import { resolveClientInitiatedOAuthClient } from '../../oauth/clientMetadata';
45
import messages from '../../oauth/messages.json';
56
import {
67
consumeOAuthAuthorizationCode,
@@ -108,7 +109,7 @@ oauthRouter.get('/authorize', (c: HonoContext) =>
108109
if (codeChallengeMethod !== 'S256')
109110
oauthError(messages.errors.invalidRequest, messages.onlyPkceS256);
110111

111-
const client = await findOAuthClient(clientId);
112+
const client = await resolveClientInitiatedOAuthClient(clientId, { redirectUri });
112113
if (!client) oauthError(messages.errors.invalidClient, messages.clientNotFound);
113114
if (!client.redirectUris.includes(redirectUri))
114115
oauthError(messages.errors.invalidRequest, messages.redirectUriMismatch);

server/tests/oauth-mcp.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import {
99
} from './oauthMcp/common.test';
1010
import { authorizeAndExchange } from './oauthMcp/flow.test';
1111
import {
12+
testClientInitiatedNativeClientFlow,
1213
testAuthorizeValidation,
1314
testDenyFlow,
1415
testMetadata,
1516
} from './oauthMcp/metadataAuthorization.test';
17+
import { testClientMetadataDocumentSupport } from './oauthMcp/clientMetadata.test';
1618
import { testInsufficientScope, testMcpTools, testProtocol } from './oauthMcp/protocolTools.test';
1719
import {
1820
testPkceReuseAndAudience,
@@ -25,6 +27,8 @@ async function main() {
2527
await ensureInitialized();
2628
const appAccessToken = await loginOrRegister();
2729
await testMetadata();
30+
await testClientMetadataDocumentSupport();
31+
await testClientInitiatedNativeClientFlow(appAccessToken);
2832
const client = await registerClient();
2933
const codeChallenge = await sha256Base64Url(randomToken(48));
3034
await testAuthorizeValidation(client.client_id, codeChallenge);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {
2+
clientMetadataTestExports,
3+
fetchOAuthClientMetadataDocument,
4+
} from '../../oauth/clientMetadata';
5+
import { assertValidRedirectUri } from '../../oauth/utils';
6+
import { assert } from './common.test';
7+
8+
const { assertClientMetadataDocumentUrl, isNativePublicClientId } = clientMetadataTestExports;
9+
10+
export async function testClientMetadataDocumentSupport() {
11+
const clientId = 'https://client.example.com/oauth/client-metadata.json';
12+
assertClientMetadataDocumentUrl(clientId);
13+
assert(isNativePublicClientId('org.rcex.KeepTalkingApp'), 'native client id should be accepted');
14+
assertValidRedirectUri('ktoauth://oauth/callback');
15+
16+
for (const invalid of [
17+
'http://client.example.com/oauth/client-metadata.json',
18+
'https://client.example.com',
19+
'https://user:pass@client.example.com/oauth/client-metadata.json',
20+
'https://client.example.com/oauth/client-metadata.json#fragment',
21+
'https://client.example.com/oauth/../client-metadata.json',
22+
]) {
23+
let rejected = false;
24+
try {
25+
assertClientMetadataDocumentUrl(invalid);
26+
} catch {
27+
rejected = true;
28+
}
29+
assert(rejected, 'invalid client metadata URL should be rejected: ' + invalid);
30+
}
31+
32+
const originalFetch = globalThis.fetch;
33+
(globalThis as any).fetch = async (url: string) => {
34+
assert(url === clientId, 'metadata fetch URL mismatch');
35+
return new Response(
36+
JSON.stringify({
37+
client_id: clientId,
38+
client_name: 'Client Metadata Test',
39+
redirect_uris: ['http://127.0.0.1:8765/callback'],
40+
grant_types: ['authorization_code'],
41+
response_types: ['code'],
42+
token_endpoint_auth_method: 'none',
43+
scope: 'notes:read notes:write',
44+
}),
45+
{
46+
status: 200,
47+
headers: { 'content-type': 'application/json' },
48+
}
49+
);
50+
};
51+
52+
try {
53+
const metadata = await fetchOAuthClientMetadataDocument(clientId);
54+
assert(metadata.client_id === clientId, 'metadata client_id mismatch');
55+
assert(metadata.client_name === 'Client Metadata Test', 'metadata client_name mismatch');
56+
assert(
57+
metadata.redirect_uris.includes('http://127.0.0.1:8765/callback'),
58+
'metadata redirect missing'
59+
);
60+
} finally {
61+
globalThis.fetch = originalFetch;
62+
}
63+
}

0 commit comments

Comments
 (0)