forked from wangziqi/gongxue-base
feat: add supabase jwt auth context
This commit is contained in:
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { SignJWT } from 'jose';
|
||||
|
||||
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const MAIN_TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
|
||||
@@ -12,6 +13,10 @@ const TENANT_ADMIN_USER_ID = process.env.TENANT_ADMIN_USER_ID || '00000000-0000-
|
||||
const TENANT_OPERATOR_USER_ID = '00000000-0000-0000-0000-000000000103';
|
||||
const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104';
|
||||
const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105';
|
||||
const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101';
|
||||
const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102';
|
||||
const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999';
|
||||
const AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
|
||||
const START_SERVER = process.argv.includes('--start-server');
|
||||
const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true';
|
||||
|
||||
@@ -346,6 +351,30 @@ function signAlipayParams(params) {
|
||||
return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64');
|
||||
}
|
||||
|
||||
async function createSupabaseJwt(authUserId, options = {}) {
|
||||
const secret = new TextEncoder().encode(options.secret || AUTH_JWT_SECRET);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claims = {
|
||||
sub: authUserId,
|
||||
aud: 'authenticated',
|
||||
role: 'authenticated',
|
||||
phone: options.phone || undefined,
|
||||
app_metadata: {
|
||||
provider: 'phone',
|
||||
providers: ['phone'],
|
||||
...(options.appRole ? { app_role: options.appRole } : {}),
|
||||
...(options.tenantId === false ? {} : { tenant_id: options.tenantId || MAIN_TENANT_ID }),
|
||||
},
|
||||
user_metadata: options.userMetadata || {},
|
||||
};
|
||||
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 60 * 60)
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
async function waitForProcessExit(child, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -482,6 +511,88 @@ async function testTrustedSessionIdentity() {
|
||||
assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers');
|
||||
}
|
||||
|
||||
async function testSupabaseJwtIdentity() {
|
||||
const studentJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' });
|
||||
const studentHeaders = { authorization: `Bearer ${studentJwt}` };
|
||||
|
||||
const me = await request('/api/auth/me', {
|
||||
userId: false,
|
||||
headers: studentHeaders,
|
||||
});
|
||||
assert.equal(me.user?.id, USER_ID, 'auth/me should map Supabase auth user to platform user');
|
||||
assert.equal(me.session?.source, 'supabase_jwt', 'auth/me should expose Supabase JWT session source');
|
||||
|
||||
const profile = await request('/api/profile/me', {
|
||||
userId: false,
|
||||
headers: studentHeaders,
|
||||
});
|
||||
assert.equal(profile.item?.userId, USER_ID, 'profile should accept Supabase JWT identity');
|
||||
|
||||
const tenantHeaderJwt = await createSupabaseJwt(AUTH_USER_ID, { tenantId: false });
|
||||
const tenantHeaderProfile = await request('/api/profile/me', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${tenantHeaderJwt}` },
|
||||
});
|
||||
assert.equal(tenantHeaderProfile.item?.userId, USER_ID, 'tenant header should scope Supabase JWT when token has no tenant claim');
|
||||
|
||||
const missingTenant = await request('/api/profile/me', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${tenantHeaderJwt}` },
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(missingTenant.code, 'TENANT_ID_REQUIRED', 'Supabase JWT without tenant context must not guess a tenant');
|
||||
|
||||
const mismatchedTenant = await request('/api/profile/me', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: false,
|
||||
headers: studentHeaders,
|
||||
expectStatus: 401,
|
||||
});
|
||||
assert.equal(mismatchedTenant.code, 'AUTH_SESSION_INVALID', 'Supabase JWT tenant claim must not be overwritten by request tenant');
|
||||
|
||||
const badSignatureJwt = await createSupabaseJwt(AUTH_USER_ID, { secret: 'wrong-development-jwt-secret-change-me' });
|
||||
const badSignature = await request('/api/profile/me', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${badSignatureJwt}` },
|
||||
expectStatus: 401,
|
||||
});
|
||||
assert.equal(badSignature.code, 'AUTH_SESSION_INVALID', 'bad Supabase JWT signatures must be rejected');
|
||||
|
||||
const tenantAdminJwt = await createSupabaseJwt(AUTH_TENANT_ADMIN_USER_ID, { phone: '13800000001' });
|
||||
const tenantOverview = await request('/api/tenant-admin/overview', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${tenantAdminJwt}` },
|
||||
});
|
||||
assert.equal(tenantOverview.item?.id, MAIN_TENANT_ID, 'tenant admin should access own tenant through Supabase JWT');
|
||||
|
||||
const studentTenantAdminDenied = await request('/api/tenant-admin/overview', {
|
||||
userId: false,
|
||||
headers: studentHeaders,
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(studentTenantAdminDenied.code, 'TENANT_ADMIN_REQUIRED', 'student Supabase JWT must not access tenant admin APIs');
|
||||
|
||||
const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, {
|
||||
phone: '13999999999',
|
||||
appRole: 'platform_admin',
|
||||
});
|
||||
const platformOverview = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${platformAdminJwt}` },
|
||||
});
|
||||
assert.ok(platformOverview.item?.tenants?.total >= 1, 'platform admin Supabase JWT should access platform overview');
|
||||
|
||||
const studentPlatformDenied = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: studentHeaders,
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(studentPlatformDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student Supabase JWT must not access platform APIs');
|
||||
}
|
||||
|
||||
async function testLegacyAuthHeadersDisabled() {
|
||||
const login = await loginBySms('13800000006');
|
||||
const baseUrl = await startLegacyDisabledServer();
|
||||
@@ -2554,6 +2665,7 @@ async function main() {
|
||||
|
||||
await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true)));
|
||||
await check('trusted session identity', testTrustedSessionIdentity);
|
||||
await check('Supabase JWT identity', testSupabaseJwtIdentity);
|
||||
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
|
||||
await check('catalog and learning', testCatalogAndLearning);
|
||||
await check('profile', testProfile);
|
||||
|
||||
Reference in New Issue
Block a user