From 5808c9d852be334a68deed9984e86b31e904fe9e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 21 Feb 2026 03:02:43 -0800 Subject: [PATCH 1/5] feat(oauth): add CIMD support for client metadata discovery --- apps/sim/app/(auth)/oauth/consent/page.tsx | 5 +- apps/sim/lib/auth/auth.ts | 19 +++ apps/sim/lib/auth/cimd.ts | 131 +++++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/auth/cimd.ts diff --git a/apps/sim/app/(auth)/oauth/consent/page.tsx b/apps/sim/app/(auth)/oauth/consent/page.tsx index bc851ccff7..ff9986d3c8 100644 --- a/apps/sim/app/(auth)/oauth/consent/page.tsx +++ b/apps/sim/app/(auth)/oauth/consent/page.tsx @@ -46,7 +46,7 @@ export default function OAuthConsentPage() { return } - fetch(`/api/auth/oauth2/client/${clientId}`, { credentials: 'include' }) + fetch(`/api/auth/oauth2/client/${encodeURIComponent(clientId)}`, { credentials: 'include' }) .then(async (res) => { if (!res.ok) return const data = await res.json() @@ -164,13 +164,12 @@ export default function OAuthConsentPage() {
{clientInfo?.icon ? ( - {clientName ) : (
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index c2e337d6bb..fd4af55fa0 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -25,6 +25,7 @@ import { renderPasswordResetEmail, renderWelcomeEmail, } from '@/components/emails' +import { isMetadataUrl, resolveClientMetadata, upsertCimdClient } from '@/lib/auth/cimd' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { handleNewUser } from '@/lib/billing/core/usage' @@ -541,6 +542,21 @@ export const auth = betterAuth({ } } + if (ctx.path === '/oauth2/authorize' || ctx.path === '/oauth2/token') { + const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined + if (clientId && isMetadataUrl(clientId)) { + try { + const metadata = await resolveClientMetadata(clientId) + await upsertCimdClient(metadata) + } catch (err) { + logger.warn('CIMD resolution failed', { + clientId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + } + return }), }, @@ -560,6 +576,9 @@ export const auth = betterAuth({ allowDynamicClientRegistration: true, useJWTPlugin: true, scopes: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'], + metadata: { + client_id_metadata_document_supported: true, + } as Record, }), oneTimeToken({ expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats diff --git a/apps/sim/lib/auth/cimd.ts b/apps/sim/lib/auth/cimd.ts new file mode 100644 index 0000000000..7f3a974d0f --- /dev/null +++ b/apps/sim/lib/auth/cimd.ts @@ -0,0 +1,131 @@ +import { randomUUID } from 'node:crypto' +import { db } from '@sim/db' +import { oauthApplication } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' + +const logger = createLogger('cimd') + +interface ClientMetadataDocument { + client_id: string + client_name: string + logo_uri?: string + redirect_uris: string[] + client_uri?: string + policy_uri?: string + tos_uri?: string + contacts?: string[] + scope?: string +} + +export function isMetadataUrl(clientId: string): boolean { + return clientId.startsWith('https://') +} + +async function fetchClientMetadata(url: string): Promise { + const parsed = new URL(url) + if (parsed.protocol !== 'https:') { + throw new Error('CIMD URL must use HTTPS') + } + + const res = await secureFetchWithValidation(url, { + headers: { Accept: 'application/json' }, + timeout: 5000, + }) + + if (!res.ok) { + throw new Error(`CIMD fetch failed: ${res.status} ${res.statusText}`) + } + + const doc = (await res.json()) as ClientMetadataDocument + + if (doc.client_id !== url) { + throw new Error(`CIMD client_id mismatch: document has "${doc.client_id}", expected "${url}"`) + } + + if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0) { + throw new Error('CIMD document must contain at least one redirect_uri') + } + + if (!doc.client_name || typeof doc.client_name !== 'string') { + throw new Error('CIMD document must contain a client_name') + } + + return doc +} + +const CACHE_TTL_MS = 5 * 60 * 1000 +const NEGATIVE_CACHE_TTL_MS = 60 * 1000 +const cache = new Map() +const failureCache = new Map() +const inflight = new Map>() + +export async function resolveClientMetadata(url: string): Promise { + const cached = cache.get(url) + if (cached && Date.now() < cached.expiresAt) { + return cached.doc + } + + const failed = failureCache.get(url) + if (failed && Date.now() < failed.expiresAt) { + throw new Error(failed.error) + } + + const pending = inflight.get(url) + if (pending) { + return pending + } + + const promise = fetchClientMetadata(url) + .then((doc) => { + cache.set(url, { doc, expiresAt: Date.now() + CACHE_TTL_MS }) + failureCache.delete(url) + return doc + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err) + failureCache.set(url, { error: message, expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS }) + throw err + }) + .finally(() => { + inflight.delete(url) + }) + + inflight.set(url, promise) + return promise +} + +export async function upsertCimdClient(metadata: ClientMetadataDocument): Promise { + const now = new Date() + const redirectURLs = metadata.redirect_uris.join(',') + + await db + .insert(oauthApplication) + .values({ + id: randomUUID(), + clientId: metadata.client_id, + name: metadata.client_name, + icon: metadata.logo_uri ?? null, + redirectURLs, + type: 'public', + clientSecret: null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: oauthApplication.clientId, + set: { + name: metadata.client_name, + icon: metadata.logo_uri ?? null, + redirectURLs, + type: 'public', + clientSecret: null, + updatedAt: now, + }, + }) + + logger.info('Upserted CIMD client', { + clientId: metadata.client_id, + name: metadata.client_name, + }) +} From be13a316e7d7642eb417e4878b0a409b872a40b2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 21 Feb 2026 12:24:17 -0800 Subject: [PATCH 2/5] 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 --- apps/sim/lib/auth/cimd.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/sim/lib/auth/cimd.ts b/apps/sim/lib/auth/cimd.ts index 7f3a974d0f..2d684739f8 100644 --- a/apps/sim/lib/auth/cimd.ts +++ b/apps/sim/lib/auth/cimd.ts @@ -31,6 +31,7 @@ async function fetchClientMetadata(url: string): Promise const res = await secureFetchWithValidation(url, { headers: { Accept: 'application/json' }, timeout: 5000, + maxResponseBytes: 256 * 1024, }) if (!res.ok) { @@ -47,6 +48,31 @@ async function fetchClientMetadata(url: string): Promise throw new Error('CIMD document must contain at least one redirect_uri') } + for (const uri of doc.redirect_uris) { + try { + const parsed = new URL(uri) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error(`Invalid redirect_uri scheme: ${parsed.protocol}`) + } + } catch { + throw new Error(`Invalid redirect_uri: ${uri}`) + } + if (uri.includes(',')) { + throw new Error(`redirect_uri must not contain commas: ${uri}`) + } + } + + if (doc.logo_uri) { + try { + const logoParsed = new URL(doc.logo_uri) + if (logoParsed.protocol !== 'https:') { + doc.logo_uri = undefined + } + } catch { + doc.logo_uri = undefined + } + } + if (!doc.client_name || typeof doc.client_name !== 'string') { throw new Error('CIMD document must contain a client_name') } From 209903763b3e7917513a6bd737c9059f6c7832b1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 21 Feb 2026 12:38:35 -0800 Subject: [PATCH 3/5] fix(oauth): add explicit userId null for CIMD client insert --- apps/sim/lib/auth/cimd.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/auth/cimd.ts b/apps/sim/lib/auth/cimd.ts index 2d684739f8..817b413700 100644 --- a/apps/sim/lib/auth/cimd.ts +++ b/apps/sim/lib/auth/cimd.ts @@ -135,6 +135,7 @@ export async function upsertCimdClient(metadata: ClientMetadataDocument): Promis redirectURLs, type: 'public', clientSecret: null, + userId: null, createdAt: now, updatedAt: now, }) From 54066b975579a7d4ad297409b2c654678eec97ea Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 21 Feb 2026 13:38:09 -0800 Subject: [PATCH 4/5] 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 --- apps/sim/lib/auth/auth.ts | 6 ++++-- apps/sim/lib/auth/cimd.ts | 22 ++++++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index fd4af55fa0..81462c6e3f 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -546,8 +546,10 @@ export const auth = betterAuth({ const clientId = (ctx.query?.client_id ?? ctx.body?.client_id) as string | undefined if (clientId && isMetadataUrl(clientId)) { try { - const metadata = await resolveClientMetadata(clientId) - await upsertCimdClient(metadata) + const { metadata, fromCache } = await resolveClientMetadata(clientId) + if (!fromCache) { + await upsertCimdClient(metadata) + } } catch (err) { logger.warn('CIMD resolution failed', { clientId, diff --git a/apps/sim/lib/auth/cimd.ts b/apps/sim/lib/auth/cimd.ts index 817b413700..be8634652e 100644 --- a/apps/sim/lib/auth/cimd.ts +++ b/apps/sim/lib/auth/cimd.ts @@ -49,14 +49,15 @@ async function fetchClientMetadata(url: string): Promise } for (const uri of doc.redirect_uris) { + let parsed: URL try { - const parsed = new URL(uri) - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - throw new Error(`Invalid redirect_uri scheme: ${parsed.protocol}`) - } + parsed = new URL(uri) } catch { throw new Error(`Invalid redirect_uri: ${uri}`) } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error(`Invalid redirect_uri scheme: ${parsed.protocol}`) + } if (uri.includes(',')) { throw new Error(`redirect_uri must not contain commas: ${uri}`) } @@ -86,10 +87,15 @@ const cache = new Map() const inflight = new Map>() -export async function resolveClientMetadata(url: string): Promise { +interface ResolveResult { + metadata: ClientMetadataDocument + fromCache: boolean +} + +export async function resolveClientMetadata(url: string): Promise { const cached = cache.get(url) if (cached && Date.now() < cached.expiresAt) { - return cached.doc + return { metadata: cached.doc, fromCache: true } } const failed = failureCache.get(url) @@ -99,7 +105,7 @@ export async function resolveClientMetadata(url: string): Promise ({ metadata: doc, fromCache: false })) } const promise = fetchClientMetadata(url) @@ -118,7 +124,7 @@ export async function resolveClientMetadata(url: string): Promise ({ metadata: doc, fromCache: false })) } export async function upsertCimdClient(metadata: ClientMetadataDocument): Promise { From f61ff7097b53f46fb1c43dcc02ba490b726a7fb4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 21 Feb 2026 14:21:02 -0800 Subject: [PATCH 5/5] fix(oauth): evict CIMD cache on upsert failure to allow retry --- apps/sim/lib/auth/auth.ts | 14 ++++++++++++-- apps/sim/lib/auth/cimd.ts | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 81462c6e3f..1ad66ab16b 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -25,7 +25,12 @@ import { renderPasswordResetEmail, renderWelcomeEmail, } from '@/components/emails' -import { isMetadataUrl, resolveClientMetadata, upsertCimdClient } from '@/lib/auth/cimd' +import { + evictCachedMetadata, + isMetadataUrl, + resolveClientMetadata, + upsertCimdClient, +} from '@/lib/auth/cimd' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { handleNewUser } from '@/lib/billing/core/usage' @@ -548,7 +553,12 @@ export const auth = betterAuth({ try { const { metadata, fromCache } = await resolveClientMetadata(clientId) if (!fromCache) { - await upsertCimdClient(metadata) + try { + await upsertCimdClient(metadata) + } catch (upsertErr) { + evictCachedMetadata(clientId) + throw upsertErr + } } } catch (err) { logger.warn('CIMD resolution failed', { diff --git a/apps/sim/lib/auth/cimd.ts b/apps/sim/lib/auth/cimd.ts index be8634652e..f3437156ea 100644 --- a/apps/sim/lib/auth/cimd.ts +++ b/apps/sim/lib/auth/cimd.ts @@ -127,6 +127,10 @@ export async function resolveClientMetadata(url: string): Promise return promise.then((doc) => ({ metadata: doc, fromCache: false })) } +export function evictCachedMetadata(url: string): void { + cache.delete(url) +} + export async function upsertCimdClient(metadata: ClientMetadataDocument): Promise { const now = new Date() const redirectURLs = metadata.redirect_uris.join(',')