Skip to content

Commit 5808c9d

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

File tree

3 files changed

+152
-3
lines changed

3 files changed

+152
-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: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { randomUUID } from 'node:crypto'
2+
import { db } from '@sim/db'
3+
import { oauthApplication } from '@sim/db/schema'
4+
import { createLogger } from '@sim/logger'
5+
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
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+
async function fetchClientMetadata(url: string): Promise<ClientMetadataDocument> {
26+
const parsed = new URL(url)
27+
if (parsed.protocol !== 'https:') {
28+
throw new Error('CIMD URL must use HTTPS')
29+
}
30+
31+
const res = await secureFetchWithValidation(url, {
32+
headers: { Accept: 'application/json' },
33+
timeout: 5000,
34+
})
35+
36+
if (!res.ok) {
37+
throw new Error(`CIMD fetch failed: ${res.status} ${res.statusText}`)
38+
}
39+
40+
const doc = (await res.json()) as ClientMetadataDocument
41+
42+
if (doc.client_id !== url) {
43+
throw new Error(`CIMD client_id mismatch: document has "${doc.client_id}", expected "${url}"`)
44+
}
45+
46+
if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0) {
47+
throw new Error('CIMD document must contain at least one redirect_uri')
48+
}
49+
50+
if (!doc.client_name || typeof doc.client_name !== 'string') {
51+
throw new Error('CIMD document must contain a client_name')
52+
}
53+
54+
return doc
55+
}
56+
57+
const CACHE_TTL_MS = 5 * 60 * 1000
58+
const NEGATIVE_CACHE_TTL_MS = 60 * 1000
59+
const cache = new Map<string, { doc: ClientMetadataDocument; expiresAt: number }>()
60+
const failureCache = new Map<string, { error: string; expiresAt: number }>()
61+
const inflight = new Map<string, Promise<ClientMetadataDocument>>()
62+
63+
export async function resolveClientMetadata(url: string): Promise<ClientMetadataDocument> {
64+
const cached = cache.get(url)
65+
if (cached && Date.now() < cached.expiresAt) {
66+
return cached.doc
67+
}
68+
69+
const failed = failureCache.get(url)
70+
if (failed && Date.now() < failed.expiresAt) {
71+
throw new Error(failed.error)
72+
}
73+
74+
const pending = inflight.get(url)
75+
if (pending) {
76+
return pending
77+
}
78+
79+
const promise = fetchClientMetadata(url)
80+
.then((doc) => {
81+
cache.set(url, { doc, expiresAt: Date.now() + CACHE_TTL_MS })
82+
failureCache.delete(url)
83+
return doc
84+
})
85+
.catch((err) => {
86+
const message = err instanceof Error ? err.message : String(err)
87+
failureCache.set(url, { error: message, expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS })
88+
throw err
89+
})
90+
.finally(() => {
91+
inflight.delete(url)
92+
})
93+
94+
inflight.set(url, promise)
95+
return promise
96+
}
97+
98+
export async function upsertCimdClient(metadata: ClientMetadataDocument): Promise<void> {
99+
const now = new Date()
100+
const redirectURLs = metadata.redirect_uris.join(',')
101+
102+
await db
103+
.insert(oauthApplication)
104+
.values({
105+
id: randomUUID(),
106+
clientId: metadata.client_id,
107+
name: metadata.client_name,
108+
icon: metadata.logo_uri ?? null,
109+
redirectURLs,
110+
type: 'public',
111+
clientSecret: null,
112+
createdAt: now,
113+
updatedAt: now,
114+
})
115+
.onConflictDoUpdate({
116+
target: oauthApplication.clientId,
117+
set: {
118+
name: metadata.client_name,
119+
icon: metadata.logo_uri ?? null,
120+
redirectURLs,
121+
type: 'public',
122+
clientSecret: null,
123+
updatedAt: now,
124+
},
125+
})
126+
127+
logger.info('Upserted CIMD client', {
128+
clientId: metadata.client_id,
129+
name: metadata.client_name,
130+
})
131+
}

0 commit comments

Comments
 (0)