forked from wangziqi/gongxue-base
test: add supabase jwks auth regression
This commit is contained in:
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import pg from 'pg';
|
||||
import { SignJWT } from 'jose';
|
||||
import { SignJWT, exportJWK } from 'jose';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
@@ -79,6 +79,9 @@ let serverProcess = null;
|
||||
let serverLogs = '';
|
||||
let legacyDisabledServer = null;
|
||||
let legacyDisabledServerLogs = '';
|
||||
let jwksAuthServer = null;
|
||||
let jwksAuthServerLogs = '';
|
||||
let jwksServer = null;
|
||||
let fakeWechatServer = null;
|
||||
let fakeQqServer = null;
|
||||
let fakeWechatPayServer = null;
|
||||
@@ -311,6 +314,64 @@ async function startLegacyDisabledServer() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
async function startJwksAuthServer(jwksUrl) {
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
jwksAuthServerLogs = '';
|
||||
jwksAuthServer = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192',
|
||||
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536',
|
||||
AUTH_JWT_JWKS_URL: jwksUrl,
|
||||
AUTH_JWT_ISSUER: 'https://auth.gongxue100.test/auth/v1',
|
||||
AUTH_JWT_AUDIENCE: 'authenticated',
|
||||
ALLOW_LEGACY_AUTH_HEADERS: 'false',
|
||||
ALLOW_PLATFORM_ADMIN_KEY: 'false',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
jwksAuthServer.stdout.on('data', chunk => {
|
||||
jwksAuthServerLogs += chunk.toString();
|
||||
});
|
||||
jwksAuthServer.stderr.on('data', chunk => {
|
||||
jwksAuthServerLogs += chunk.toString();
|
||||
});
|
||||
|
||||
await waitForHealthAt(baseUrl, () => jwksAuthServerLogs);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
async function startLocalJwksServer(jwksPayload) {
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
jwksServer = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
if (url.pathname !== '/auth/v1/.well-known/jwks.json') {
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not_found' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'cache-control': 'public, max-age=600',
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
res.end(JSON.stringify(jwksPayload));
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
jwksServer.once('error', reject);
|
||||
jwksServer.listen(port, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
return `${baseUrl}/auth/v1/.well-known/jwks.json`;
|
||||
}
|
||||
|
||||
async function startFakeWechatServer() {
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
@@ -690,6 +751,30 @@ async function createSupabaseJwt(authUserId, options = {}) {
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
async function createSupabaseJwksJwt(authUserId, privateKey, options = {}) {
|
||||
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: 'RS256', typ: 'JWT', kid: options.kid || 'local-jwks-key-1' })
|
||||
.setIssuer('https://auth.gongxue100.test/auth/v1')
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 60 * 60)
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
async function waitForProcessExit(child, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -969,6 +1054,59 @@ async function testSupabaseJwtIdentity() {
|
||||
assert.equal(studentPlatformDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student Supabase JWT must not access platform APIs');
|
||||
}
|
||||
|
||||
async function testSupabaseJwksIdentity() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicJwk = await exportJWK(publicKey);
|
||||
publicJwk.kid = 'local-jwks-key-1';
|
||||
publicJwk.alg = 'RS256';
|
||||
publicJwk.use = 'sig';
|
||||
|
||||
const jwksUrl = await startLocalJwksServer({ keys: [publicJwk] });
|
||||
const baseUrl = await startJwksAuthServer(jwksUrl);
|
||||
|
||||
const studentJwt = await createSupabaseJwksJwt(AUTH_USER_ID, privateKey, { phone: '13800000000' });
|
||||
const studentHeaders = { authorization: `Bearer ${studentJwt}` };
|
||||
const me = await requestAt(baseUrl, '/api/auth/me', {
|
||||
userId: false,
|
||||
headers: studentHeaders,
|
||||
});
|
||||
assert.equal(me.user?.id, USER_ID, 'JWKS RS256 JWT should map Supabase auth user to platform user');
|
||||
assert.equal(me.session?.source, 'supabase_jwt', 'JWKS JWT auth/me should expose Supabase JWT session source');
|
||||
|
||||
const tenantHeaderJwt = await createSupabaseJwksJwt(AUTH_USER_ID, privateKey, { tenantId: false });
|
||||
const tenantHeaderProfile = await requestAt(baseUrl, '/api/profile/me', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${tenantHeaderJwt}` },
|
||||
});
|
||||
assert.equal(tenantHeaderProfile.item?.userId, USER_ID, 'JWKS JWT without tenant claim should be scoped by x-tenant-id');
|
||||
|
||||
const tenantAdminJwt = await createSupabaseJwksJwt(AUTH_TENANT_ADMIN_USER_ID, privateKey, { phone: '13800000001' });
|
||||
const tenantOverview = await requestAt(baseUrl, '/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 JWKS JWT');
|
||||
|
||||
const platformAdminJwt = await createSupabaseJwksJwt(AUTH_PLATFORM_ADMIN_USER_ID, privateKey, {
|
||||
phone: '13999999999',
|
||||
appRole: 'platform_admin',
|
||||
});
|
||||
const platformOverview = await requestAt(baseUrl, '/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${platformAdminJwt}` },
|
||||
});
|
||||
assert.ok(platformOverview.item?.tenants?.total >= 1, 'platform admin should access platform overview through JWKS JWT');
|
||||
|
||||
const badKidJwt = await createSupabaseJwksJwt(AUTH_USER_ID, privateKey, { kid: 'unknown-key-id' });
|
||||
const badKid = await requestAt(baseUrl, '/api/profile/me', {
|
||||
userId: false,
|
||||
headers: { authorization: `Bearer ${badKidJwt}` },
|
||||
expectStatus: 401,
|
||||
});
|
||||
assert.equal(badKid.code, 'AUTH_SESSION_INVALID', 'JWKS JWT with an unknown kid must be rejected');
|
||||
}
|
||||
|
||||
async function testLegacyAuthHeadersDisabled() {
|
||||
const login = await loginBySms('13800000006');
|
||||
const baseUrl = await startLegacyDisabledServer();
|
||||
@@ -999,6 +1137,13 @@ function stopServer() {
|
||||
if (legacyDisabledServer && !legacyDisabledServer.killed) {
|
||||
legacyDisabledServer.kill();
|
||||
}
|
||||
if (jwksAuthServer && !jwksAuthServer.killed) {
|
||||
jwksAuthServer.kill();
|
||||
}
|
||||
if (jwksServer) {
|
||||
jwksServer.close();
|
||||
jwksServer = null;
|
||||
}
|
||||
if (fakeWechatServer) {
|
||||
fakeWechatServer.close();
|
||||
fakeWechatServer = null;
|
||||
@@ -7654,6 +7799,7 @@ async function main() {
|
||||
await check('trusted session identity', testTrustedSessionIdentity);
|
||||
await check('phone binding', testPhoneBinding);
|
||||
await check('Supabase JWT identity', testSupabaseJwtIdentity);
|
||||
await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity);
|
||||
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
|
||||
await check('catalog and learning', testCatalogAndLearning);
|
||||
await check('composite practice questions', testCompositePracticeQuestions);
|
||||
|
||||
Reference in New Issue
Block a user