Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
21 changes: 21 additions & 0 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -541,6 +542,23 @@ 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) {
await upsertCimdClient(metadata)
}
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 +578,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
164 changes: 164 additions & 0 deletions apps/sim/lib/auth/cimd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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 }))
}
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,
})
}