diff --git a/.changeset/bright-auth-rollouts.md b/.changeset/bright-auth-rollouts.md new file mode 100644 index 0000000..60e0e7b --- /dev/null +++ b/.changeset/bright-auth-rollouts.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/infra': minor +--- + +Add canonical environment-aware Auth redirect configuration and reconcile Minikube GoTrue callback settings through a forced, bounded deployment rollout. diff --git a/src/adapters/minikube/auth/index.ts b/src/adapters/minikube/auth/index.ts index 0a41b5f..d851ed9 100644 --- a/src/adapters/minikube/auth/index.ts +++ b/src/adapters/minikube/auth/index.ts @@ -1,5 +1,6 @@ import type { InfraManifestInput } from '../../../types'; import { emptyMinikubeArtifacts, type MinikubeAdapterArtifacts } from '../contracts'; +import { generateSupabaseOAuthRuntimeArtifacts } from './oauthRuntime'; import { generateSupabaseAuthArtifacts } from './supabase'; export function generateAuthProviderArtifacts(args: { @@ -19,5 +20,17 @@ export function generateAuthProviderArtifacts(args: { ); } - return generateSupabaseAuthArtifacts({ manifest, namespace }); + const providerArtifacts = generateSupabaseAuthArtifacts({ manifest, namespace }); + const oauthArtifacts = generateSupabaseOAuthRuntimeArtifacts(manifest); + + return { + files: [...providerArtifacts.files, ...oauthArtifacts.files], + resources: [...providerArtifacts.resources, ...oauthArtifacts.resources], + providerLifecycle: [ + ...providerArtifacts.providerLifecycle, + ...oauthArtifacts.providerLifecycle, + ], + envEntries: [...providerArtifacts.envEntries, ...oauthArtifacts.envEntries], + warnings: [...providerArtifacts.warnings, ...oauthArtifacts.warnings], + }; } diff --git a/src/adapters/minikube/auth/oauthRuntime.ts b/src/adapters/minikube/auth/oauthRuntime.ts new file mode 100644 index 0000000..0284bf8 --- /dev/null +++ b/src/adapters/minikube/auth/oauthRuntime.ts @@ -0,0 +1,169 @@ +import { getLocalAuthRedirectPatterns, normalizeAuthCallbackRoute } from '../../../authRedirects'; +import type { InfraManifestInput } from '../../../types'; +import type { MinikubeAdapterArtifacts, MinikubeProviderLifecycle } from '../contracts'; + +interface SupabaseOAuthRuntimeModel { + callbackRoute: string; + envPrefixes: readonly string[]; + localRedirectPatterns: readonly string[]; +} + +export function generateSupabaseOAuthRuntimeArtifacts( + manifest: InfraManifestInput, +): MinikubeAdapterArtifacts { + const oauthRuntime = resolveOAuthRuntimeModel(manifest); + if (!oauthRuntime) { + return { + files: [], + resources: [], + providerLifecycle: [], + envEntries: [], + warnings: [], + }; + } + + return { + files: [ + { + path: 'infra/minikube/auth/oauth-runtime.md', + content: getOAuthRuntimeGuide(oauthRuntime), + }, + ], + resources: [], + providerLifecycle: [getOAuthProviderLifecycle(oauthRuntime)], + envEntries: [], + warnings: [], + }; +} + +function resolveOAuthRuntimeModel(manifest: InfraManifestInput): SupabaseOAuthRuntimeModel | null { + const oauth = manifest.auth?.oauth; + if (!oauth?.enabled) return null; + + const providers = oauth.providers.filter((provider) => provider.enabled !== false); + if (providers.length === 0) return null; + + const callbackRoute = normalizeAuthCallbackRoute(oauth.callbackRoute); + const envPrefixes = providers.map((provider) => { + const prefix = provider.id + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/gu, '_') + .replace(/^_+|_+$/gu, ''); + if (!prefix) { + throw new Error( + `OAuth provider "${provider.id}" cannot be mapped to GoTrue environment keys.`, + ); + } + return prefix; + }); + + return { + callbackRoute, + envPrefixes, + localRedirectPatterns: getLocalAuthRedirectPatterns(callbackRoute), + }; +} + +function getOAuthProviderLifecycle( + oauthRuntime: SupabaseOAuthRuntimeModel, +): MinikubeProviderLifecycle { + return { + id: 'supabase-auth', + namespace: 'supabase', + endpoints: [], + readinessChecks: [ + { + label: 'GoTrue', + namespace: 'supabase', + resource: 'deployment/auth', + timeoutSeconds: 600, + }, + ], + migrationCommands: [], + reconciliationCommands: [ + { + label: 'OAuth redirect and runtime rollout reconciliation', + command: getOAuthRuntimeReconciliationCommand(oauthRuntime), + }, + ], + statusChecks: [ + { + label: 'OAuth redirect configuration', + command: getOAuthRuntimeStatusCommand(oauthRuntime), + }, + ], + }; +} + +function getOAuthRuntimeReconciliationCommand(oauthRuntime: SupabaseOAuthRuntimeModel): string { + const localPatterns = oauthRuntime.localRedirectPatterns.join(','); + const providerRedirectAssignments = oauthRuntime.envPrefixes + .map((prefix) => `GOTRUE_EXTERNAL_${prefix}_REDIRECT_URI="\${oauth_provider_callback}"`) + .join(' '); + const providerExports = oauthRuntime.envPrefixes + .map( + (prefix) => `export GOTRUE_EXTERNAL_${prefix}_REDIRECT_URI="\${oauth_provider_callback}" +write_env_value GOTRUE_EXTERNAL_${prefix}_REDIRECT_URI "\${oauth_provider_callback}"`, + ) + .join('\n'); + + return `oauth_callback_route='${oauthRuntime.callbackRoute}' +oauth_callback_path="\${oauth_callback_route#/}" +oauth_site_url="\${SITE_URL%/}" +oauth_provider_callback="\${API_EXTERNAL_URL%/}/callback" +oauth_redirect_allow_list="\${oauth_site_url},\${oauth_site_url}/\${oauth_callback_path},${localPatterns}" +if [[ -n "\${OAUTH_NATIVE_REDIRECT_URLS:-}" ]]; then + oauth_redirect_allow_list="\${oauth_redirect_allow_list},\${OAUTH_NATIVE_REDIRECT_URLS}" +fi +export ADDITIONAL_REDIRECT_URLS="\${oauth_redirect_allow_list}" +write_env_value ADDITIONAL_REDIRECT_URLS "\${oauth_redirect_allow_list}" +${providerExports} +kubectl --context "\${PROFILE}" -n supabase set env deployment/auth \\ + API_EXTERNAL_URL="\${API_EXTERNAL_URL}" \\ + GOTRUE_SITE_URL="\${SITE_URL}" \\ + GOTRUE_URI_ALLOW_LIST="\${oauth_redirect_allow_list}" \\ + GOTRUE_JWT_ISSUER="\${API_EXTERNAL_URL}" \\ + ${providerRedirectAssignments} >/dev/null +kubectl --context "\${PROFILE}" -n supabase rollout restart deployment/auth >/dev/null +kubectl --context "\${PROFILE}" -n supabase rollout status deployment/auth --timeout=600s`; +} + +function getOAuthRuntimeStatusCommand(oauthRuntime: SupabaseOAuthRuntimeModel): string { + return `oauth_callback_route='${oauthRuntime.callbackRoute}' +oauth_callback_path="\${oauth_callback_route#/}" +if [[ -n "\${API_EXTERNAL_URL:-}" ]]; then + echo "- provider supabase-auth/provider-callback: \${API_EXTERNAL_URL%/}/callback" +else + echo "- provider supabase-auth/provider-callback: unavailable" +fi +if [[ -n "\${SITE_URL:-}" ]]; then + echo "- provider supabase-auth/app-callback: \${SITE_URL%/}/\${oauth_callback_path}" +else + echo "- provider supabase-auth/app-callback: unavailable" +fi +echo "- provider supabase-auth/local-callback-patterns: ${oauthRuntime.localRedirectPatterns.join(',')}"`; +} + +function getOAuthRuntimeGuide(oauthRuntime: SupabaseOAuthRuntimeModel): string { + const localPatterns = oauthRuntime.localRedirectPatterns + .map((pattern) => `- \`${pattern}\``) + .join('\n'); + + return `# Supabase OAuth Runtime Reconciliation + +The provider callback is derived from the active project gateway as +\`\${API_EXTERNAL_URL%/}/callback\`, which resolves to the project-owned +\`/auth/v1/callback\` endpoint. GoTrue-to-app redirects use the configured callback route +\`${oauthRuntime.callbackRoute}\`. + +Local Minikube reconciliation adds only callback-scoped loopback patterns: + +${localPatterns} + +Native callback URIs may be supplied through \`OAUTH_NATIVE_REDIRECT_URLS\`. The generated +provider lifecycle writes only redirect metadata, never OAuth client secrets. It updates the +GoTrue deployment environment, forces \`deployment/auth\` to restart, and waits up to 600 +seconds for the rollout. A failed rollout stops Infra Up before its success message. +`; +} diff --git a/src/adapters/minikube/auth/supabase/oauthRuntime.test.ts b/src/adapters/minikube/auth/supabase/oauthRuntime.test.ts new file mode 100644 index 0000000..8676eaf --- /dev/null +++ b/src/adapters/minikube/auth/supabase/oauthRuntime.test.ts @@ -0,0 +1,143 @@ +import type { InfraManifest } from '@ankhorage/contracts'; +import { describe, expect, test } from 'bun:test'; + +import { generateInfrastructure } from '../../../../index'; +import { createAppManifest } from '../../../../testSupport'; +import { generateSupabaseOAuthRuntimeArtifacts } from '../oauthRuntime'; + +describe('Supabase OAuth runtime reconciliation', () => { + test('contributes callback-scoped local redirects and a bounded GoTrue restart', () => { + const artifacts = generateSupabaseOAuthRuntimeArtifacts(createOAuthManifest()); + const [lifecycle] = artifacts.providerLifecycle; + const command = lifecycle?.reconciliationCommands[0]?.command ?? ''; + const statusCommand = lifecycle?.statusChecks[0]?.command ?? ''; + const guide = getFile(artifacts.files, 'infra/minikube/auth/oauth-runtime.md'); + + expect(lifecycle?.id).toBe('supabase-auth'); + expect(lifecycle?.namespace).toBe('supabase'); + expect(lifecycle?.readinessChecks).toEqual([ + { + label: 'GoTrue', + namespace: 'supabase', + resource: 'deployment/auth', + timeoutSeconds: 600, + }, + ]); + expect(command).toContain('oauth_provider_callback="${API_EXTERNAL_URL%/}/callback"'); + expect(command).toContain('http://127.0.0.1:*/auth/callback'); + expect(command).toContain('http://localhost:*/auth/callback'); + expect(command).toContain('OAUTH_NATIVE_REDIRECT_URLS'); + expect(command).toContain('GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI="${oauth_provider_callback}"'); + expect(command).toContain('GOTRUE_URI_ALLOW_LIST="${oauth_redirect_allow_list}"'); + expect(command).toContain('rollout restart deployment/auth'); + expect(command).toContain('rollout status deployment/auth --timeout=600s'); + expect(command.indexOf('set env deployment/auth')).toBeLessThan( + command.indexOf('rollout restart deployment/auth'), + ); + expect(command.indexOf('rollout restart deployment/auth')).toBeLessThan( + command.indexOf('rollout status deployment/auth'), + ); + expect(command).not.toContain('clientSecret'); + expect(command).not.toContain('GOTRUE_EXTERNAL_GOOGLE_SECRET='); + expect(statusCommand).toContain('provider supabase-auth/provider-callback'); + expect(statusCommand).toContain('provider supabase-auth/app-callback'); + expect(guide).toContain('Supabase OAuth Runtime Reconciliation'); + expect(guide).toContain('A failed rollout stops Infra Up before its success message.'); + }); + + test('integrates reconciliation after generated runtime resources without duplicate namespaces', () => { + const result = generateInfrastructure(createOAuthManifest(), { + appManifest: createAppManifest('oauth-app'), + }); + const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh'); + const kustomization = getFile(result.files, 'infra/minikube/k8s/kustomization.yaml'); + const statusScript = getFile(result.files, 'infra/minikube/scripts/status.sh'); + + expect(kustomization.match(/namespaces\/supabase\.yaml/gu)).toHaveLength(1); + expect(upScript).toContain( + 'Running provider supabase-auth OAuth redirect and runtime rollout reconciliation.', + ); + expect(upScript).toContain( + 'kubectl --context "${PROFILE}" -n supabase set env deployment/auth', + ); + expect(upScript).toContain( + 'kubectl --context "${PROFILE}" -n supabase rollout restart deployment/auth', + ); + expect(upScript).toContain( + 'kubectl --context "${PROFILE}" -n supabase rollout status deployment/auth --timeout=600s', + ); + expect(upScript.lastIndexOf('run_provider_reconciliation')).toBeLessThan( + upScript.lastIndexOf('echo "Minikube infrastructure for \'${PROFILE}\' is running."'), + ); + expect(upScript.indexOf('credentialsRef auth/oauth/google')).toBeLessThan( + upScript.lastIndexOf('run_provider_reconciliation'), + ); + expect(statusScript).toContain('provider supabase-auth/local-callback-patterns'); + }); + + test('keeps canonical port groups distinct for concurrent projects', () => { + const first = generateInfrastructure(createOAuthManifest(), { + appManifest: createAppManifest('oauth-one'), + }); + const second = generateInfrastructure(createOAuthManifest(), { + appManifest: createAppManifest('oauth-two'), + }); + + const firstEnv = getFile(first.files, 'infra/minikube/.env.example'); + const secondEnv = getFile(second.files, 'infra/minikube/.env.example'); + const firstGatewayPort = readEnvValue(firstEnv, 'SUPABASE_GATEWAY_FORWARD_LOCAL_PORT'); + const secondGatewayPort = readEnvValue(secondEnv, 'SUPABASE_GATEWAY_FORWARD_LOCAL_PORT'); + const firstAppPort = readEnvValue(firstEnv, 'APP_PORT_FORWARD_LOCAL_PORT'); + const secondAppPort = readEnvValue(secondEnv, 'APP_PORT_FORWARD_LOCAL_PORT'); + + expect(firstGatewayPort).not.toBe(secondGatewayPort); + expect(firstAppPort).not.toBe(secondAppPort); + expect(`http://127.0.0.1:${firstGatewayPort}/auth/v1/callback`).not.toBe( + `http://127.0.0.1:${secondGatewayPort}/auth/v1/callback`, + ); + }); +}); + +function createOAuthManifest(): InfraManifest { + return { + deployment: { + target: 'minikube', + monitoring: false, + }, + auth: { + scope: 'global', + provider: 'supabase', + oauth: { + enabled: true, + callbackRoute: '/auth/callback', + providers: [ + { + id: 'google', + enabled: true, + credentialsRef: 'auth/oauth/google', + }, + ], + }, + }, + database: { + provider: 'supabase', + tier: 'dev', + }, + secretStore: { + provider: 'supabase-vault', + }, + plugins: [], + }; +} + +function getFile(files: readonly { path: string; content: string }[], path: string): string { + const file = files.find((candidate) => candidate.path === path); + if (!file) throw new Error(`Missing generated file: ${path}`); + return file.content; +} + +function readEnvValue(content: string, key: string): string { + const line = content.split('\n').find((candidate) => candidate.startsWith(`${key}=`)); + if (!line) throw new Error(`Missing generated env key: ${key}`); + return line.slice(key.length + 1); +} diff --git a/src/adapters/minikube/auth/supabase/providerAssignments.test.ts b/src/adapters/minikube/auth/supabase/providerAssignments.test.ts new file mode 100644 index 0000000..07fcfec --- /dev/null +++ b/src/adapters/minikube/auth/supabase/providerAssignments.test.ts @@ -0,0 +1,36 @@ +import type { InfraManifest } from '@ankhorage/contracts'; +import { expect, test } from 'bun:test'; + +import { generateInfrastructure } from '../../../../index'; +import { createAppManifest } from '../../../../testSupport'; + +test('keeps multiple provider redirect assignments as separate shell arguments', () => { + const manifest: InfraManifest = { + deployment: { target: 'minikube', monitoring: false }, + auth: { + scope: 'global', + provider: 'supabase', + oauth: { + enabled: true, + callbackRoute: '/auth/callback', + providers: [ + { id: 'google', enabled: true, credentialsRef: 'auth/oauth/google' }, + { id: 'apple', enabled: true, credentialsRef: 'auth/oauth/apple' }, + ], + }, + }, + database: { provider: 'supabase', tier: 'dev' }, + secretStore: { provider: 'supabase-vault' }, + plugins: [], + }; + const result = generateInfrastructure(manifest, { + appManifest: createAppManifest('multi-oauth'), + }); + const upScript = result.files.find( + (file) => file.path === 'infra/minikube/scripts/up.sh', + )?.content; + + expect(upScript).toContain('GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI="${oauth_provider_callback}"'); + expect(upScript).toContain('GOTRUE_EXTERNAL_APPLE_REDIRECT_URI="${oauth_provider_callback}"'); + expect(upScript).not.toContain('\\ GOTRUE_EXTERNAL_APPLE_REDIRECT_URI'); +}); diff --git a/src/adapters/minikube/index.ts b/src/adapters/minikube/index.ts index c0f76e6..14b24b5 100644 --- a/src/adapters/minikube/index.ts +++ b/src/adapters/minikube/index.ts @@ -12,6 +12,7 @@ import { generateSecretStoreArtifacts } from './secrets'; import { generateStorageArtifacts } from './storage'; const CANONICAL_PROJECT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; +const BUILT_IN_NAMESPACES = new Set([APP_NAMESPACE, 'supabase']); export function generateMinikubeInfra( manifest: InfraManifestInput, @@ -59,7 +60,7 @@ export function generateMinikubeInfra( ]; const providerNamespaces = unique([ ...providerLifecycle.map((contribution) => contribution.namespace), - ]); + ]).filter((providerNamespace) => !BUILT_IN_NAMESPACES.has(providerNamespace)); const extraEnvEntries = unique([ ...authArtifacts.envEntries, ...authzArtifacts.envEntries, diff --git a/src/authRedirects.test.ts b/src/authRedirects.test.ts new file mode 100644 index 0000000..b449efd --- /dev/null +++ b/src/authRedirects.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test'; + +import { getLocalAuthRedirectPatterns, resolveAuthRedirectConfiguration } from './authRedirects'; + +describe('resolveAuthRedirectConfiguration', () => { + test('derives an exact provider callback and local browser callback policy', () => { + const config = resolveAuthRedirectConfiguration({ + environment: 'local', + gatewayOrigin: 'http://127.0.0.1:18081/', + siteOrigin: 'http://127.0.0.1:18080/', + callbackRoute: 'auth/callback', + webOrigins: ['http://localhost:8081'], + nativeRedirectUris: ['ankh-demo://auth/callback'], + }); + + expect(config.providerCallbackUrl).toBe('http://127.0.0.1:18081/auth/v1/callback'); + expect(config.siteUrl).toBe('http://127.0.0.1:18080'); + expect(config.redirectAllowList).toEqual([ + 'http://127.0.0.1:18080', + 'http://localhost:8081', + 'http://127.0.0.1:18080/auth/callback', + 'http://localhost:8081/auth/callback', + 'http://127.0.0.1:*/auth/callback', + 'http://localhost:*/auth/callback', + 'ankh-demo://auth/callback', + ]); + }); + + test('never leaks local wildcard policy into preview or production', () => { + for (const environment of ['preview', 'production'] as const) { + const config = resolveAuthRedirectConfiguration({ + environment, + gatewayOrigin: 'https://api.example.test', + siteOrigin: 'https://app.example.test', + callbackRoute: '/auth/callback', + }); + + expect(config.redirectAllowList).toEqual([ + 'https://app.example.test', + 'https://app.example.test/auth/callback', + ]); + expect(config.serializedRedirectAllowList).not.toContain('localhost'); + expect(config.serializedRedirectAllowList).not.toContain('127.0.0.1'); + expect(config.serializedRedirectAllowList).not.toContain('*'); + } + }); + + test('normalizes callback routes and rejects unsafe origins', () => { + expect(getLocalAuthRedirectPatterns('//auth//callback')).toEqual([ + 'http://127.0.0.1:*/auth/callback', + 'http://localhost:*/auth/callback', + ]); + + expect(() => + resolveAuthRedirectConfiguration({ + environment: 'production', + gatewayOrigin: 'https://user:secret@example.test', + siteOrigin: 'https://app.example.test', + callbackRoute: '/auth/callback', + }), + ).toThrow('gatewayOrigin must be a canonical origin'); + }); +}); diff --git a/src/authRedirects.ts b/src/authRedirects.ts new file mode 100644 index 0000000..528edb9 --- /dev/null +++ b/src/authRedirects.ts @@ -0,0 +1,113 @@ +export type AuthRedirectEnvironment = 'local' | 'preview' | 'production'; + +export interface ResolveAuthRedirectConfigurationInput { + environment: AuthRedirectEnvironment; + gatewayOrigin: string; + siteOrigin: string; + callbackRoute: string; + webOrigins?: readonly string[]; + nativeRedirectUris?: readonly string[]; +} + +export interface AuthRedirectConfiguration { + providerCallbackUrl: string; + siteUrl: string; + redirectAllowList: readonly string[]; + serializedRedirectAllowList: string; +} + +export function resolveAuthRedirectConfiguration( + input: ResolveAuthRedirectConfigurationInput, +): AuthRedirectConfiguration { + const gatewayOrigin = normalizeWebOrigin(input.gatewayOrigin, 'gatewayOrigin'); + const siteUrl = normalizeWebOrigin(input.siteOrigin, 'siteOrigin'); + const callbackRoute = normalizeCallbackRoute(input.callbackRoute); + const webOrigins = unique([ + siteUrl, + ...(input.webOrigins ?? []).map((origin) => normalizeWebOrigin(origin, 'webOrigins')), + ]); + const nativeRedirectUris = unique( + (input.nativeRedirectUris ?? []).map((uri) => normalizeNativeRedirectUri(uri)), + ); + + const redirectAllowList = unique([ + ...webOrigins, + ...webOrigins.map((origin) => `${origin}${callbackRoute}`), + ...(input.environment === 'local' ? getLocalAuthRedirectPatterns(callbackRoute) : []), + ...nativeRedirectUris, + ]); + + return { + providerCallbackUrl: `${gatewayOrigin}/auth/v1/callback`, + siteUrl, + redirectAllowList, + serializedRedirectAllowList: redirectAllowList.join(','), + }; +} + +export function getLocalAuthRedirectPatterns(callbackRoute: string): readonly string[] { + const normalizedRoute = normalizeCallbackRoute(callbackRoute); + return [`http://127.0.0.1:*${normalizedRoute}`, `http://localhost:*${normalizedRoute}`]; +} + +export function normalizeAuthCallbackRoute(callbackRoute: string): string { + return normalizeCallbackRoute(callbackRoute); +} + +function normalizeCallbackRoute(callbackRoute: string): string { + const trimmed = callbackRoute.trim(); + if (!trimmed) { + throw new Error('Auth callback route must not be empty.'); + } + + const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + const normalized = withLeadingSlash.replace(/\/{2,}/gu, '/'); + if (normalized.includes('?') || normalized.includes('#')) { + throw new Error('Auth callback route must not include a query string or fragment.'); + } + return normalized; +} + +function normalizeWebOrigin(value: string, field: string): string { + const parsed = parseUrl(value, field); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`${field} must use http or https.`); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error(`${field} must be a canonical origin without credentials, query, or fragment.`); + } + if (parsed.pathname !== '/' && parsed.pathname !== '') { + throw new Error(`${field} must not include a path.`); + } + return parsed.origin; +} + +function normalizeNativeRedirectUri(value: string): string { + const parsed = parseUrl(value, 'nativeRedirectUris'); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + throw new Error('nativeRedirectUris must use a non-HTTP application scheme.'); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error( + 'nativeRedirectUris must not include credentials, query strings, or fragments.', + ); + } + return parsed.toString(); +} + +function parseUrl(value: string, field: string): URL { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${field} must not contain empty values.`); + } + + try { + return new URL(trimmed); + } catch { + throw new Error(`${field} contains an invalid URL.`); + } +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/index.ts b/src/index.ts index 79d3689..b6950e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,14 @@ import type { export type { ApiInfrastructureArtifacts } from './apiArtifacts'; export { generateApiInfrastructureArtifacts } from './apiArtifacts'; +export { + type AuthRedirectConfiguration, + type AuthRedirectEnvironment, + getLocalAuthRedirectPatterns, + normalizeAuthCallbackRoute, + resolveAuthRedirectConfiguration, + type ResolveAuthRedirectConfigurationInput, +} from './authRedirects'; export { createInfraSecretStoreAdapter, type CreateInfraSecretStoreAdapterInput,