mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user