import { db, member, ssoProvider } from '@sim/db' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { ssoRegistrationContract } from '@/lib/api/contracts/auth' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth' import { normalizeSSODomain } from '@/lib/auth/sso/domain' import { hasSSOAccess } from '@/lib/billing' import { env } from '@/lib/core/config/env' import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { REDACTED_MARKER } from '@/lib/core/security/redaction' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SSORegisterRoute') type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' /** * Prefers client_secret_post over client_secret_basic when an IdP supports both: * better-auth sends client_secret_basic credentials without URL-encoding per * RFC 6749 ยง2.3.1, so a '+' in the client secret is decoded as a space, causing * invalid_client errors. Matches the same default in register-sso-provider.ts. */ function selectTokenEndpointAuthMethod( supportedMethods: unknown, existing?: TokenEndpointAuthMethod ): TokenEndpointAuthMethod { if (existing) return existing if (!Array.isArray(supportedMethods) || supportedMethods.length === 0) { return 'client_secret_post' } if (supportedMethods.includes('client_secret_post')) return 'client_secret_post' if (supportedMethods.includes('client_secret_basic')) return 'client_secret_basic' return 'client_secret_post' } type DiscoveryResult = | { ok: true; discovery: Record } | { ok: false; error: string } const OIDC_DISCOVERY_TIMEOUT_MS = 10000 async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise { const urlValidation = await validateUrlWithDNS(discoveryUrl, 'OIDC discovery URL') if (!urlValidation.isValid || !urlValidation.resolvedIP) { return { ok: false, error: urlValidation.error ?? 'SSRF validation failed' } } try { const response = await secureFetchWithPinnedIP(discoveryUrl, urlValidation.resolvedIP, { headers: { Accept: 'application/json' }, timeout: OIDC_DISCOVERY_TIMEOUT_MS, }) if (!response.ok) { return { ok: false, error: `Discovery request failed with status ${response.status}` } } return { ok: true, discovery: (await response.json()) as Record } } catch (error) { return { ok: false, error: getErrorMessage(error, 'Unknown error') } } } export const POST = withRouteHandler(async (request: NextRequest) => { try { if (!env.SSO_ENABLED) { return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) } const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } const hasAccess = await hasSSOAccess(session.user.id) if (!hasAccess) { return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 }) } const parsed = await parseRequest( ssoRegistrationContract, request, {}, { validationErrorResponse: (error) => { logger.warn('Invalid SSO registration request', { errors: error.issues }) return NextResponse.json( { error: getValidationErrorMessage(error, 'Validation failed') }, { status: 400 } ) }, } ) if (!parsed.success) return parsed.response const body = parsed.data.body const { providerId, issuer, providerType, mapping, orgId } = body if (orgId) { const [membership] = await db .select({ organizationId: member.organizationId, role: member.role }) .from(member) .where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId))) .limit(1) if (!membership) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } if (membership.role !== 'owner' && membership.role !== 'admin') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } const domain = normalizeSSODomain(body.domain) if (!domain) { return NextResponse.json({ error: 'Enter a valid domain like company.com' }, { status: 400 }) } const isOwnedByCaller = (provider: { userId: string | null organizationId: string | null }): boolean => { if (provider.userId === session.user.id && !provider.organizationId) return true return orgId ? provider.organizationId === orgId : false } const findDomainConflict = async () => ( await db .select({ userId: ssoProvider.userId, organizationId: ssoProvider.organizationId, }) .from(ssoProvider) .where(sql`lower(${ssoProvider.domain}) = ${domain}`) ).find((provider) => !isOwnedByCaller(provider)) const domainConflictResponse = () => NextResponse.json( { error: 'This domain is already registered for SSO by another organization.', code: 'SSO_DOMAIN_ALREADY_REGISTERED', }, { status: 409 } ) if (await findDomainConflict()) { logger.warn('Rejected SSO registration for domain owned by another tenant', { domain, orgId, userId: session.user.id, }) return domainConflictResponse() } const headers: Record = {} request.headers.forEach((value, key) => { headers[key] = value }) const providerConfig: any = { providerId, issuer, domain, mapping, ...(orgId ? { organizationId: orgId } : {}), } if (providerType === 'oidc') { const { clientId, clientSecret: rawClientSecret, scopes, pkce, authorizationEndpoint, tokenEndpoint, userInfoEndpoint, skipUserInfoEndpoint, jwksEndpoint, } = body let clientSecret = rawClientSecret if (rawClientSecret === REDACTED_MARKER) { const ownerClause = orgId ? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId)) : and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.userId, session.user.id)) const [existing] = await db .select({ oidcConfig: ssoProvider.oidcConfig }) .from(ssoProvider) .where(ownerClause) .limit(1) if (!existing?.oidcConfig) { return NextResponse.json( { error: 'Cannot update: existing provider not found. Re-enter your client secret.' }, { status: 400 } ) } try { clientSecret = JSON.parse(existing.oidcConfig).clientSecret } catch { return NextResponse.json( { error: 'Cannot update: failed to read existing secret. Re-enter your client secret.', }, { status: 400 } ) } } const oidcConfig: any = { clientId, clientSecret, scopes: Array.isArray(scopes) ? scopes.filter((s: string) => s !== 'offline_access') : ['openid', 'profile', 'email'].filter((s: string) => s !== 'offline_access'), pkce: pkce ?? true, } oidcConfig.authorizationEndpoint = authorizationEndpoint oidcConfig.tokenEndpoint = tokenEndpoint oidcConfig.userInfoEndpoint = userInfoEndpoint oidcConfig.jwksEndpoint = jwksEndpoint const userProvidedEndpoints: Record = { authorizationEndpoint, tokenEndpoint, jwksEndpoint, ...(skipUserInfoEndpoint ? {} : { userInfoEndpoint }), } for (const [name, endpointUrl] of Object.entries(userProvidedEndpoints)) { if (endpointUrl) { const endpointValidation = await validateUrlWithDNS(endpointUrl, `OIDC ${name}`) if (!endpointValidation.isValid) { logger.warn('Explicitly provided OIDC endpoint failed SSRF validation', { endpoint: name, url: endpointUrl, error: endpointValidation.error, }) return NextResponse.json( { error: `OIDC ${name} failed security validation: ${endpointValidation.error}`, }, { status: 400 } ) } } } const needsDiscovery = !oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration` const discoveryResult = await fetchOIDCDiscoveryDocument(discoveryUrl) if (needsDiscovery) { logger.info('Fetching OIDC discovery document for missing endpoints', { discoveryUrl, hasAuthEndpoint: !!oidcConfig.authorizationEndpoint, hasTokenEndpoint: !!oidcConfig.tokenEndpoint, hasJwksEndpoint: !!oidcConfig.jwksEndpoint, }) if (!discoveryResult.ok) { logger.error('Failed to fetch OIDC discovery document', { discoveryResult }) return NextResponse.json( { error: `Failed to fetch OIDC discovery document: ${discoveryResult.error}. Provide all endpoints explicitly or verify the issuer URL.`, }, { status: 400 } ) } const { discovery } = discoveryResult const discoveredEndpoints: Record = { authorization_endpoint: discovery.authorization_endpoint, token_endpoint: discovery.token_endpoint, jwks_uri: discovery.jwks_uri, ...(skipUserInfoEndpoint ? {} : { userinfo_endpoint: discovery.userinfo_endpoint }), } for (const [key, value] of Object.entries(discoveredEndpoints)) { if (typeof value === 'string') { const endpointValidation = await validateUrlWithDNS(value, `OIDC ${key}`) if (!endpointValidation.isValid) { logger.warn('OIDC discovered endpoint failed SSRF validation', { endpoint: key, url: value, error: endpointValidation.error, }) return NextResponse.json( { error: `Discovered OIDC ${key} failed security validation: ${endpointValidation.error}`, }, { status: 400 } ) } } } oidcConfig.authorizationEndpoint = oidcConfig.authorizationEndpoint || discovery.authorization_endpoint oidcConfig.tokenEndpoint = oidcConfig.tokenEndpoint || discovery.token_endpoint oidcConfig.userInfoEndpoint = oidcConfig.userInfoEndpoint || discovery.userinfo_endpoint oidcConfig.jwksEndpoint = oidcConfig.jwksEndpoint || discovery.jwks_uri oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod( discovery.token_endpoint_auth_methods_supported, oidcConfig.tokenEndpointAuthentication ) logger.info('Merged OIDC endpoints (user-provided + discovery)', { providerId, issuer, authorizationEndpoint: oidcConfig.authorizationEndpoint, tokenEndpoint: oidcConfig.tokenEndpoint, userInfoEndpoint: oidcConfig.userInfoEndpoint, jwksEndpoint: oidcConfig.jwksEndpoint, tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication, }) } else { logger.info('Using explicitly provided OIDC endpoints (all present)', { providerId, issuer, authorizationEndpoint: oidcConfig.authorizationEndpoint, tokenEndpoint: oidcConfig.tokenEndpoint, userInfoEndpoint: oidcConfig.userInfoEndpoint, jwksEndpoint: oidcConfig.jwksEndpoint, }) if (!discoveryResult.ok) { logger.info('OIDC discovery unavailable; falling back to the default token auth method', { providerId, discoveryUrl, }) } oidcConfig.tokenEndpointAuthentication = selectTokenEndpointAuthMethod( discoveryResult.ok ? discoveryResult.discovery.token_endpoint_auth_methods_supported : undefined, oidcConfig.tokenEndpointAuthentication ) } if (skipUserInfoEndpoint) { oidcConfig.userInfoEndpoint = undefined logger.info('Skipping UserInfo endpoint for provider, claims will come from the ID token', { providerId, }) } if ( !oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint ) { const missing: string[] = [] if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint') if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint') if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint') logger.error('Missing required OIDC endpoints after discovery merge', { missing, authorizationEndpoint: oidcConfig.authorizationEndpoint, tokenEndpoint: oidcConfig.tokenEndpoint, jwksEndpoint: oidcConfig.jwksEndpoint, }) return NextResponse.json( { error: `Missing required OIDC endpoints: ${missing.join(', ')}. Please provide these explicitly or verify the issuer supports OIDC discovery.`, }, { status: 400 } ) } oidcConfig.skipDiscovery = true providerConfig.oidcConfig = oidcConfig } else if (providerType === 'saml') { const { entryPoint, cert, callbackUrl, audience, wantAssertionsSigned, signatureAlgorithm, digestAlgorithm, identifierFormat, idpMetadata, } = body const computedCallbackUrl = callbackUrl || `${getBaseUrl()}/api/auth/sso/saml2/callback/${providerId}` const escapeXml = (str: string) => str.replace(/[<>&"']/g, (c) => { switch (c) { case '<': return '<' case '>': return '>' case '&': return '&' case '"': return '"' case "'": return ''' default: return c } }) const spMetadataXml = ` ` const certBase64 = cert .replace(/-----BEGIN CERTIFICATE-----/g, '') .replace(/-----END CERTIFICATE-----/g, '') .replace(/\s/g, '') const computedIdpMetadataXml = idpMetadata || ` ${certBase64} ` const samlConfig: any = { entryPoint, cert, callbackUrl: computedCallbackUrl, spMetadata: { metadata: spMetadataXml, }, idpMetadata: { metadata: computedIdpMetadataXml, }, } if (audience) samlConfig.audience = audience if (wantAssertionsSigned !== undefined) samlConfig.wantAssertionsSigned = wantAssertionsSigned if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm if (identifierFormat) samlConfig.identifierFormat = identifierFormat providerConfig.samlConfig = samlConfig } logger.info('Calling Better Auth registerSSOProvider with config:', { providerId: providerConfig.providerId, domain: providerConfig.domain, hasOidcConfig: !!providerConfig.oidcConfig, hasSamlConfig: !!providerConfig.samlConfig, samlConfigKeys: providerConfig.samlConfig ? Object.keys(providerConfig.samlConfig) : [], fullConfig: JSON.stringify( { ...providerConfig, oidcConfig: providerConfig.oidcConfig ? { ...providerConfig.oidcConfig, clientSecret: REDACTED_MARKER, } : undefined, samlConfig: providerConfig.samlConfig ? { ...providerConfig.samlConfig, cert: REDACTED_MARKER, } : undefined, }, null, 2 ), }) if (await findDomainConflict()) { logger.warn('Rejected SSO registration: domain was claimed during registration', { domain, orgId, userId: session.user.id, }) return domainConflictResponse() } const registration = await auth.api.registerSSOProvider({ body: providerConfig, headers, }) logger.info('SSO provider registered successfully', { providerId, providerType, domain, }) return NextResponse.json({ success: true, providerId: registration.providerId, providerType, message: `${providerType.toUpperCase()} provider registered successfully`, }) } catch (error) { logger.error('Failed to register SSO provider', { error, errorMessage: getErrorMessage(error, 'Unknown error'), errorStack: error instanceof Error ? error.stack : undefined, errorDetails: JSON.stringify(error), }) return NextResponse.json( { error: 'Failed to register SSO provider', details: getErrorMessage(error, 'Unknown error'), }, { status: 500 } ) } })