Skip to content

Commit 17c5389

Browse files
authored
feat: add service role auth validation to edge worker requests (#525)
# Add service role authentication for edge worker requests This PR adds authentication validation for edge worker requests in production environments. It implements a secure mechanism to verify that incoming requests to the edge worker are authorized using the Supabase service role key. Key changes: - Added authentication validation in the `SupabasePlatformAdapter` to verify requests - Implemented `validateServiceRoleAuth()` function using timing-safe comparison to prevent timing attacks - Created `createUnauthorizedResponse()` helper to generate standardized 401 responses - Authentication is automatically bypassed in local development environments - Added comprehensive unit tests for all authentication scenarios The implementation uses the `@std/crypto/timing-safe-equal` module to perform constant-time comparison of authentication tokens, which helps prevent timing-based attacks.
1 parent 070b9bd commit 17c5389

File tree

7 files changed

+233
-1
lines changed

7 files changed

+233
-1
lines changed

pkgs/cli/supabase/functions/pgflow/deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@pgflow/edge-worker/_internal": "../_vendor/@pgflow/edge-worker/_internal.ts",
1616
"@henrygd/queue": "jsr:@henrygd/queue@^1.0.7",
1717
"@supabase/supabase-js": "jsr:@supabase/supabase-js@^2.49.4",
18-
"postgres": "npm:postgres@3.4.5"
18+
"postgres": "npm:postgres@3.4.5",
19+
"@std/crypto": "jsr:@std/crypto"
1920
}
2021
}

pkgs/edge-worker/deno.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkgs/edge-worker/deno.test.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"@henrygd/queue": "jsr:@henrygd/queue@^1.0.7",
99
"@std/assert": "jsr:@std/assert@^0.224.0",
1010
"@std/async": "jsr:@std/async@^0.224.0",
11+
"@std/crypto/timing-safe-equal": "jsr:@std/crypto@^0.224.0/timing-safe-equal",
1112
"@std/log": "jsr:@std/log@^0.224.13",
1213
"@std/testing/mock": "jsr:@std/testing@^0.224.0/mock",
1314
"postgres": "npm:postgres@3.4.5",

pkgs/edge-worker/jsr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
},
99
"imports": {
1010
"@henrygd/queue": "jsr:@henrygd/queue@^1.0.7",
11+
"@std/crypto": "jsr:@std/crypto@^1.0.5",
1112
"postgres": "npm:postgres@3.4.5",
1213
"@pgflow/core": "npm:@pgflow/core@0.9.1",
1314
"@pgflow/dsl": "npm:@pgflow/dsl@0.9.1"

pkgs/edge-worker/src/platform/SupabasePlatformAdapter.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import type { SupabaseResources } from '@pgflow/dsl/supabase';
66
import { createServiceSupabaseClient } from '../core/supabase-utils.js';
77
import { createLoggingFactory } from './logging.js';
88
import { isLocalSupabaseEnv } from '../shared/localDetection.js';
9+
import {
10+
validateServiceRoleAuth,
11+
createUnauthorizedResponse,
12+
createServerErrorResponse,
13+
} from '../shared/authValidation.js';
914
import {
1015
resolveConnectionString,
1116
resolveSqlConnection,
@@ -199,6 +204,16 @@ export class SupabasePlatformAdapter implements PlatformAdapter<SupabaseResource
199204

200205
private setupStartupHandler(createWorkerFn: CreateWorkerFn): void {
201206
Deno.serve({}, (req: Request) => {
207+
// Validate auth header in production (skipped in local mode)
208+
const authResult = validateServiceRoleAuth(req, this.validatedEnv);
209+
if (!authResult.valid) {
210+
this.logger.warn(`Auth validation failed: ${authResult.error}`);
211+
if (authResult.error?.includes('misconfigured')) {
212+
return createServerErrorResponse();
213+
}
214+
return createUnauthorizedResponse();
215+
}
216+
202217
this.logger.debug(`HTTP Request: ${this.edgeFunctionName}`);
203218

204219
const wasStarted = !this.worker;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { timingSafeEqual } from '@std/crypto/timing-safe-equal';
2+
import { isLocalSupabaseEnv } from './localDetection.ts';
3+
4+
export interface AuthValidationResult {
5+
valid: boolean;
6+
error?: string;
7+
}
8+
9+
export function validateServiceRoleAuth(
10+
request: Request,
11+
env: Record<string, string | undefined>
12+
): AuthValidationResult {
13+
// Skip validation in local mode
14+
if (isLocalSupabaseEnv(env)) {
15+
return { valid: true };
16+
}
17+
18+
const authHeader = request.headers.get('Authorization');
19+
const expectedKey = env['SUPABASE_SERVICE_ROLE_KEY'];
20+
21+
if (!authHeader) {
22+
return { valid: false, error: 'Missing Authorization header' };
23+
}
24+
25+
if (!expectedKey) {
26+
return { valid: false, error: 'Server misconfigured: missing service role key' };
27+
}
28+
29+
const expected = `Bearer ${expectedKey}`;
30+
31+
// Use constant-time comparison to prevent timing attacks
32+
const encoder = new TextEncoder();
33+
const authBytes = encoder.encode(authHeader);
34+
const expectedBytes = encoder.encode(expected);
35+
36+
// Length check first (timingSafeEqual requires same length)
37+
if (authBytes.length !== expectedBytes.length) {
38+
return { valid: false, error: 'Invalid Authorization header' };
39+
}
40+
41+
if (!timingSafeEqual(authBytes, expectedBytes)) {
42+
return { valid: false, error: 'Invalid Authorization header' };
43+
}
44+
45+
return { valid: true };
46+
}
47+
48+
export function createUnauthorizedResponse(): Response {
49+
return new Response(
50+
JSON.stringify({ error: 'Unauthorized', message: 'Unauthorized' }),
51+
{ status: 401, headers: { 'Content-Type': 'application/json' } }
52+
);
53+
}
54+
55+
export function createServerErrorResponse(): Response {
56+
return new Response(
57+
JSON.stringify({ error: 'Internal Server Error', message: 'Internal Server Error' }),
58+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
59+
);
60+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { assertEquals } from '@std/assert';
2+
import {
3+
validateServiceRoleAuth,
4+
createUnauthorizedResponse,
5+
createServerErrorResponse,
6+
} from '../../../src/shared/authValidation.ts';
7+
import {
8+
KNOWN_LOCAL_ANON_KEY,
9+
KNOWN_LOCAL_SERVICE_ROLE_KEY,
10+
} from '../../../src/shared/localDetection.ts';
11+
12+
// ============================================================
13+
// Helper functions
14+
// ============================================================
15+
16+
function createRequest(authHeader?: string): Request {
17+
const headers = new Headers();
18+
if (authHeader !== undefined) {
19+
headers.set('Authorization', authHeader);
20+
}
21+
return new Request('http://localhost/test', { headers });
22+
}
23+
24+
function localEnv(): Record<string, string | undefined> {
25+
return {
26+
SUPABASE_ANON_KEY: KNOWN_LOCAL_ANON_KEY,
27+
SUPABASE_SERVICE_ROLE_KEY: KNOWN_LOCAL_SERVICE_ROLE_KEY,
28+
};
29+
}
30+
31+
function productionEnv(serviceRoleKey?: string): Record<string, string | undefined> {
32+
return {
33+
SUPABASE_ANON_KEY: 'production-anon-key-abc',
34+
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
35+
};
36+
}
37+
38+
const PRODUCTION_SERVICE_ROLE_KEY = 'production-service-role-key-xyz';
39+
40+
// ============================================================
41+
// validateServiceRoleAuth() - Local mode tests
42+
// ============================================================
43+
44+
Deno.test('validateServiceRoleAuth - local mode: allows request without auth header', () => {
45+
const request = createRequest();
46+
const result = validateServiceRoleAuth(request, localEnv());
47+
assertEquals(result, { valid: true });
48+
});
49+
50+
Deno.test('validateServiceRoleAuth - local mode: allows request with wrong auth header', () => {
51+
const request = createRequest('Bearer wrong-key');
52+
const result = validateServiceRoleAuth(request, localEnv());
53+
assertEquals(result, { valid: true });
54+
});
55+
56+
Deno.test('validateServiceRoleAuth - local mode: allows request with correct auth header', () => {
57+
const request = createRequest(`Bearer ${KNOWN_LOCAL_SERVICE_ROLE_KEY}`);
58+
const result = validateServiceRoleAuth(request, localEnv());
59+
assertEquals(result, { valid: true });
60+
});
61+
62+
// ============================================================
63+
// validateServiceRoleAuth() - Production mode tests
64+
// ============================================================
65+
66+
Deno.test('validateServiceRoleAuth - production: rejects request without auth header', () => {
67+
const request = createRequest();
68+
const result = validateServiceRoleAuth(request, productionEnv(PRODUCTION_SERVICE_ROLE_KEY));
69+
assertEquals(result, { valid: false, error: 'Missing Authorization header' });
70+
});
71+
72+
Deno.test('validateServiceRoleAuth - production: rejects request with wrong auth header', () => {
73+
const request = createRequest('Bearer wrong-key');
74+
const result = validateServiceRoleAuth(request, productionEnv(PRODUCTION_SERVICE_ROLE_KEY));
75+
assertEquals(result, { valid: false, error: 'Invalid Authorization header' });
76+
});
77+
78+
Deno.test('validateServiceRoleAuth - production: accepts request with correct auth header', () => {
79+
const request = createRequest(`Bearer ${PRODUCTION_SERVICE_ROLE_KEY}`);
80+
const result = validateServiceRoleAuth(request, productionEnv(PRODUCTION_SERVICE_ROLE_KEY));
81+
assertEquals(result, { valid: true });
82+
});
83+
84+
Deno.test('validateServiceRoleAuth - production: rejects when service role key not configured', () => {
85+
const request = createRequest('Bearer any-key');
86+
const result = validateServiceRoleAuth(request, productionEnv(undefined));
87+
assertEquals(result, { valid: false, error: 'Server misconfigured: missing service role key' });
88+
});
89+
90+
Deno.test('validateServiceRoleAuth - production: rejects Basic auth scheme', () => {
91+
const request = createRequest(`Basic ${PRODUCTION_SERVICE_ROLE_KEY}`);
92+
const result = validateServiceRoleAuth(request, productionEnv(PRODUCTION_SERVICE_ROLE_KEY));
93+
assertEquals(result, { valid: false, error: 'Invalid Authorization header' });
94+
});
95+
96+
Deno.test('validateServiceRoleAuth - production: rejects malformed Bearer token', () => {
97+
const request = createRequest('Bearer');
98+
const result = validateServiceRoleAuth(request, productionEnv(PRODUCTION_SERVICE_ROLE_KEY));
99+
assertEquals(result, { valid: false, error: 'Invalid Authorization header' });
100+
});
101+
102+
Deno.test('validateServiceRoleAuth - production: rejects auth header without scheme', () => {
103+
const request = createRequest(PRODUCTION_SERVICE_ROLE_KEY);
104+
const result = validateServiceRoleAuth(request, productionEnv(PRODUCTION_SERVICE_ROLE_KEY));
105+
assertEquals(result, { valid: false, error: 'Invalid Authorization header' });
106+
});
107+
108+
// ============================================================
109+
// createUnauthorizedResponse() tests
110+
// ============================================================
111+
112+
Deno.test('createUnauthorizedResponse - returns 401 status', () => {
113+
const response = createUnauthorizedResponse();
114+
assertEquals(response.status, 401);
115+
});
116+
117+
Deno.test('createUnauthorizedResponse - returns JSON content type', () => {
118+
const response = createUnauthorizedResponse();
119+
assertEquals(response.headers.get('Content-Type'), 'application/json');
120+
});
121+
122+
Deno.test('createUnauthorizedResponse - returns error body', async () => {
123+
const response = createUnauthorizedResponse();
124+
const body = await response.json();
125+
assertEquals(body, { error: 'Unauthorized', message: 'Unauthorized' });
126+
});
127+
128+
// ============================================================
129+
// createServerErrorResponse() tests
130+
// ============================================================
131+
132+
Deno.test('createServerErrorResponse - returns 500 status', () => {
133+
const response = createServerErrorResponse();
134+
assertEquals(response.status, 500);
135+
});
136+
137+
Deno.test('createServerErrorResponse - returns JSON content type', () => {
138+
const response = createServerErrorResponse();
139+
assertEquals(response.headers.get('Content-Type'), 'application/json');
140+
});
141+
142+
Deno.test('createServerErrorResponse - returns error body', async () => {
143+
const response = createServerErrorResponse();
144+
const body = await response.json();
145+
assertEquals(body, { error: 'Internal Server Error', message: 'Internal Server Error' });
146+
});

0 commit comments

Comments
 (0)