Skip to content

Commit 4391432

Browse files
authored
feat: add PGFLOW_AUTH_SECRET support for worker authentication (#604)
# Add PGFLOW_AUTH_SECRET support for worker authentication This PR adds support for a dedicated `PGFLOW_AUTH_SECRET` to authenticate worker function calls, addressing JWT format mismatches between vault secrets and Edge Functions. ## Changes: - Added `pgflow_auth_secret` support in the `ensure_workers()` function with fallback to `supabase_service_role_key` - Updated Edge Worker authentication to check for `PGFLOW_AUTH_SECRET` before falling back to `SUPABASE_SERVICE_ROLE_KEY` - Added comprehensive unit tests for the new authentication flow - Updated documentation with clear instructions for configuring secrets in both vault and Edge Functions - Added troubleshooting guidance for authentication issues This change is backward compatible with existing deployments using `supabase_service_role_key`, while providing a more reliable authentication method for new deployments. Solves #603
1 parent e215ca3 commit 4391432

File tree

9 files changed

+407
-10
lines changed

9 files changed

+407
-10
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@pgflow/core': patch
3+
'@pgflow/edge-worker': patch
4+
---
5+
6+
Add PGFLOW_AUTH_SECRET support to bypass JWT format mismatch in ensure_workers authentication

pkgs/core/schemas/0059_function_ensure_workers.sql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ as $$
1414

1515
-- Get credentials: Local mode uses hardcoded URL, production uses vault secrets
1616
-- Empty strings are treated as NULL using nullif()
17+
-- pgflow_auth_secret takes priority over supabase_service_role_key for production auth
1718
credentials as (
1819
select
1920
case
2021
when (select is_local from env) then null
21-
else nullif((select decrypted_secret from vault.decrypted_secrets where name = 'supabase_service_role_key'), '')
22+
else coalesce(
23+
nullif((select decrypted_secret from vault.decrypted_secrets where name = 'pgflow_auth_secret'), ''),
24+
nullif((select decrypted_secret from vault.decrypted_secrets where name = 'supabase_service_role_key'), '')
25+
)
2226
end as service_role_key,
2327
case
2428
when (select is_local from env) then 'http://kong:8000/functions/v1'
@@ -105,6 +109,6 @@ comment on function pgflow.ensure_workers() is
105109
In local mode: pings ALL enabled functions (ignores debounce AND alive workers check).
106110
In production mode: only pings functions that pass debounce AND have no alive workers.
107111
Debounce: skips functions pinged within their debounce interval (production only).
108-
Credentials: Uses Vault secrets (supabase_service_role_key, supabase_project_id) or local fallbacks.
112+
Credentials: Uses Vault secrets (pgflow_auth_secret with fallback to supabase_service_role_key, supabase_project_id) or local fallbacks.
109113
URL is built from project_id: https://{project_id}.supabase.co/functions/v1
110114
Returns request_id from pg_net for each HTTP request made.';
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
-- Modify "ensure_workers" function
2+
CREATE OR REPLACE FUNCTION "pgflow"."ensure_workers" () RETURNS TABLE ("function_name" text, "invoked" boolean, "request_id" bigint) LANGUAGE sql AS $$
3+
with
4+
-- Detect environment
5+
env as (
6+
select pgflow.is_local() as is_local
7+
),
8+
9+
-- Get credentials: Local mode uses hardcoded URL, production uses vault secrets
10+
-- Empty strings are treated as NULL using nullif()
11+
-- pgflow_auth_secret takes priority over supabase_service_role_key for production auth
12+
credentials as (
13+
select
14+
case
15+
when (select is_local from env) then null
16+
else coalesce(
17+
nullif((select decrypted_secret from vault.decrypted_secrets where name = 'pgflow_auth_secret'), ''),
18+
nullif((select decrypted_secret from vault.decrypted_secrets where name = 'supabase_service_role_key'), '')
19+
)
20+
end as service_role_key,
21+
case
22+
when (select is_local from env) then 'http://kong:8000/functions/v1'
23+
else (select 'https://' || nullif(decrypted_secret, '') || '.supabase.co/functions/v1' from vault.decrypted_secrets where name = 'supabase_project_id')
24+
end as base_url
25+
),
26+
27+
-- Find functions that pass the debounce check
28+
debounce_passed as (
29+
select wf.function_name, wf.debounce
30+
from pgflow.worker_functions as wf
31+
where wf.enabled = true
32+
and (
33+
wf.last_invoked_at is null
34+
or wf.last_invoked_at < now() - wf.debounce
35+
)
36+
),
37+
38+
-- Find functions that have at least one alive worker
39+
functions_with_alive_workers as (
40+
select distinct w.function_name
41+
from pgflow.workers as w
42+
inner join debounce_passed as dp on w.function_name = dp.function_name
43+
where w.stopped_at is null
44+
and w.deprecated_at is null
45+
and w.last_heartbeat_at > now() - dp.debounce
46+
),
47+
48+
-- Determine which functions should be invoked
49+
-- Local mode: all enabled functions (bypass debounce AND alive workers check)
50+
-- Production mode: only functions that pass debounce AND have no alive workers
51+
functions_to_invoke as (
52+
select wf.function_name
53+
from pgflow.worker_functions as wf
54+
where wf.enabled = true
55+
and (
56+
pgflow.is_local() = true -- Local: all enabled functions
57+
or (
58+
-- Production: debounce + no alive workers
59+
wf.function_name in (select dp.function_name from debounce_passed as dp)
60+
and wf.function_name not in (select faw.function_name from functions_with_alive_workers as faw)
61+
)
62+
)
63+
),
64+
65+
-- Make HTTP requests and capture request_ids
66+
http_requests as (
67+
select
68+
fti.function_name,
69+
net.http_post(
70+
url => c.base_url || '/' || fti.function_name,
71+
headers => case
72+
when e.is_local then '{}'::jsonb
73+
else jsonb_build_object(
74+
'Content-Type', 'application/json',
75+
'Authorization', 'Bearer ' || c.service_role_key
76+
)
77+
end,
78+
body => '{}'::jsonb
79+
) as request_id
80+
from functions_to_invoke as fti
81+
cross join credentials as c
82+
cross join env as e
83+
where c.base_url is not null
84+
and (e.is_local or c.service_role_key is not null)
85+
),
86+
87+
-- Update last_invoked_at for invoked functions
88+
updated as (
89+
update pgflow.worker_functions as wf
90+
set last_invoked_at = clock_timestamp()
91+
from http_requests as hr
92+
where wf.function_name = hr.function_name
93+
returning wf.function_name
94+
)
95+
96+
select u.function_name, true as invoked, hr.request_id
97+
from updated as u
98+
inner join http_requests as hr on u.function_name = hr.function_name
99+
$$;
100+
-- Set comment to function: "ensure_workers"
101+
COMMENT ON FUNCTION "pgflow"."ensure_workers" IS 'Ensures worker functions are running by pinging them via HTTP when needed.
102+
In local mode: pings ALL enabled functions (ignores debounce AND alive workers check).
103+
In production mode: only pings functions that pass debounce AND have no alive workers.
104+
Debounce: skips functions pinged within their debounce interval (production only).
105+
Credentials: Uses Vault secrets (pgflow_auth_secret with fallback to supabase_service_role_key, supabase_project_id) or local fallbacks.
106+
URL is built from project_id: https://{project_id}.supabase.co/functions/v1
107+
Returns request_id from pg_net for each HTTP request made.';

pkgs/core/supabase/migrations/atlas.sum

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
h1:dzKOHL+hbunxWTZaGOIDWQG9THDva7Pk7VVDGASJkps=
1+
h1:q21PG0IM91BaFRI7KM/qP5t76ER7If38sCU9TieHenI=
22
20250429164909_pgflow_initial.sql h1:I3n/tQIg5Q5nLg7RDoU3BzqHvFVjmumQxVNbXTPG15s=
33
20250517072017_pgflow_fix_poll_for_tasks_to_use_separate_statement_for_polling.sql h1:wTuXuwMxVniCr3ONCpodpVWJcHktoQZIbqMZ3sUHKMY=
44
20250609105135_pgflow_add_start_tasks_and_started_status.sql h1:ggGanW4Wyt8Kv6TWjnZ00/qVb3sm+/eFVDjGfT8qyPg=
@@ -17,3 +17,4 @@ h1:dzKOHL+hbunxWTZaGOIDWQG9THDva7Pk7VVDGASJkps=
1717
20251225163110_pgflow_add_flow_input_column.sql h1:734uCbTgKmPhTK3TY56uNYZ31T8u59yll9ea7nwtEoc=
1818
20260103145141_pgflow_step_output_storage.sql h1:mgVHSFDLdtYy//SZ6C03j9Str1iS9xCM8Rz/wyFwn3o=
1919
20260120205547_pgflow_requeue_stalled_tasks.sql h1:4wCBBvjtETCgJf1eXmlH5wCTKDUhiLi0uzsFG1V528E=
20+
20260124113408_pgflow_auth_secret_support.sql h1:i/s1JkBqRElN6FOYFQviJt685W08SuSo30aP25lNlLc=
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
-- Test: ensure_workers() uses pgflow_auth_secret with fallback to supabase_service_role_key
2+
begin;
3+
select plan(3);
4+
select pgflow_tests.reset_db();
5+
6+
-- Setup: Create project ID secret (needed for all tests)
7+
select vault.create_secret('testproject123', 'supabase_project_id');
8+
9+
-- Setup: Register a worker function
10+
select pgflow.track_worker_function('my-function');
11+
12+
-- Simulate production mode
13+
set local app.settings.jwt_secret = 'production-secret-different-from-local';
14+
15+
-- =============================================================================
16+
-- TEST 1: pgflow_auth_secret takes priority when both are set
17+
-- =============================================================================
18+
select vault.create_secret('pgflow-auth-secret-value', 'pgflow_auth_secret');
19+
select vault.create_secret('legacy-service-role-key', 'supabase_service_role_key');
20+
21+
update pgflow.worker_functions
22+
set last_invoked_at = now() - interval '10 seconds';
23+
24+
select * into temporary test1_result from pgflow.ensure_workers();
25+
26+
select ok(
27+
(select headers->>'Authorization' = 'Bearer pgflow-auth-secret-value'
28+
from net.http_request_queue
29+
where id = (select request_id from test1_result limit 1)),
30+
'pgflow_auth_secret takes priority over supabase_service_role_key'
31+
);
32+
33+
drop table test1_result;
34+
35+
-- Cleanup secrets for next test
36+
delete from vault.secrets where name in ('pgflow_auth_secret', 'supabase_service_role_key');
37+
38+
-- =============================================================================
39+
-- TEST 2: Falls back to supabase_service_role_key when pgflow_auth_secret not set
40+
-- =============================================================================
41+
select vault.create_secret('fallback-service-role-key', 'supabase_service_role_key');
42+
43+
update pgflow.worker_functions
44+
set last_invoked_at = now() - interval '10 seconds';
45+
46+
select * into temporary test2_result from pgflow.ensure_workers();
47+
48+
select ok(
49+
(select headers->>'Authorization' = 'Bearer fallback-service-role-key'
50+
from net.http_request_queue
51+
where id = (select request_id from test2_result limit 1)),
52+
'Falls back to supabase_service_role_key when pgflow_auth_secret not set'
53+
);
54+
55+
drop table test2_result;
56+
57+
-- Cleanup secrets for next test
58+
delete from vault.secrets where name = 'supabase_service_role_key';
59+
60+
-- =============================================================================
61+
-- TEST 3: pgflow_auth_secret works without supabase_service_role_key
62+
-- =============================================================================
63+
select vault.create_secret('standalone-auth-secret', 'pgflow_auth_secret');
64+
65+
update pgflow.worker_functions
66+
set last_invoked_at = now() - interval '10 seconds';
67+
68+
select * into temporary test3_result from pgflow.ensure_workers();
69+
70+
select ok(
71+
(select headers->>'Authorization' = 'Bearer standalone-auth-secret'
72+
from net.http_request_queue
73+
where id = (select request_id from test3_result limit 1)),
74+
'pgflow_auth_secret works without supabase_service_role_key being set'
75+
);
76+
77+
drop table test3_result;
78+
79+
select finish();
80+
rollback;

pkgs/edge-worker/src/shared/authValidation.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,19 @@ export function validateServiceRoleAuth(
1616
}
1717

1818
const authHeader = request.headers.get('Authorization');
19-
const expectedKey = env['SUPABASE_SERVICE_ROLE_KEY'];
19+
20+
// Treat empty string as unset - use PGFLOW_AUTH_SECRET if set and non-empty,
21+
// otherwise fall back to SUPABASE_SERVICE_ROLE_KEY
22+
const authSecret = env['PGFLOW_AUTH_SECRET'];
23+
const serviceRoleKey = env['SUPABASE_SERVICE_ROLE_KEY'];
24+
const expectedKey = (authSecret && authSecret !== '') ? authSecret : serviceRoleKey;
2025

2126
if (!authHeader) {
2227
return { valid: false, error: 'Missing Authorization header' };
2328
}
2429

25-
if (!expectedKey) {
26-
return { valid: false, error: 'Server misconfigured: missing service role key' };
30+
if (!expectedKey || expectedKey === '') {
31+
return { valid: false, error: 'Server misconfigured: missing PGFLOW_AUTH_SECRET or SUPABASE_SERVICE_ROLE_KEY' };
2732
}
2833

2934
const expected = `Bearer ${expectedKey}`;

pkgs/edge-worker/tests/unit/shared/authValidation.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,19 @@ function productionEnv(serviceRoleKey?: string): Record<string, string | undefin
3131
};
3232
}
3333

34+
function productionEnvWithAuthSecret(
35+
authSecret?: string,
36+
serviceRoleKey?: string
37+
): Record<string, string | undefined> {
38+
return {
39+
SUPABASE_ANON_KEY: 'production-anon-key-abc',
40+
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
41+
PGFLOW_AUTH_SECRET: authSecret,
42+
};
43+
}
44+
3445
const PRODUCTION_SERVICE_ROLE_KEY = 'production-service-role-key-xyz';
46+
const PGFLOW_AUTH_SECRET_VALUE = 'user-controlled-auth-secret-123';
3547

3648
// ============================================================
3749
// validateServiceRoleAuth() - Local mode tests
@@ -80,7 +92,7 @@ Deno.test('validateServiceRoleAuth - production: accepts request with correct au
8092
Deno.test('validateServiceRoleAuth - production: rejects when service role key not configured', () => {
8193
const request = createRequest('Bearer any-key');
8294
const result = validateServiceRoleAuth(request, productionEnv(undefined));
83-
assertEquals(result, { valid: false, error: 'Server misconfigured: missing service role key' });
95+
assertEquals(result, { valid: false, error: 'Server misconfigured: missing PGFLOW_AUTH_SECRET or SUPABASE_SERVICE_ROLE_KEY' });
8496
});
8597

8698
Deno.test('validateServiceRoleAuth - production: rejects Basic auth scheme', () => {
@@ -101,6 +113,64 @@ Deno.test('validateServiceRoleAuth - production: rejects auth header without sch
101113
assertEquals(result, { valid: false, error: 'Invalid Authorization header' });
102114
});
103115

116+
// ============================================================
117+
// validateServiceRoleAuth() - PGFLOW_AUTH_SECRET tests
118+
// ============================================================
119+
120+
Deno.test('validateServiceRoleAuth - PGFLOW_AUTH_SECRET: accepts request with auth secret when set', () => {
121+
const request = createRequest(`Bearer ${PGFLOW_AUTH_SECRET_VALUE}`);
122+
const result = validateServiceRoleAuth(
123+
request,
124+
productionEnvWithAuthSecret(PGFLOW_AUTH_SECRET_VALUE, PRODUCTION_SERVICE_ROLE_KEY)
125+
);
126+
assertEquals(result, { valid: true });
127+
});
128+
129+
Deno.test('validateServiceRoleAuth - PGFLOW_AUTH_SECRET: rejects service role key when auth secret is set', () => {
130+
const request = createRequest(`Bearer ${PRODUCTION_SERVICE_ROLE_KEY}`);
131+
const result = validateServiceRoleAuth(
132+
request,
133+
productionEnvWithAuthSecret(PGFLOW_AUTH_SECRET_VALUE, PRODUCTION_SERVICE_ROLE_KEY)
134+
);
135+
assertEquals(result, { valid: false, error: 'Invalid Authorization header' });
136+
});
137+
138+
Deno.test('validateServiceRoleAuth - PGFLOW_AUTH_SECRET: falls back to service role key when auth secret not set', () => {
139+
const request = createRequest(`Bearer ${PRODUCTION_SERVICE_ROLE_KEY}`);
140+
const result = validateServiceRoleAuth(
141+
request,
142+
productionEnvWithAuthSecret(undefined, PRODUCTION_SERVICE_ROLE_KEY)
143+
);
144+
assertEquals(result, { valid: true });
145+
});
146+
147+
Deno.test('validateServiceRoleAuth - PGFLOW_AUTH_SECRET: works without service role key when auth secret is set', () => {
148+
const request = createRequest(`Bearer ${PGFLOW_AUTH_SECRET_VALUE}`);
149+
const result = validateServiceRoleAuth(
150+
request,
151+
productionEnvWithAuthSecret(PGFLOW_AUTH_SECRET_VALUE, undefined)
152+
);
153+
assertEquals(result, { valid: true });
154+
});
155+
156+
Deno.test('validateServiceRoleAuth - PGFLOW_AUTH_SECRET: returns error when neither key is set', () => {
157+
const request = createRequest('Bearer any-key');
158+
const result = validateServiceRoleAuth(
159+
request,
160+
productionEnvWithAuthSecret(undefined, undefined)
161+
);
162+
assertEquals(result, { valid: false, error: 'Server misconfigured: missing PGFLOW_AUTH_SECRET or SUPABASE_SERVICE_ROLE_KEY' });
163+
});
164+
165+
Deno.test('validateServiceRoleAuth - PGFLOW_AUTH_SECRET: treats empty string as unset, falls back to service role key', () => {
166+
const request = createRequest(`Bearer ${PRODUCTION_SERVICE_ROLE_KEY}`);
167+
const result = validateServiceRoleAuth(
168+
request,
169+
productionEnvWithAuthSecret('', PRODUCTION_SERVICE_ROLE_KEY) // Empty string
170+
);
171+
assertEquals(result, { valid: true });
172+
});
173+
104174
// ============================================================
105175
// createUnauthorizedResponse() tests
106176
// ============================================================

0 commit comments

Comments
 (0)