Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-auth-rollouts.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion src/adapters/minikube/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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],
};
}
169 changes: 169 additions & 0 deletions src/adapters/minikube/auth/oauthRuntime.ts
Original file line number Diff line number Diff line change
@@ -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.
`;
}
143 changes: 143 additions & 0 deletions src/adapters/minikube/auth/supabase/oauthRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
36 changes: 36 additions & 0 deletions src/adapters/minikube/auth/supabase/providerAssignments.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
3 changes: 2 additions & 1 deletion src/adapters/minikube/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading