diff --git a/netlify/functions/lib/emulatable/runtime.ts b/netlify/functions/lib/emulatable/runtime.ts index 419a0eba..365c9aef 100644 --- a/netlify/functions/lib/emulatable/runtime.ts +++ b/netlify/functions/lib/emulatable/runtime.ts @@ -16,6 +16,33 @@ const PGLITE_PACKAGE_VERSION = '0.5.3' const PGLITE_SOCKET_PACKAGE_VERSION = '0.2.6' const PGLITE_PORT = 55432 const PGLITE_DATABASE_URL = `postgresql://postgres:postgres@127.0.0.1:${PGLITE_PORT}/postgres` +const AUTH_SECRET = 'loopqa_emulated_auth_secret' + +const AUTH_CLIENTS = { + google: { + clientId: 'loopqa-google-client.apps.googleusercontent.com', + clientSecret: 'loopqa-google-secret', + }, + apple: { + clientId: 'com.loopqa.emulated', + clientSecret: 'loopqa-apple-secret', + teamId: 'TEAM001', + }, + microsoft: { + clientId: 'loopqa-microsoft-client', + clientSecret: 'loopqa-microsoft-secret', + }, + okta: { + clientId: 'loopqa-okta-client', + clientSecret: 'loopqa-okta-secret', + }, + clerk: { + clientId: 'loopqa-clerk-client', + clientSecret: 'loopqa-clerk-secret', + publishableKey: 'pk_test_loopqa_clerk', + secretKey: 'sk_test_loopqa_clerk', + }, +} as const const shq = (s: string) => `'${String(s).replace(/'/g, `'\\''`)}'` const shBlock = (lines: string[]) => lines.join('\n') @@ -45,11 +72,64 @@ const EMULATE_SERVICE_ENV: Record> = { BLOB_READ_WRITE_TOKEN: 'vercel_blob_rw_mystore_secret', }, github: { GITHUB_EMULATOR_URL: 'http://127.0.0.1:4001' }, - google: { GOOGLE_EMULATOR_URL: 'http://127.0.0.1:4002' }, + google: { + GOOGLE_EMULATOR_URL: 'http://127.0.0.1:4002', + GOOGLE_ISSUER: 'http://127.0.0.1:4002', + GOOGLE_AUTHORIZATION_URL: 'http://127.0.0.1:4002/o/oauth2/v2/auth', + GOOGLE_TOKEN_URL: 'http://127.0.0.1:4002/oauth2/token', + GOOGLE_USERINFO_URL: 'http://127.0.0.1:4002/oauth2/v2/userinfo', + GOOGLE_CLIENT_ID: AUTH_CLIENTS.google.clientId, + GOOGLE_CLIENT_SECRET: AUTH_CLIENTS.google.clientSecret, + AUTH_GOOGLE_ID: AUTH_CLIENTS.google.clientId, + AUTH_GOOGLE_SECRET: AUTH_CLIENTS.google.clientSecret, + }, slack: { SLACK_EMULATOR_URL: 'http://127.0.0.1:4003' }, - apple: { APPLE_EMULATOR_URL: 'http://127.0.0.1:4004' }, - microsoft: { MICROSOFT_EMULATOR_URL: 'http://127.0.0.1:4005' }, - okta: { OKTA_EMULATOR_URL: 'http://127.0.0.1:4006', OKTA_ISSUER: 'http://127.0.0.1:4006' }, + apple: { + APPLE_EMULATOR_URL: 'http://127.0.0.1:4004', + APPLE_ISSUER: 'http://127.0.0.1:4004', + APPLE_AUTHORIZATION_URL: 'http://127.0.0.1:4004/auth/authorize', + APPLE_TOKEN_URL: 'http://127.0.0.1:4004/auth/token', + APPLE_JWKS_URL: 'http://127.0.0.1:4004/auth/keys', + APPLE_CLIENT_ID: AUTH_CLIENTS.apple.clientId, + APPLE_CLIENT_SECRET: AUTH_CLIENTS.apple.clientSecret, + APPLE_TEAM_ID: AUTH_CLIENTS.apple.teamId, + AUTH_APPLE_ID: AUTH_CLIENTS.apple.clientId, + AUTH_APPLE_SECRET: AUTH_CLIENTS.apple.clientSecret, + }, + microsoft: { + MICROSOFT_EMULATOR_URL: 'http://127.0.0.1:4005', + MICROSOFT_ISSUER: 'http://127.0.0.1:4005', + MICROSOFT_TENANT_ID: 'common', + MICROSOFT_AUTHORITY: 'http://127.0.0.1:4005', + MICROSOFT_AUTHORIZATION_URL: 'http://127.0.0.1:4005/oauth2/v2.0/authorize', + MICROSOFT_TOKEN_URL: 'http://127.0.0.1:4005/oauth2/v2.0/token', + MICROSOFT_USERINFO_URL: 'http://127.0.0.1:4005/oidc/userinfo', + MICROSOFT_GRAPH_BASE_URL: 'http://127.0.0.1:4005/v1.0', + MICROSOFT_CLIENT_ID: AUTH_CLIENTS.microsoft.clientId, + MICROSOFT_CLIENT_SECRET: AUTH_CLIENTS.microsoft.clientSecret, + AZURE_AD_CLIENT_ID: AUTH_CLIENTS.microsoft.clientId, + AZURE_AD_CLIENT_SECRET: AUTH_CLIENTS.microsoft.clientSecret, + AZURE_AD_ISSUER: 'http://127.0.0.1:4005', + AUTH_MICROSOFT_ENTRA_ID_ID: AUTH_CLIENTS.microsoft.clientId, + AUTH_MICROSOFT_ENTRA_ID_SECRET: AUTH_CLIENTS.microsoft.clientSecret, + }, + okta: { + OKTA_EMULATOR_URL: 'http://127.0.0.1:4006', + OKTA_ISSUER: 'http://127.0.0.1:4006/oauth2/default', + OKTA_AUTHORIZATION_URL: 'http://127.0.0.1:4006/oauth2/default/v1/authorize', + OKTA_TOKEN_URL: 'http://127.0.0.1:4006/oauth2/default/v1/token', + OKTA_USERINFO_URL: 'http://127.0.0.1:4006/oauth2/default/v1/userinfo', + OKTA_CLIENT_ID: AUTH_CLIENTS.okta.clientId, + OKTA_CLIENT_SECRET: AUTH_CLIENTS.okta.clientSecret, + AUTH_OKTA_ID: AUTH_CLIENTS.okta.clientId, + AUTH_OKTA_SECRET: AUTH_CLIENTS.okta.clientSecret, + AUTH_OKTA_ISSUER: 'http://127.0.0.1:4006/oauth2/default', + AUTH0_ISSUER_BASE_URL: 'http://127.0.0.1:4006/oauth2/default', + AUTH0_CLIENT_ID: AUTH_CLIENTS.okta.clientId, + AUTH0_CLIENT_SECRET: AUTH_CLIENTS.okta.clientSecret, + AUTH0_AUDIENCE: 'api://default', + AUTH0_SECRET: AUTH_SECRET, + }, aws: { AWS_EMULATOR_URL: 'http://127.0.0.1:4007' }, resend: { RESEND_EMULATOR_URL: 'http://127.0.0.1:4008', @@ -60,7 +140,21 @@ const EMULATE_SERVICE_ENV: Record> = { }, stripe: { STRIPE_API_BASE: 'http://127.0.0.1:4009', STRIPE_SECRET_KEY: 'sk_test_emulate' }, mongoatlas: { MONGOATLAS_EMULATOR_URL: 'http://127.0.0.1:4010', MONGODB_URI: 'mongodb://127.0.0.1:4010/emulate' }, - clerk: { CLERK_EMULATOR_URL: 'http://127.0.0.1:4011' }, + clerk: { + CLERK_EMULATOR_URL: 'http://127.0.0.1:4011', + CLERK_ISSUER: 'http://127.0.0.1:4011', + CLERK_OIDC_ISSUER: 'http://127.0.0.1:4011', + CLERK_AUTHORIZATION_URL: 'http://127.0.0.1:4011/oauth/authorize', + CLERK_TOKEN_URL: 'http://127.0.0.1:4011/oauth/token', + CLERK_USERINFO_URL: 'http://127.0.0.1:4011/oauth/userinfo', + CLERK_CLIENT_ID: AUTH_CLIENTS.clerk.clientId, + CLERK_CLIENT_SECRET: AUTH_CLIENTS.clerk.clientSecret, + CLERK_PUBLISHABLE_KEY: AUTH_CLIENTS.clerk.publishableKey, + CLERK_SECRET_KEY: AUTH_CLIENTS.clerk.secretKey, + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: AUTH_CLIENTS.clerk.publishableKey, + AUTH_CLERK_ID: AUTH_CLIENTS.clerk.clientId, + AUTH_CLERK_SECRET: AUTH_CLIENTS.clerk.clientSecret, + }, linear: { LINEAR_EMULATOR_URL: 'http://127.0.0.1:4012' }, twilio: { TWILIO_EMULATOR_URL: 'http://127.0.0.1:4013', @@ -110,6 +204,94 @@ export function buildEmulatedEmailAccessPrompt(services: EmulateService[]): stri return notes.join('\n\n') } +function hasAuthService(services: EmulateService[]): boolean { + return services.some((service) => ['google', 'apple', 'microsoft', 'okta', 'clerk'].includes(service)) +} + +function callbackUris(appTargetUrl: string, slugs: string[]): string[] { + return slugs.flatMap((slug) => [ + `${appTargetUrl}/api/auth/callback/${slug}`, + `${appTargetUrl}/auth/callback/${slug}`, + ]) +} + +function buildAuthServiceSeed(service: EmulateService, appTargetUrl: string): Record | null { + switch (service) { + case 'google': + return { + oauth_clients: [ + { + client_id: AUTH_CLIENTS.google.clientId, + client_secret: AUTH_CLIENTS.google.clientSecret, + name: 'Loop QA Google', + redirect_uris: callbackUris(appTargetUrl, ['google']), + }, + ], + } + case 'apple': + return { + oauth_clients: [ + { + client_id: AUTH_CLIENTS.apple.clientId, + team_id: AUTH_CLIENTS.apple.teamId, + name: 'Loop QA Apple', + redirect_uris: callbackUris(appTargetUrl, ['apple']), + }, + ], + } + case 'microsoft': + return { + oauth_clients: [ + { + client_id: AUTH_CLIENTS.microsoft.clientId, + client_secret: AUTH_CLIENTS.microsoft.clientSecret, + name: 'Loop QA Microsoft', + redirect_uris: callbackUris(appTargetUrl, ['microsoft', 'microsoft-entra-id', 'azure-ad']), + }, + ], + } + case 'okta': + return { + oauth_clients: [ + { + client_id: AUTH_CLIENTS.okta.clientId, + client_secret: AUTH_CLIENTS.okta.clientSecret, + name: 'Loop QA Okta', + redirect_uris: callbackUris(appTargetUrl, ['okta', 'auth0']), + auth_server_id: 'default', + }, + ], + } + case 'clerk': + return { + oauth_applications: [ + { + client_id: AUTH_CLIENTS.clerk.clientId, + client_secret: AUTH_CLIENTS.clerk.clientSecret, + name: 'Loop QA Clerk', + redirect_uris: callbackUris(appTargetUrl, ['clerk']), + }, + ], + } + default: + return null + } +} + +function buildEmulateServiceEnv(service: EmulateService, appTargetUrl: string): Record { + const env = { ...EMULATE_SERVICE_ENV[service] } + if (hasAuthService([service])) { + env.AUTH_URL = appTargetUrl + env.NEXTAUTH_URL = appTargetUrl + env.AUTH_SECRET = AUTH_SECRET + env.NEXTAUTH_SECRET = AUTH_SECRET + if (service === 'okta') { + env.AUTH0_BASE_URL = appTargetUrl + } + } + return env +} + function parsePort(value: string | undefined): number | null { if (!value) return null const port = Number(value) @@ -401,24 +583,31 @@ console.log(JSON.stringify({ /** Bash that clones the app, starts emulate + the app on a local port in the background, and waits for it. */ export function buildEmulateSetup(input: EmulateSetupInput): string { const emuEnv: Record = {} - for (const svc of input.services) { - Object.assign(emuEnv, EMULATE_SERVICE_ENV[svc]) - } - const emuExports = Object.entries(emuEnv).map(([k, v]) => `export ${k}=${shq(v)}`).join('; ') const startupScript = input.startupScript?.trim() || DEFAULT_STARTUP_SCRIPT const appPort = getEmulatedAppPort(startupScript) const appTargetUrl = `http://localhost:${appPort}` + for (const svc of input.services) { + Object.assign(emuEnv, buildEmulateServiceEnv(svc, appTargetUrl)) + } + const emuExports = Object.entries(emuEnv).map(([k, v]) => `export ${k}=${shq(v)}`).join('; ') const startupScriptB64 = Buffer.from(startupScript, 'utf8').toString('base64') const ports = Object.fromEntries(input.services.map((service) => [service, EMULATE_SERVICE_PORT[service]])) + const serviceSeeds = Object.fromEntries( + input.services + .map((service) => [service, buildAuthServiceSeed(service, appTargetUrl)] as const) + .filter((entry): entry is readonly [EmulateService, Record] => entry[1] !== null), + ) const emulatorScript = ` import { createEmulator } from 'emulate' const services = ${JSON.stringify(input.services)} const ports = ${JSON.stringify(ports)} +const serviceSeeds = ${JSON.stringify(serviceSeeds)} const emulators = [] for (const service of services) { - const emulator = await createEmulator({ service, port: ports[service] }) + const seed = serviceSeeds[service] ? { [service]: serviceSeeds[service] } : undefined + const emulator = await createEmulator({ service, port: ports[service], seed }) console.log(\`[emulate] \${service} \${emulator.url}\`) emulators.push(emulator) } diff --git a/scripts/check-emulatable-runtime.ts b/scripts/check-emulatable-runtime.ts index 6f24068a..9a1d12e6 100644 --- a/scripts/check-emulatable-runtime.ts +++ b/scripts/check-emulatable-runtime.ts @@ -82,3 +82,28 @@ assert.match(twilioPrompt, /use `\+15550002222`/) assert.match(twilioPrompt, /node \/tmp\/loopqa-twilio-messages\.mjs \+15550002222/) assert.match(twilioPrompt, /latest local Verify code/) assert.equal(buildEmulatedEmailAccessPrompt(['stripe']), '') + +const authSetup = buildEmulateSetup({ + repoUrl: 'https://github.com/BLamy/GrooveCart', + services: ['google', 'apple', 'microsoft', 'okta', 'clerk'], + startupScript: netlifyStartupScript, +}) + +assert.match(authSetup, /GOOGLE_CLIENT_ID='loopqa-google-client\.apps\.googleusercontent\.com'/) +assert.match(authSetup, /APPLE_CLIENT_ID='com\.loopqa\.emulated'/) +assert.match(authSetup, /MICROSOFT_CLIENT_ID='loopqa-microsoft-client'/) +assert.match(authSetup, /OKTA_ISSUER='http:\/\/127\.0\.0\.1:4006\/oauth2\/default'/) +assert.match(authSetup, /AUTH0_ISSUER_BASE_URL='http:\/\/127\.0\.0\.1:4006\/oauth2\/default'/) +assert.match(authSetup, /CLERK_CLIENT_ID='loopqa-clerk-client'/) +assert.match(authSetup, /NEXTAUTH_URL='http:\/\/localhost:18080'/) +assert.match(authSetup, /AUTH_SECRET='loopqa_emulated_auth_secret'/) +assert.match(authSetup, /loopqa-google-client\.apps\.googleusercontent\.com/) + +const emulatorScriptMatch = /printf %s '([^']+)' \| base64 -d > \/tmp\/loopqa-emulators\/start-emulators\.mjs/.exec(authSetup) +assert.ok(emulatorScriptMatch?.[1], 'emulator script is embedded as base64') +const emulatorScript = Buffer.from(emulatorScriptMatch[1], 'base64').toString('utf8') +assert.match(emulatorScript, /serviceSeeds\[service\] \? \{ \[service\]: serviceSeeds\[service\] \} : undefined/) +assert.match(emulatorScript, /http:\/\/localhost:18080\/api\/auth\/callback\/google/) +assert.match(emulatorScript, /http:\/\/localhost:18080\/api\/auth\/callback\/microsoft-entra-id/) +assert.match(emulatorScript, /http:\/\/localhost:18080\/api\/auth\/callback\/auth0/) +assert.match(emulatorScript, /http:\/\/localhost:18080\/api\/auth\/callback\/clerk/)