Skip to content

Commit adeb7d3

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

File tree

3 files changed

+177
-3
lines changed

3 files changed

+177
-3
lines changed

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

Lines changed: 2 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,12 @@ 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+
<img
168168
src={clientInfo.icon}
169169
alt={clientName ?? 'Application'}
170170
width={48}
171171
height={48}
172172
className='rounded-[10px]'
173-
unoptimized
174173
/>
175174
) : (
176175
<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: 19 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,21 @@ export const auth = betterAuth({
541542
}
542543
}
543544

545+
if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') {
546+
const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined
547+
if (clientId && isMetadataUrl(clientId)) {
548+
try {
549+
const metadata = await resolveClientMetadata(clientId)
550+
await upsertCimdClient(metadata)
551+
} catch (err) {
552+
logger.warn('CIMD resolution failed', {
553+
clientId,
554+
error: err instanceof Error ? err.message : String(err),
555+
})
556+
}
557+
}
558+
}
559+
544560
return
545561
}),
546562
},
@@ -560,6 +576,9 @@ export const auth = betterAuth({
560576
allowDynamicClientRegistration: true,
561577
useJWTPlugin: true,
562578
scopes: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'],
579+
metadata: {
580+
client_id_metadata_document_supported: true,
581+
} as Record<string, unknown>,
563582
}),
564583
oneTimeToken({
565584
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats

apps/sim/lib/auth/cimd.ts

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

0 commit comments

Comments
 (0)