Skip to content

Commit aa05f87

Browse files
committed
feat(oauth): add CIMD support for client metadata discovery
1 parent 42020c3 commit aa05f87

File tree

3 files changed

+183
-3
lines changed

3 files changed

+183
-3
lines changed

apps/sim/app/(auth)/oauth/consent/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default function OAuthConsentPage() {
4646
return
4747
}
4848

49-
fetch(`/api/auth/oauth2/client/${clientId}`, { credentials: 'include' })
49+
fetch(`/api/auth/oauth2/client/${encodeURIComponent(clientId)}`, { credentials: 'include' })
5050
.then(async (res) => {
5151
if (!res.ok) return
5252
const data = await res.json()
@@ -164,13 +164,13 @@ export default function OAuthConsentPage() {
164164
<div className='flex flex-col items-center justify-center'>
165165
<div className='mb-6 flex items-center gap-4'>
166166
{clientInfo?.icon ? (
167-
<Image
167+
/* eslint-disable-next-line @next/next/no-img-element */
168+
<img
168169
src={clientInfo.icon}
169170
alt={clientName ?? 'Application'}
170171
width={48}
171172
height={48}
172173
className='rounded-[10px]'
173-
unoptimized
174174
/>
175175
) : (
176176
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>

apps/sim/lib/auth/auth.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
renderPasswordResetEmail,
2626
renderWelcomeEmail,
2727
} from '@/components/emails'
28+
import { isMetadataUrl, resolveClientMetadata, upsertCimdClient } from '@/lib/auth/cimd'
2829
import { sendPlanWelcomeEmail } from '@/lib/billing'
2930
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
3031
import { handleNewUser } from '@/lib/billing/core/usage'
@@ -541,6 +542,22 @@ export const auth = betterAuth({
541542
}
542543
}
543544

545+
// CIMD: resolve URL-formatted client_id before Better Auth looks it up
546+
if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') {
547+
const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined
548+
if (clientId && isMetadataUrl(clientId)) {
549+
try {
550+
const metadata = await resolveClientMetadata(clientId)
551+
await upsertCimdClient(metadata)
552+
} catch (err) {
553+
logger.warn('CIMD resolution failed', {
554+
clientId,
555+
error: err instanceof Error ? err.message : String(err),
556+
})
557+
}
558+
}
559+
}
560+
544561
return
545562
}),
546563
},
@@ -560,6 +577,9 @@ export const auth = betterAuth({
560577
allowDynamicClientRegistration: true,
561578
useJWTPlugin: true,
562579
scopes: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'],
580+
metadata: {
581+
client_id_metadata_document_supported: true,
582+
} as Record<string, unknown>,
563583
}),
564584
oneTimeToken({
565585
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats

apps/sim/lib/auth/cimd.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { randomUUID } from 'node:crypto'
2+
import dns from 'node:dns/promises'
3+
import { db } from '@sim/db'
4+
import { oauthApplication } from '@sim/db/schema'
5+
import { createLogger } from '@sim/logger'
6+
import { sql } from 'drizzle-orm'
7+
8+
const logger = createLogger('cimd')
9+
10+
/** CIMD document shape per MCP spec (Nov 2025). */
11+
interface ClientMetadataDocument {
12+
client_id: string
13+
client_name: string
14+
logo_uri?: string
15+
redirect_uris: string[]
16+
client_uri?: string
17+
policy_uri?: string
18+
tos_uri?: string
19+
contacts?: string[]
20+
scope?: string
21+
}
22+
23+
/** Returns true when the client_id looks like a CIMD metadata URL. */
24+
export function isMetadataUrl(clientId: string): boolean {
25+
return clientId.startsWith('https://')
26+
}
27+
28+
const PRIVATE_RANGES = [
29+
/^127\./,
30+
/^10\./,
31+
/^172\.(1[6-9]|2\d|3[01])\./,
32+
/^192\.168\./,
33+
/^169\.254\./,
34+
/^0\./,
35+
/^::1$/,
36+
/^fc/i,
37+
/^fd/i,
38+
/^fe80/i,
39+
]
40+
41+
function isPrivateIp(ip: string): boolean {
42+
return PRIVATE_RANGES.some((re) => re.test(ip))
43+
}
44+
45+
/**
46+
* Fetches and validates a CIMD document.
47+
* Rejects non-HTTPS URLs, private IPs (SSRF), and invalid documents.
48+
*/
49+
async function fetchClientMetadata(url: string): Promise<ClientMetadataDocument> {
50+
const parsed = new URL(url)
51+
if (parsed.protocol !== 'https:') {
52+
throw new Error('CIMD URL must use HTTPS')
53+
}
54+
55+
// SSRF: resolve hostname and reject private/loopback IPs
56+
const addresses = await dns.resolve4(parsed.hostname).catch(() => [] as string[])
57+
const addresses6 = await dns.resolve6(parsed.hostname).catch(() => [] as string[])
58+
const allAddresses = [...addresses, ...addresses6]
59+
60+
if (allAddresses.length === 0) {
61+
throw new Error(`Cannot resolve hostname: ${parsed.hostname}`)
62+
}
63+
64+
for (const addr of allAddresses) {
65+
if (isPrivateIp(addr)) {
66+
throw new Error(`CIMD URL resolves to private IP: ${addr}`)
67+
}
68+
}
69+
70+
const controller = new AbortController()
71+
const timeout = setTimeout(() => controller.abort(), 5000)
72+
73+
try {
74+
const res = await fetch(url, {
75+
signal: controller.signal,
76+
headers: { Accept: 'application/json' },
77+
})
78+
79+
if (!res.ok) {
80+
throw new Error(`CIMD fetch failed: ${res.status} ${res.statusText}`)
81+
}
82+
83+
const doc = (await res.json()) as ClientMetadataDocument
84+
85+
// Validate: client_id in document must match the URL exactly
86+
if (doc.client_id !== url) {
87+
throw new Error(`CIMD client_id mismatch: document has "${doc.client_id}", expected "${url}"`)
88+
}
89+
90+
// Validate: redirect_uris must be present and non-empty
91+
if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0) {
92+
throw new Error('CIMD document must contain at least one redirect_uri')
93+
}
94+
95+
// Validate: client_name must be present
96+
if (!doc.client_name || typeof doc.client_name !== 'string') {
97+
throw new Error('CIMD document must contain a client_name')
98+
}
99+
100+
return doc
101+
} finally {
102+
clearTimeout(timeout)
103+
}
104+
}
105+
106+
/** In-memory cache with 5-minute TTL. */
107+
const cache = new Map<string, { doc: ClientMetadataDocument; expiresAt: number }>()
108+
const CACHE_TTL_MS = 5 * 60 * 1000
109+
110+
/**
111+
* Resolves a CIMD document with caching.
112+
* Returns the cached document if still valid, otherwise fetches fresh.
113+
*/
114+
export async function resolveClientMetadata(url: string): Promise<ClientMetadataDocument> {
115+
const cached = cache.get(url)
116+
if (cached && Date.now() < cached.expiresAt) {
117+
return cached.doc
118+
}
119+
120+
const doc = await fetchClientMetadata(url)
121+
cache.set(url, { doc, expiresAt: Date.now() + CACHE_TTL_MS })
122+
return doc
123+
}
124+
125+
/**
126+
* Upserts an OAuth application row from a CIMD document.
127+
* Uses the metadata URL as clientId. Public client (no secret).
128+
*/
129+
export async function upsertCimdClient(metadata: ClientMetadataDocument): Promise<void> {
130+
const now = new Date()
131+
const redirectURLs = metadata.redirect_uris.join(',')
132+
133+
await db
134+
.insert(oauthApplication)
135+
.values({
136+
id: randomUUID(),
137+
clientId: metadata.client_id,
138+
name: metadata.client_name,
139+
icon: metadata.logo_uri ?? null,
140+
redirectURLs,
141+
type: 'public',
142+
clientSecret: null,
143+
createdAt: now,
144+
updatedAt: now,
145+
})
146+
.onConflictDoUpdate({
147+
target: oauthApplication.clientId,
148+
set: {
149+
name: sql`excluded.name`,
150+
icon: sql`excluded.icon`,
151+
redirectURLs: sql`excluded.redirect_urls`,
152+
updatedAt: sql`excluded.updated_at`,
153+
},
154+
})
155+
156+
logger.info('Upserted CIMD client', {
157+
clientId: metadata.client_id,
158+
name: metadata.client_name,
159+
})
160+
}

0 commit comments

Comments
 (0)