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
/>
Comment thread
waleedlatif1 marked this conversation as resolved.
) : (
<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
}
}
Comment thread
waleedlatif1 marked this conversation as resolved.
} catch (err) {
logger.warn('CIMD resolution failed', {
clientId,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.

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,
})
Comment thread
waleedlatif1 marked this conversation as resolved.

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}"`)
Comment thread
waleedlatif1 marked this conversation as resolved.
}

if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0) {
throw new Error('CIMD document must contain at least one redirect_uri')
}
Comment thread
waleedlatif1 marked this conversation as resolved.

for (const uri of doc.redirect_uris) {
let parsed: URL
try {
parsed = new URL(uri)
} catch {
throw new Error(`Invalid redirect_uri: ${uri}`)
}
Comment thread
waleedlatif1 marked this conversation as resolved.
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error(`Invalid redirect_uri scheme: ${parsed.protocol}`)
}
Comment thread
waleedlatif1 marked this conversation as resolved.
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>>()
Comment thread
waleedlatif1 marked this conversation as resolved.

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)
}
Comment thread
waleedlatif1 marked this conversation as resolved.

export async function upsertCimdClient(metadata: ClientMetadataDocument): Promise<void> {
const now = new Date()
const redirectURLs = metadata.redirect_uris.join(',')
Comment thread
waleedlatif1 marked this conversation as resolved.

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,
Comment thread
waleedlatif1 marked this conversation as resolved.
})
.onConflictDoUpdate({
target: oauthApplication.clientId,
set: {
name: metadata.client_name,
icon: metadata.logo_uri ?? null,
redirectURLs,
type: 'public',
clientSecret: null,
updatedAt: now,
Comment thread
waleedlatif1 marked this conversation as resolved.
},
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
})
Comment thread
waleedlatif1 marked this conversation as resolved.

logger.info('Upserted CIMD client', {
clientId: metadata.client_id,
name: metadata.client_name,
})
}