feat(oauth): add CIMD support for client metadata discovery (#3285)

* feat(oauth): add CIMD support for client metadata discovery

* 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

* fix(oauth): add explicit userId null for CIMD client insert

* 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

* fix(oauth): evict CIMD cache on upsert failure to allow retry
This commit is contained in:
Waleed
2026-02-21 14:38:05 -08:00
committed by GitHub
parent ccb4f5956d
commit 4913799a27
3 changed files with 201 additions and 3 deletions
+2 -3
View File
@@ -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() {
<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'>
+31
View File
@@ -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<string, unknown>,
}),
oneTimeToken({
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
+168
View File
@@ -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,
})
}