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);
|
||||
|
||||
@@ -6,6 +6,10 @@ const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@
|
||||
const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
const ids = {
|
||||
authUser: '00000000-0000-0000-0000-00000000a101',
|
||||
authTenantAdminUser: '00000000-0000-0000-0000-00000000a102',
|
||||
authTenantOperatorUser: '00000000-0000-0000-0000-00000000a103',
|
||||
authPlatformAdminUser: '00000000-0000-0000-0000-00000000a999',
|
||||
user: '00000000-0000-0000-0000-000000000101',
|
||||
tenantAdminUser: '00000000-0000-0000-0000-000000000102',
|
||||
tenantOperatorUser: '00000000-0000-0000-0000-000000000103',
|
||||
@@ -51,6 +55,7 @@ const ids = {
|
||||
partnerInvoice: '00000000-0000-0000-0000-000000000903',
|
||||
partnerInvoiceItem: '00000000-0000-0000-0000-000000000904',
|
||||
partnerInvoicePayment: '00000000-0000-0000-0000-000000000905',
|
||||
platformAdminUser: '00000000-0000-0000-0000-000000000999',
|
||||
};
|
||||
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
@@ -212,46 +217,83 @@ async function main() {
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
|
||||
values ($1, 'smoke_student', '13800000000', 'Smoke Student', 'student', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set username = excluded.username,
|
||||
phone = excluded.phone,
|
||||
name = excluded.name,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.user],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
|
||||
values ($1, 'smoke_tenant_admin', '13800000001', 'Smoke Tenant Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set username = excluded.username,
|
||||
phone = excluded.phone,
|
||||
name = excluded.name,
|
||||
primary_role = excluded.primary_role,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.tenantAdminUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
|
||||
insert into auth.users (
|
||||
id, aud, role, phone, phone_confirmed_at, raw_app_meta_data, raw_user_meta_data,
|
||||
created_at, updated_at
|
||||
)
|
||||
values
|
||||
($1, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb),
|
||||
($2, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb),
|
||||
($3, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb)
|
||||
($1, 'authenticated', 'authenticated', '13800000000', now(), '{"provider":"phone","providers":["phone"]}'::jsonb, '{}'::jsonb, now(), now()),
|
||||
($2, 'authenticated', 'authenticated', '13800000001', now(), '{"provider":"phone","providers":["phone"]}'::jsonb, '{}'::jsonb, now(), now()),
|
||||
($3, 'authenticated', 'authenticated', '13800000003', now(), '{"provider":"phone","providers":["phone"]}'::jsonb, '{}'::jsonb, now(), now()),
|
||||
($4, 'authenticated', 'authenticated', '13999999999', now(), '{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb, '{}'::jsonb, now(), now())
|
||||
on conflict (id)
|
||||
do update set phone = excluded.phone,
|
||||
raw_app_meta_data = excluded.raw_app_meta_data,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.authUser, ids.authTenantAdminUser, ids.authTenantOperatorUser, ids.authPlatformAdminUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
|
||||
values ($1, $2, 'smoke_student', '13800000000', 'Smoke Student', 'student', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set username = excluded.username,
|
||||
auth_user_id = excluded.auth_user_id,
|
||||
phone = excluded.phone,
|
||||
name = excluded.name,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.user, ids.authUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
|
||||
values ($1, $2, 'smoke_tenant_admin', '13800000001', 'Smoke Tenant Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set username = excluded.username,
|
||||
auth_user_id = excluded.auth_user_id,
|
||||
phone = excluded.phone,
|
||||
name = excluded.name,
|
||||
primary_role = excluded.primary_role,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||
[ids.tenantAdminUser, ids.authTenantAdminUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
|
||||
values
|
||||
($1, $2, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb),
|
||||
($3, null, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb),
|
||||
($4, null, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set username = excluded.username,
|
||||
auth_user_id = excluded.auth_user_id,
|
||||
phone = excluded.phone,
|
||||
name = excluded.name,
|
||||
primary_role = excluded.primary_role,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.tenantOperatorUser, ids.authTenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
|
||||
values ($1, $2, 'smoke_platform_admin', '13999999999', 'Smoke Platform Admin', 'platform_admin', '{"source":"smoke-seed"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set username = excluded.username,
|
||||
auth_user_id = excluded.auth_user_id,
|
||||
phone = excluded.phone,
|
||||
name = excluded.name,
|
||||
primary_role = excluded.primary_role,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.platformAdminUser, ids.authPlatformAdminUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
@@ -288,6 +330,20 @@ async function main() {
|
||||
[tenantId, ids.tenantAdminUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
|
||||
values
|
||||
($1, $2, 'tenant_operator', 'active', '{"marketing:read":true}'::jsonb),
|
||||
($1, $3, 'platform_admin', 'active', '{"*":true}'::jsonb)
|
||||
on conflict (tenant_id, user_id, role)
|
||||
do update set status = 'active',
|
||||
permissions = excluded.permissions,
|
||||
updated_at = now()
|
||||
`,
|
||||
[tenantId, ids.tenantOperatorUser, ids.platformAdminUser],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.student_profiles (tenant_id, user_id, stats, progress)
|
||||
|
||||
Reference in New Issue
Block a user