d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
706 lines
24 KiB
TypeScript
706 lines
24 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
/**
|
|
* Direct Database SSO Registration Script (Better Auth Best Practice)
|
|
*
|
|
* This script bypasses the authentication requirement by directly inserting
|
|
* SSO provider records into the database, following the exact same logic
|
|
* as Better Auth's registerSSOProvider endpoint.
|
|
*
|
|
* Usage: bun run packages/db/scripts/register-sso-provider.ts
|
|
*
|
|
* Required Environment Variables:
|
|
* SSO_ENABLED=true
|
|
* SSO_PROVIDER_TYPE=oidc|saml
|
|
* SSO_PROVIDER_ID=your-provider-id
|
|
* SSO_ISSUER=https://your-idp-url
|
|
* SSO_DOMAIN=your-email-domain.com
|
|
* SSO_USER_EMAIL=admin@yourdomain.com (must be existing user)
|
|
*
|
|
* OIDC Providers:
|
|
* SSO_OIDC_CLIENT_ID=your_client_id
|
|
* SSO_OIDC_CLIENT_SECRET=your_client_secret
|
|
* SSO_OIDC_SCOPES=openid,profile,email (optional)
|
|
* SSO_OIDC_TOKEN_ENDPOINT_AUTH=client_secret_post|client_secret_basic (optional, defaults to client_secret_post)
|
|
* SSO_OIDC_SKIP_USERINFO_ENDPOINT=true (optional; reads claims from the verified ID token
|
|
* instead of calling the discovered UserInfo endpoint, matching better-auth's ID-token
|
|
* path in its OIDC callback. Use this for IdPs whose UserInfo endpoint omits claims that
|
|
* are present on the ID token, e.g. Microsoft Entra ID's Graph userinfo endpoint dropping
|
|
* `email` for some tenants)
|
|
*
|
|
* SAML Providers:
|
|
* SSO_SAML_ENTRY_POINT=https://your-idp/sso
|
|
* SSO_SAML_CERT=your-certificate-pem-string
|
|
* SSO_SAML_CALLBACK_URL=https://yourdomain.com/api/auth/sso/saml2/callback/provider-id
|
|
* SSO_SAML_SP_METADATA=<custom-sp-metadata-xml> (optional, auto-generated if not provided)
|
|
* SSO_SAML_IDP_METADATA=<idp-metadata-xml> (optional)
|
|
* SSO_SAML_AUDIENCE=https://yourdomain.com (optional, defaults to SSO_ISSUER)
|
|
* SSO_SAML_WANT_ASSERTIONS_SIGNED=true (optional, defaults to false)
|
|
*/
|
|
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { eq } from 'drizzle-orm'
|
|
import { drizzle } from 'drizzle-orm/postgres-js'
|
|
import postgres from 'postgres'
|
|
import { ssoProvider, user } from '../schema'
|
|
|
|
interface SSOMapping {
|
|
id: string
|
|
email: string
|
|
name: string
|
|
image?: string
|
|
}
|
|
|
|
interface OIDCConfig {
|
|
clientId: string
|
|
clientSecret: string
|
|
scopes?: string[]
|
|
pkce?: boolean
|
|
authorizationEndpoint?: string
|
|
tokenEndpoint?: string
|
|
userInfoEndpoint?: string
|
|
skipUserInfoEndpoint?: boolean
|
|
jwksEndpoint?: string
|
|
discoveryEndpoint?: string
|
|
tokenEndpointAuthentication?: 'client_secret_post' | 'client_secret_basic'
|
|
}
|
|
|
|
interface SAMLConfig {
|
|
issuer?: string
|
|
entryPoint: string
|
|
cert: string
|
|
callbackUrl?: string
|
|
audience?: string
|
|
wantAssertionsSigned?: boolean
|
|
signatureAlgorithm?: string
|
|
digestAlgorithm?: string
|
|
identifierFormat?: string
|
|
idpMetadata?: {
|
|
metadata?: string
|
|
entityID?: string
|
|
cert?: string
|
|
privateKey?: string
|
|
privateKeyPass?: string
|
|
isAssertionEncrypted?: boolean
|
|
encPrivateKey?: string
|
|
encPrivateKeyPass?: string
|
|
singleSignOnService?: Array<{
|
|
Binding: string
|
|
Location: string
|
|
}>
|
|
}
|
|
spMetadata?: {
|
|
metadata?: string
|
|
entityID?: string
|
|
binding?: string
|
|
privateKey?: string
|
|
privateKeyPass?: string
|
|
isAssertionEncrypted?: boolean
|
|
encPrivateKey?: string
|
|
encPrivateKeyPass?: string
|
|
}
|
|
privateKey?: string
|
|
decryptionPvk?: string
|
|
additionalParams?: Record<string, unknown>
|
|
}
|
|
|
|
interface SSOProviderConfig {
|
|
providerId: string
|
|
issuer: string
|
|
domain: string
|
|
providerType: 'oidc' | 'saml'
|
|
mapping?: SSOMapping
|
|
oidcConfig?: OIDCConfig
|
|
samlConfig?: SAMLConfig
|
|
}
|
|
|
|
const logger = {
|
|
info: (message: string, meta?: any) => {
|
|
const timestamp = new Date().toISOString()
|
|
console.log(
|
|
`[${timestamp}] [INFO] [RegisterSSODB] ${message}`,
|
|
meta ? JSON.stringify(meta, null, 2) : ''
|
|
)
|
|
},
|
|
error: (message: string, meta?: any) => {
|
|
const timestamp = new Date().toISOString()
|
|
console.error(
|
|
`[${timestamp}] [ERROR] [RegisterSSODB] ${message}`,
|
|
meta ? JSON.stringify(meta, null, 2) : ''
|
|
)
|
|
},
|
|
warn: (message: string, meta?: any) => {
|
|
const timestamp = new Date().toISOString()
|
|
console.warn(
|
|
`[${timestamp}] [WARN] [RegisterSSODB] ${message}`,
|
|
meta ? JSON.stringify(meta, null, 2) : ''
|
|
)
|
|
},
|
|
}
|
|
|
|
const CONNECTION_STRING = process.env.POSTGRES_URL ?? process.env.DATABASE_URL
|
|
if (!CONNECTION_STRING) {
|
|
console.error('❌ POSTGRES_URL or DATABASE_URL environment variable is required')
|
|
process.exit(1)
|
|
}
|
|
|
|
const postgresClient = postgres(CONNECTION_STRING, {
|
|
prepare: false,
|
|
idle_timeout: 20,
|
|
connect_timeout: 30,
|
|
max: 10,
|
|
onnotice: () => {},
|
|
})
|
|
const db = drizzle(postgresClient)
|
|
|
|
interface SSOProviderData {
|
|
id: string
|
|
issuer: string
|
|
domain: string
|
|
oidcConfig?: string
|
|
samlConfig?: string
|
|
userId: string
|
|
providerId: string
|
|
organizationId?: string
|
|
}
|
|
|
|
function buildSSOConfigFromEnv(): SSOProviderConfig | null {
|
|
const enabled = process.env.SSO_ENABLED === 'true'
|
|
if (!enabled) return null
|
|
|
|
const providerId = process.env.SSO_PROVIDER_ID
|
|
const issuer = process.env.SSO_ISSUER
|
|
const domain = process.env.SSO_DOMAIN
|
|
const providerType = process.env.SSO_PROVIDER_TYPE as 'oidc' | 'saml'
|
|
|
|
if (!providerId || !issuer || !domain || !providerType) {
|
|
return null
|
|
}
|
|
|
|
const config: SSOProviderConfig = {
|
|
providerId,
|
|
issuer,
|
|
domain,
|
|
providerType,
|
|
}
|
|
|
|
config.mapping = {
|
|
id:
|
|
process.env.SSO_MAPPING_ID ||
|
|
(providerType === 'oidc'
|
|
? 'sub'
|
|
: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'),
|
|
email:
|
|
process.env.SSO_MAPPING_EMAIL ||
|
|
(providerType === 'oidc'
|
|
? 'email'
|
|
: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'),
|
|
name:
|
|
process.env.SSO_MAPPING_NAME ||
|
|
(providerType === 'oidc'
|
|
? 'name'
|
|
: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'),
|
|
image: process.env.SSO_MAPPING_IMAGE || (providerType === 'oidc' ? 'picture' : undefined),
|
|
}
|
|
|
|
if (providerType === 'oidc') {
|
|
const clientId = process.env.SSO_OIDC_CLIENT_ID
|
|
const clientSecret = process.env.SSO_OIDC_CLIENT_SECRET
|
|
|
|
if (!clientId || !clientSecret) {
|
|
return null
|
|
}
|
|
|
|
config.oidcConfig = {
|
|
clientId,
|
|
clientSecret,
|
|
scopes: process.env.SSO_OIDC_SCOPES?.split(',').map((s) => s.trim()) || [
|
|
'openid',
|
|
'profile',
|
|
'email',
|
|
],
|
|
pkce: process.env.SSO_OIDC_PKCE !== 'false',
|
|
authorizationEndpoint: process.env.SSO_OIDC_AUTHORIZATION_ENDPOINT,
|
|
tokenEndpoint: process.env.SSO_OIDC_TOKEN_ENDPOINT,
|
|
tokenEndpointAuthentication:
|
|
process.env.SSO_OIDC_TOKEN_ENDPOINT_AUTH === 'client_secret_post' ||
|
|
process.env.SSO_OIDC_TOKEN_ENDPOINT_AUTH === 'client_secret_basic'
|
|
? process.env.SSO_OIDC_TOKEN_ENDPOINT_AUTH
|
|
: undefined,
|
|
userInfoEndpoint: process.env.SSO_OIDC_USERINFO_ENDPOINT,
|
|
skipUserInfoEndpoint: process.env.SSO_OIDC_SKIP_USERINFO_ENDPOINT === 'true',
|
|
jwksEndpoint: process.env.SSO_OIDC_JWKS_ENDPOINT,
|
|
discoveryEndpoint:
|
|
process.env.SSO_OIDC_DISCOVERY_ENDPOINT ||
|
|
`${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
|
|
}
|
|
} else if (providerType === 'saml') {
|
|
const entryPoint = process.env.SSO_SAML_ENTRY_POINT
|
|
const cert = process.env.SSO_SAML_CERT
|
|
|
|
if (!entryPoint || !cert) {
|
|
return null
|
|
}
|
|
|
|
const appBaseUrl = (
|
|
process.env.NEXT_PUBLIC_APP_URL ||
|
|
process.env.BETTER_AUTH_URL ||
|
|
''
|
|
).replace(/\/$/, '')
|
|
|
|
const escapeXml = (str: string) =>
|
|
str.replace(/[<>&"']/g, (c) => {
|
|
switch (c) {
|
|
case '<':
|
|
return '<'
|
|
case '>':
|
|
return '>'
|
|
case '&':
|
|
return '&'
|
|
case '"':
|
|
return '"'
|
|
case "'":
|
|
return '''
|
|
default:
|
|
return c
|
|
}
|
|
})
|
|
|
|
const callbackUrl =
|
|
process.env.SSO_SAML_CALLBACK_URL || `${appBaseUrl}/api/auth/sso/saml2/callback/${providerId}`
|
|
|
|
let spMetadata = process.env.SSO_SAML_SP_METADATA
|
|
if (!spMetadata) {
|
|
spMetadata = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(appBaseUrl)}">
|
|
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${escapeXml(callbackUrl)}" index="1"/>
|
|
</md:SPSSODescriptor>
|
|
</md:EntityDescriptor>`
|
|
}
|
|
|
|
const idpMetadataXml = process.env.SSO_SAML_IDP_METADATA
|
|
let computedIdpMetadata: string
|
|
if (idpMetadataXml) {
|
|
computedIdpMetadata = idpMetadataXml
|
|
} else {
|
|
const certBase64 = cert
|
|
.replace(/-----BEGIN CERTIFICATE-----/g, '')
|
|
.replace(/-----END CERTIFICATE-----/g, '')
|
|
.replace(/\s/g, '')
|
|
const escapedEntryPoint = escapeXml(entryPoint)
|
|
computedIdpMetadata = `<?xml version="1.0"?>
|
|
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${escapeXml(issuer)}">
|
|
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<KeyDescriptor use="signing">
|
|
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
|
<ds:X509Data>
|
|
<ds:X509Certificate>${certBase64}</ds:X509Certificate>
|
|
</ds:X509Data>
|
|
</ds:KeyInfo>
|
|
</KeyDescriptor>
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${escapedEntryPoint}"/>
|
|
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="${escapedEntryPoint}"/>
|
|
</IDPSSODescriptor>
|
|
</EntityDescriptor>`
|
|
}
|
|
|
|
config.samlConfig = {
|
|
issuer,
|
|
entryPoint,
|
|
cert,
|
|
callbackUrl,
|
|
audience: process.env.SSO_SAML_AUDIENCE || issuer,
|
|
wantAssertionsSigned: process.env.SSO_SAML_WANT_ASSERTIONS_SIGNED === 'true',
|
|
signatureAlgorithm: process.env.SSO_SAML_SIGNATURE_ALGORITHM,
|
|
digestAlgorithm: process.env.SSO_SAML_DIGEST_ALGORITHM,
|
|
identifierFormat: process.env.SSO_SAML_IDENTIFIER_FORMAT,
|
|
spMetadata: {
|
|
metadata: spMetadata,
|
|
entityID: appBaseUrl,
|
|
},
|
|
idpMetadata: {
|
|
metadata: computedIdpMetadata,
|
|
},
|
|
}
|
|
}
|
|
|
|
return config
|
|
}
|
|
|
|
function getExampleEnvVars(
|
|
providerType: 'oidc' | 'saml',
|
|
provider?: string
|
|
): Record<string, string> {
|
|
const baseVars = {
|
|
SSO_ENABLED: 'true',
|
|
SSO_PROVIDER_TYPE: providerType,
|
|
SSO_PROVIDER_ID: provider || (providerType === 'oidc' ? 'okta' : 'adfs'),
|
|
SSO_DOMAIN: 'yourcompany.com',
|
|
SSO_USER_EMAIL: 'admin@yourcompany.com',
|
|
}
|
|
|
|
if (providerType === 'oidc') {
|
|
const examples: Record<string, Record<string, string>> = {
|
|
okta: {
|
|
...baseVars,
|
|
SSO_PROVIDER_ID: 'okta',
|
|
SSO_ISSUER: 'https://dev-123456.okta.com/oauth2/default',
|
|
SSO_OIDC_CLIENT_ID: '0oavhncxymgOpe06E697',
|
|
SSO_OIDC_CLIENT_SECRET: 'your-client-secret',
|
|
SSO_OIDC_SCOPES: 'openid,profile,email',
|
|
},
|
|
'azure-ad': {
|
|
...baseVars,
|
|
SSO_PROVIDER_ID: 'azure-ad',
|
|
SSO_ISSUER: 'https://login.microsoftonline.com/{tenant-id}/v2.0',
|
|
SSO_OIDC_CLIENT_ID: 'your-application-id',
|
|
SSO_OIDC_CLIENT_SECRET: 'your-client-secret',
|
|
SSO_MAPPING_ID: 'oid',
|
|
SSO_OIDC_SKIP_USERINFO_ENDPOINT: 'true',
|
|
},
|
|
generic: {
|
|
...baseVars,
|
|
SSO_PROVIDER_ID: 'custom-oidc',
|
|
SSO_ISSUER: 'https://idp.example.com',
|
|
SSO_OIDC_CLIENT_ID: 'your-client-id',
|
|
SSO_OIDC_CLIENT_SECRET: 'your-client-secret',
|
|
SSO_OIDC_AUTHORIZATION_ENDPOINT: 'https://idp.example.com/auth',
|
|
SSO_OIDC_TOKEN_ENDPOINT: 'https://idp.example.com/token',
|
|
SSO_OIDC_USERINFO_ENDPOINT: 'https://idp.example.com/userinfo',
|
|
},
|
|
}
|
|
return examples[provider || 'okta'] || examples.generic
|
|
}
|
|
|
|
return {
|
|
...baseVars,
|
|
SSO_PROVIDER_ID: 'adfs',
|
|
SSO_ISSUER: 'https://adfs.company.com',
|
|
SSO_SAML_ENTRY_POINT: 'https://adfs.company.com/adfs/ls/',
|
|
SSO_SAML_CERT:
|
|
'-----BEGIN CERTIFICATE-----\nMIIDBjCCAe4CAQAwDQYJKoZIhvcNAQEFBQAwEjEQMA4GA1UEAwwHYWRmcy...\n-----END CERTIFICATE-----',
|
|
SSO_SAML_AUDIENCE: 'https://yourapp.com',
|
|
SSO_SAML_WANT_ASSERTIONS_SIGNED: 'true',
|
|
SSO_MAPPING_ID: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
|
|
SSO_MAPPING_EMAIL: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
|
SSO_MAPPING_NAME: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
|
|
}
|
|
}
|
|
|
|
async function getAdminUser(): Promise<{ id: string; email: string } | null> {
|
|
const adminEmail = process.env.SSO_USER_EMAIL
|
|
if (!adminEmail) {
|
|
logger.error('SSO_USER_EMAIL is required to identify the admin user')
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const users = await db.select().from(user).where(eq(user.email, adminEmail))
|
|
if (users.length === 0) {
|
|
logger.error(`No user found with email: ${adminEmail}`)
|
|
logger.error('Please ensure this user exists in your database first')
|
|
return null
|
|
}
|
|
return { id: users[0].id, email: users[0].email }
|
|
} catch (error) {
|
|
logger.error('Failed to query user:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function registerSSOProvider(): Promise<boolean> {
|
|
try {
|
|
const ssoConfig = buildSSOConfigFromEnv()
|
|
|
|
if (!ssoConfig) {
|
|
logger.error('❌ No valid SSO configuration found in environment variables')
|
|
logger.error('')
|
|
logger.error('📝 Required environment variables:')
|
|
logger.error('For OIDC providers (like Okta, Azure AD):')
|
|
const oidcExample = getExampleEnvVars('oidc', 'okta')
|
|
for (const [key, value] of Object.entries(oidcExample)) {
|
|
logger.error(` ${key}=${value}`)
|
|
}
|
|
logger.error(' SSO_USER_EMAIL=admin@yourdomain.com')
|
|
logger.error('')
|
|
logger.error('For SAML providers (like ADFS):')
|
|
const samlExample = getExampleEnvVars('saml')
|
|
for (const [key, value] of Object.entries(samlExample)) {
|
|
logger.error(` ${key}=${value}`)
|
|
}
|
|
logger.error(' SSO_USER_EMAIL=admin@yourdomain.com')
|
|
return false
|
|
}
|
|
|
|
const adminUser = await getAdminUser()
|
|
if (!adminUser) {
|
|
return false
|
|
}
|
|
|
|
logger.info('Registering SSO provider directly in database...', {
|
|
providerId: ssoConfig.providerId,
|
|
providerType: ssoConfig.providerType,
|
|
domain: ssoConfig.domain,
|
|
adminUser: adminUser.email,
|
|
})
|
|
|
|
try {
|
|
new URL(ssoConfig.issuer)
|
|
} catch {
|
|
logger.error('Invalid issuer. Must be a valid URL:', ssoConfig.issuer)
|
|
return false
|
|
}
|
|
|
|
if (
|
|
ssoConfig.providerType === 'saml' &&
|
|
!process.env.NEXT_PUBLIC_APP_URL &&
|
|
!process.env.BETTER_AUTH_URL
|
|
) {
|
|
logger.error(
|
|
'NEXT_PUBLIC_APP_URL or BETTER_AUTH_URL is required for SAML — it is used as the SP entity ID in SP metadata. Set one of these env vars.'
|
|
)
|
|
return false
|
|
}
|
|
|
|
if (ssoConfig.providerType === 'oidc' && ssoConfig.oidcConfig) {
|
|
const needsDiscovery =
|
|
!ssoConfig.oidcConfig.authorizationEndpoint ||
|
|
!ssoConfig.oidcConfig.tokenEndpoint ||
|
|
!ssoConfig.oidcConfig.jwksEndpoint
|
|
|
|
if (needsDiscovery) {
|
|
const discoveryUrl =
|
|
ssoConfig.oidcConfig.discoveryEndpoint ||
|
|
`${ssoConfig.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
|
|
logger.info('Fetching OIDC discovery document for missing endpoints...', {
|
|
discoveryUrl,
|
|
hasAuthEndpoint: !!ssoConfig.oidcConfig.authorizationEndpoint,
|
|
hasTokenEndpoint: !!ssoConfig.oidcConfig.tokenEndpoint,
|
|
hasJwksEndpoint: !!ssoConfig.oidcConfig.jwksEndpoint,
|
|
})
|
|
|
|
try {
|
|
const response = await fetch(discoveryUrl, {
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
|
|
if (!response.ok) {
|
|
logger.error('Failed to fetch OIDC discovery document', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
})
|
|
logger.error(
|
|
'Provide all endpoints explicitly via SSO_OIDC_AUTHORIZATION_ENDPOINT, SSO_OIDC_TOKEN_ENDPOINT, SSO_OIDC_JWKS_ENDPOINT'
|
|
)
|
|
return false
|
|
}
|
|
|
|
const discovery = await response.json()
|
|
|
|
ssoConfig.oidcConfig.authorizationEndpoint =
|
|
ssoConfig.oidcConfig.authorizationEndpoint || discovery.authorization_endpoint
|
|
ssoConfig.oidcConfig.tokenEndpoint =
|
|
ssoConfig.oidcConfig.tokenEndpoint || discovery.token_endpoint
|
|
ssoConfig.oidcConfig.userInfoEndpoint =
|
|
ssoConfig.oidcConfig.userInfoEndpoint || discovery.userinfo_endpoint
|
|
ssoConfig.oidcConfig.jwksEndpoint =
|
|
ssoConfig.oidcConfig.jwksEndpoint || discovery.jwks_uri
|
|
|
|
logger.info('Merged OIDC endpoints (user-provided + discovery)', {
|
|
authorizationEndpoint: ssoConfig.oidcConfig.authorizationEndpoint,
|
|
tokenEndpoint: ssoConfig.oidcConfig.tokenEndpoint,
|
|
userInfoEndpoint: ssoConfig.oidcConfig.userInfoEndpoint,
|
|
jwksEndpoint: ssoConfig.oidcConfig.jwksEndpoint,
|
|
})
|
|
} catch (error) {
|
|
logger.error('Error fetching OIDC discovery document', {
|
|
error: getErrorMessage(error, 'Unknown error'),
|
|
discoveryUrl,
|
|
})
|
|
logger.error(
|
|
'Please provide explicit endpoints via SSO_OIDC_AUTHORIZATION_ENDPOINT, SSO_OIDC_TOKEN_ENDPOINT, SSO_OIDC_JWKS_ENDPOINT'
|
|
)
|
|
return false
|
|
}
|
|
} else {
|
|
logger.info('Using explicitly provided OIDC endpoints (all present)', {
|
|
authorizationEndpoint: ssoConfig.oidcConfig.authorizationEndpoint,
|
|
tokenEndpoint: ssoConfig.oidcConfig.tokenEndpoint,
|
|
userInfoEndpoint: ssoConfig.oidcConfig.userInfoEndpoint,
|
|
jwksEndpoint: ssoConfig.oidcConfig.jwksEndpoint,
|
|
})
|
|
}
|
|
|
|
if (ssoConfig.oidcConfig.skipUserInfoEndpoint) {
|
|
ssoConfig.oidcConfig.userInfoEndpoint = undefined
|
|
logger.info('Skipping UserInfo endpoint: claims will be read from the verified ID token')
|
|
}
|
|
|
|
if (
|
|
!ssoConfig.oidcConfig.authorizationEndpoint ||
|
|
!ssoConfig.oidcConfig.tokenEndpoint ||
|
|
!ssoConfig.oidcConfig.jwksEndpoint
|
|
) {
|
|
const missing: string[] = []
|
|
if (!ssoConfig.oidcConfig.authorizationEndpoint)
|
|
missing.push('SSO_OIDC_AUTHORIZATION_ENDPOINT')
|
|
if (!ssoConfig.oidcConfig.tokenEndpoint) missing.push('SSO_OIDC_TOKEN_ENDPOINT')
|
|
if (!ssoConfig.oidcConfig.jwksEndpoint) missing.push('SSO_OIDC_JWKS_ENDPOINT')
|
|
|
|
logger.error('Missing required OIDC endpoints after discovery merge', {
|
|
missing,
|
|
authorizationEndpoint: ssoConfig.oidcConfig.authorizationEndpoint,
|
|
tokenEndpoint: ssoConfig.oidcConfig.tokenEndpoint,
|
|
jwksEndpoint: ssoConfig.oidcConfig.jwksEndpoint,
|
|
})
|
|
logger.error(`Please provide: ${missing.join(', ')}`)
|
|
return false
|
|
}
|
|
}
|
|
|
|
const existingProviders = await db
|
|
.select()
|
|
.from(ssoProvider)
|
|
.where(eq(ssoProvider.providerId, ssoConfig.providerId))
|
|
|
|
if (existingProviders.length > 0) {
|
|
logger.warn(`SSO provider with ID '${ssoConfig.providerId}' already exists`)
|
|
logger.info('Updating existing provider...')
|
|
}
|
|
|
|
const providerData: SSOProviderData = {
|
|
id: generateId(),
|
|
issuer: ssoConfig.issuer,
|
|
domain: ssoConfig.domain,
|
|
userId: adminUser.id,
|
|
providerId: ssoConfig.providerId,
|
|
organizationId: process.env.SSO_ORGANIZATION_ID || undefined,
|
|
}
|
|
|
|
if (ssoConfig.providerType === 'oidc' && ssoConfig.oidcConfig) {
|
|
const oidcConfig = {
|
|
issuer: ssoConfig.issuer,
|
|
clientId: ssoConfig.oidcConfig.clientId,
|
|
clientSecret: ssoConfig.oidcConfig.clientSecret,
|
|
authorizationEndpoint: ssoConfig.oidcConfig.authorizationEndpoint,
|
|
tokenEndpoint: ssoConfig.oidcConfig.tokenEndpoint,
|
|
// Default to client_secret_post: better-auth sends client_secret_basic
|
|
// credentials without URL-encoding per RFC 6749 §2.3.1, so '+' in secrets
|
|
// is decoded as space by OIDC providers, causing invalid_client errors.
|
|
tokenEndpointAuthentication:
|
|
ssoConfig.oidcConfig.tokenEndpointAuthentication ?? 'client_secret_post',
|
|
jwksEndpoint: ssoConfig.oidcConfig.jwksEndpoint,
|
|
pkce: ssoConfig.oidcConfig.pkce,
|
|
discoveryEndpoint:
|
|
ssoConfig.oidcConfig.discoveryEndpoint ||
|
|
`${ssoConfig.issuer}/.well-known/openid-configuration`,
|
|
mapping: ssoConfig.mapping,
|
|
scopes: ssoConfig.oidcConfig.scopes,
|
|
userInfoEndpoint: ssoConfig.oidcConfig.userInfoEndpoint,
|
|
overrideUserInfo: false,
|
|
}
|
|
providerData.oidcConfig = JSON.stringify(oidcConfig)
|
|
}
|
|
|
|
if (ssoConfig.providerType === 'saml' && ssoConfig.samlConfig) {
|
|
const samlConfig = {
|
|
issuer: ssoConfig.issuer,
|
|
entryPoint: ssoConfig.samlConfig.entryPoint,
|
|
cert: ssoConfig.samlConfig.cert,
|
|
callbackUrl: ssoConfig.samlConfig.callbackUrl,
|
|
audience: ssoConfig.samlConfig.audience,
|
|
idpMetadata: ssoConfig.samlConfig.idpMetadata,
|
|
spMetadata: ssoConfig.samlConfig.spMetadata,
|
|
wantAssertionsSigned: ssoConfig.samlConfig.wantAssertionsSigned,
|
|
signatureAlgorithm: ssoConfig.samlConfig.signatureAlgorithm,
|
|
digestAlgorithm: ssoConfig.samlConfig.digestAlgorithm,
|
|
identifierFormat: ssoConfig.samlConfig.identifierFormat,
|
|
privateKey: ssoConfig.samlConfig.privateKey,
|
|
decryptionPvk: ssoConfig.samlConfig.decryptionPvk,
|
|
additionalParams: ssoConfig.samlConfig.additionalParams,
|
|
mapping: ssoConfig.mapping,
|
|
}
|
|
providerData.samlConfig = JSON.stringify(samlConfig)
|
|
}
|
|
|
|
if (existingProviders.length > 0) {
|
|
await db
|
|
.update(ssoProvider)
|
|
.set({
|
|
issuer: providerData.issuer,
|
|
domain: providerData.domain,
|
|
oidcConfig: providerData.oidcConfig,
|
|
samlConfig: providerData.samlConfig,
|
|
userId: providerData.userId,
|
|
organizationId: providerData.organizationId,
|
|
})
|
|
.where(eq(ssoProvider.providerId, ssoConfig.providerId))
|
|
} else {
|
|
await db.insert(ssoProvider).values(providerData)
|
|
}
|
|
|
|
logger.info('✅ SSO provider registered successfully in database!', {
|
|
providerId: ssoConfig.providerId,
|
|
providerType: ssoConfig.providerType,
|
|
domain: ssoConfig.domain,
|
|
id: providerData.id,
|
|
})
|
|
|
|
logger.info('🔗 Users can now sign in using SSO')
|
|
|
|
const baseUrl =
|
|
process.env.NEXT_PUBLIC_APP_URL || process.env.BETTER_AUTH_URL || 'https://your-domain.com'
|
|
const callbackPath =
|
|
ssoConfig.providerType === 'saml'
|
|
? `api/auth/sso/saml2/callback/${ssoConfig.providerId}`
|
|
: `api/auth/sso/callback/${ssoConfig.providerId}`
|
|
logger.info(
|
|
`📋 Callback URL (configure this in your identity provider): ${baseUrl}/${callbackPath}`
|
|
)
|
|
|
|
return true
|
|
} catch (error) {
|
|
logger.error('❌ Failed to register SSO provider:', {
|
|
error: getErrorMessage(error, 'Unknown error'),
|
|
errorType: typeof error,
|
|
errorDetails: JSON.stringify(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
})
|
|
|
|
return false
|
|
} finally {
|
|
try {
|
|
await postgresClient.end({ timeout: 5 })
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log('🔐 Direct Database SSO Registration Script (Better Auth Best Practice)')
|
|
console.log('====================================================================')
|
|
console.log('This script directly inserts SSO provider records into the database.')
|
|
console.log("It follows Better Auth's exact registerSSOProvider logic.\n")
|
|
|
|
const success = await registerSSOProvider()
|
|
|
|
if (success) {
|
|
console.log('🎉 SSO setup completed successfully!')
|
|
console.log()
|
|
console.log('Next steps:')
|
|
console.log('1. Configure the callback URL in your identity provider')
|
|
console.log('2. Restart your application if needed')
|
|
console.log('3. Users can now sign in with SSO!')
|
|
process.exit(0)
|
|
} else {
|
|
console.log('💥 SSO setup failed. Check the logs above for details.')
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
logger.error('Script execution failed:', { error })
|
|
process.exit(1)
|
|
})
|