Skip to content

Commit 4913799

Browse files
authored
feat(oauth): add CIMD support for client metadata discovery (#3285)
* feat(oauth): add CIMD support for client metadata discovery * fix(oauth): add response size limit, redirect_uri and logo_uri validation to CIMD - Add maxResponseBytes (256KB) to prevent oversized responses - Validate redirect_uri schemes (https/http only) and reject commas - Validate logo_uri requires HTTPS, silently drop invalid logos * fix(oauth): add explicit userId null for CIMD client insert * fix(oauth): fix redirect_uri error handling, skip upsert on cache hit - Move scheme check outside try/catch so specific error isn't swallowed - Return fromCache flag from resolveClientMetadata to skip redundant DB writes * fix(oauth): evict CIMD cache on upsert failure to allow retry
1 parent ccb4f59 commit 4913799

File tree

3 files changed

+201
-3
lines changed

3 files changed

+201
-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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ import {
2525
renderPasswordResetEmail,
2626
renderWelcomeEmail,
2727
} from '@/components/emails'
28+
import {
29+
evictCachedMetadata,
30+
isMetadataUrl,
31+
resolveClientMetadata,
32+
upsertCimdClient,
33+
} from '@/lib/auth/cimd'
2834
import { sendPlanWelcomeEmail } from '@/lib/billing'
2935
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
3036
import { handleNewUser } from '@/lib/billing/core/usage'
@@ -541,6 +547,28 @@ export const auth = betterAuth({
541547
}
542548
}
543549

550+
if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') {
551+
const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined
552+
if (clientId && isMetadataUrl(clientId)) {
553+
try {
554+
const { metadata, fromCache } = await resolveClientMetadata(clientId)
555+
if (!fromCache) {
556+
try {
557+
await upsertCimdClient(metadata)
558+
} catch (upsertErr) {
559+
evictCachedMetadata(clientId)
560+
throw upsertErr
561+
}
562+
}
563+
} catch (err) {
564+
logger.warn('CIMD resolution failed', {
565+
clientId,
566+
error: err instanceof Error ? err.message : String(err),
567+
})
568+
}
569+
}
570+
}
571+
544572
return
545573
}),
546574
},
@@ -560,6 +588,9 @@ export const auth = betterAuth({
560588
allowDynamicClientRegistration: true,
561589
useJWTPlugin: true,
562590
scopes: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'],
591+
metadata: {
592+
client_id_metadata_document_supported: true,
593+
} as Record<string, unknown>,
563594
}),
564595
oneTimeToken({
565596
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats

apps/sim/lib/auth/cimd.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
maxResponseBytes: 256 * 1024,
35+
})
36+
37+
if (!res.ok) {
38+
throw new Error(`CIMD fetch failed: ${res.status} ${res.statusText}`)
39+
}
40+
41+
const doc = (await res.json()) as ClientMetadataDocument
42+
43+
if (doc.client_id !== url) {
44+
throw new Error(`CIMD client_id mismatch: document has "${doc.client_id}", expected "${url}"`)
45+
}
46+
47+
if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0) {
48+
throw new Error('CIMD document must contain at least one redirect_uri')
49+
}
50+
51+
for (const uri of doc.redirect_uris) {
52+
let parsed: URL
53+
try {
54+
parsed = new URL(uri)
55+
} catch {
56+
throw new Error(`Invalid redirect_uri: ${uri}`)
57+
}
58+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
59+
throw new Error(`Invalid redirect_uri scheme: ${parsed.protocol}`)
60+
}
61+
if (uri.includes(',')) {
62+
throw new Error(`redirect_uri must not contain commas: ${uri}`)
63+
}
64+
}
65+
66+
if (doc.logo_uri) {
67+
try {
68+
const logoParsed = new URL(doc.logo_uri)
69+
if (logoParsed.protocol !== 'https:') {
70+
doc.logo_uri = undefined
71+
}
72+
} catch {
73+
doc.logo_uri = undefined
74+
}
75+
}
76+
77+
if (!doc.client_name || typeof doc.client_name !== 'string') {
78+
throw new Error('CIMD document must contain a client_name')
79+
}
80+
81+
return doc
82+
}
83+
84+
const CACHE_TTL_MS = 5 * 60 * 1000
85+
const NEGATIVE_CACHE_TTL_MS = 60 * 1000
86+
const cache = new Map<string, { doc: ClientMetadataDocument; expiresAt: number }>()
87+
const failureCache = new Map<string, { error: string; expiresAt: number }>()
88+
const inflight = new Map<string, Promise<ClientMetadataDocument>>()
89+
90+
interface ResolveResult {
91+
metadata: ClientMetadataDocument
92+
fromCache: boolean
93+
}
94+
95+
export async function resolveClientMetadata(url: string): Promise<ResolveResult> {
96+
const cached = cache.get(url)
97+
if (cached && Date.now() < cached.expiresAt) {
98+
return { metadata: cached.doc, fromCache: true }
99+
}
100+
101+
const failed = failureCache.get(url)
102+
if (failed && Date.now() < failed.expiresAt) {
103+
throw new Error(failed.error)
104+
}
105+
106+
const pending = inflight.get(url)
107+
if (pending) {
108+
return pending.then((doc) => ({ metadata: doc, fromCache: false }))
109+
}
110+
111+
const promise = fetchClientMetadata(url)
112+
.then((doc) => {
113+
cache.set(url, { doc, expiresAt: Date.now() + CACHE_TTL_MS })
114+
failureCache.delete(url)
115+
return doc
116+
})
117+
.catch((err) => {
118+
const message = err instanceof Error ? err.message : String(err)
119+
failureCache.set(url, { error: message, expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS })
120+
throw err
121+
})
122+
.finally(() => {
123+
inflight.delete(url)
124+
})
125+
126+
inflight.set(url, promise)
127+
return promise.then((doc) => ({ metadata: doc, fromCache: false }))
128+
}
129+
130+
export function evictCachedMetadata(url: string): void {
131+
cache.delete(url)
132+
}
133+
134+
export async function upsertCimdClient(metadata: ClientMetadataDocument): Promise<void> {
135+
const now = new Date()
136+
const redirectURLs = metadata.redirect_uris.join(',')
137+
138+
await db
139+
.insert(oauthApplication)
140+
.values({
141+
id: randomUUID(),
142+
clientId: metadata.client_id,
143+
name: metadata.client_name,
144+
icon: metadata.logo_uri ?? null,
145+
redirectURLs,
146+
type: 'public',
147+
clientSecret: null,
148+
userId: null,
149+
createdAt: now,
150+
updatedAt: now,
151+
})
152+
.onConflictDoUpdate({
153+
target: oauthApplication.clientId,
154+
set: {
155+
name: metadata.client_name,
156+
icon: metadata.logo_uri ?? null,
157+
redirectURLs,
158+
type: 'public',
159+
clientSecret: null,
160+
updatedAt: now,
161+
},
162+
})
163+
164+
logger.info('Upserted CIMD client', {
165+
clientId: metadata.client_id,
166+
name: metadata.client_name,
167+
})
168+
}

0 commit comments

Comments
 (0)