Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/sim/app/(auth)/oauth/consent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -164,13 +164,12 @@ export default function OAuthConsentPage() {
<div className='flex flex-col items-center justify-center'>
<div className='mb-6 flex items-center gap-4'>
{clientInfo?.icon ? (
<Image
<img
src={clientInfo.icon}
alt={clientName ?? 'Application'}
width={48}
height={48}
className='rounded-[10px]'
unoptimized
/>
) : (
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
Expand Down
31 changes: 31 additions & 0 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ import {
renderPasswordResetEmail,
renderWelcomeEmail,
} from '@/components/emails'
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'
Expand Down Expand Up @@ -541,6 +547,28 @@ 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, fromCache } = await resolveClientMetadata(clientId)
if (!fromCache) {
try {
await upsertCimdClient(metadata)
} catch (upsertErr) {
evictCachedMetadata(clientId)
throw upsertErr
}
}
} catch (err) {
logger.warn('CIMD resolution failed', {
clientId,
error: err instanceof Error ? err.message : String(err),
})
}
}
}

return
}),
},
Expand All @@ -560,6 +588,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<string, unknown>,
}),
oneTimeToken({
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
Expand Down
168 changes: 168 additions & 0 deletions apps/sim/lib/auth/cimd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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<ClientMetadataDocument> {
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,
maxResponseBytes: 256 * 1024,
})

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')
}

for (const uri of doc.redirect_uris) {
let parsed: URL
try {
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}`)
}
}

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')
}

return doc
}

const CACHE_TTL_MS = 5 * 60 * 1000
const NEGATIVE_CACHE_TTL_MS = 60 * 1000
const cache = new Map<string, { doc: ClientMetadataDocument; expiresAt: number }>()
const failureCache = new Map<string, { error: string; expiresAt: number }>()
const inflight = new Map<string, Promise<ClientMetadataDocument>>()

interface ResolveResult {
metadata: ClientMetadataDocument
fromCache: boolean
}

export async function resolveClientMetadata(url: string): Promise<ResolveResult> {
const cached = cache.get(url)
if (cached && Date.now() < cached.expiresAt) {
return { metadata: cached.doc, fromCache: true }
}

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.then((doc) => ({ metadata: doc, fromCache: false }))
}

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.then((doc) => ({ metadata: doc, fromCache: false }))
}

export function evictCachedMetadata(url: string): void {
cache.delete(url)
}

export async function upsertCimdClient(metadata: ClientMetadataDocument): Promise<void> {
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,
userId: 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,
})
}