test: add supabase jwks auth regression

This commit is contained in:
Codex
2026-06-30 01:45:29 +08:00
parent 3bd2ac1799
commit 3e741a2a23
9 changed files with 215 additions and 9 deletions

View File

@@ -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);

View File

@@ -29,6 +29,7 @@ const safeApiEnv = {
AUTH_CODE_PEPPER: 's3cure-prod-code-pepper-2026-06-30-abcdef',
AUTH_SESSION_SECRET: 's3cure-prod-session-secret-2026-06-30-ghijkl',
AUTH_JWT_JWKS_URL: 'https://auth.gongxue100.com/auth/v1/.well-known/jwks.json',
AUTH_JWT_ISSUER: 'https://auth.gongxue100.com/auth/v1',
ALLOW_LEGACY_AUTH_HEADERS: 'false',
ALLOW_PLATFORM_ADMIN_KEY: 'false',
PLATFORM_ADMIN_API_KEY: 's3cure-platform-admin-key-2026-06-30-mnopqr',

View File

@@ -59,6 +59,7 @@ AUTH_SMS_PROVIDER=aliyun
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=${strongSecretC}
@@ -92,4 +93,34 @@ assert.ok(
'env-only readiness should explicitly warn that DB checks are skipped',
);
const missingJwksIssuer = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
AUTH_SMS_PROVIDER=aliyun
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=${strongSecretC}
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=tiku-assets
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=cn-hangzhou
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
`);
assert.notEqual(missingJwksIssuer.status, 0, 'JWKS readiness without issuer should fail');
assert.ok(
missingJwksIssuer.payload.checks?.some(item => item.id === 'env.auth_jwt_issuer' && item.status === 'blocker'),
'JWKS readiness should block missing AUTH_JWT_ISSUER',
);
console.log('[PASS] production readiness check script');

View File

@@ -202,6 +202,11 @@ function validateEnv() {
} else {
pass('env.auth_jwt_jwks_url', 'AUTH_JWT_JWKS_URL is configured');
}
if (!env('AUTH_JWT_ISSUER', '').trim()) {
block('env.auth_jwt_issuer', 'AUTH_JWT_ISSUER is required when AUTH_JWT_JWKS_URL is configured');
} else {
pass('env.auth_jwt_issuer', 'AUTH_JWT_ISSUER is configured for JWKS verification');
}
} else if (isUnsafeSecret(jwtSecret, DEFAULT_AUTH_JWT_SECRET)) {
block('env.auth_jwt_secret', 'AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification');
} else {