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..1ad66ab16b 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -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' @@ -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 }), }, @@ -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, }), 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..f3437156ea --- /dev/null +++ b/apps/sim/lib/auth/cimd.ts @@ -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 { + 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() +const failureCache = new Map() +const inflight = new Map>() + +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 { 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 { + 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, + }) +}