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

@@ -22,6 +22,10 @@ MAX_IMPORT_JSON_BODY_BYTES=10485760
AUTH_SMS_PROVIDER=mock AUTH_SMS_PROVIDER=mock
AUTH_CODE_PEPPER=replace_with_a_long_random_secret AUTH_CODE_PEPPER=replace_with_a_long_random_secret
AUTH_SESSION_SECRET=replace_with_another_long_random_secret AUTH_SESSION_SECRET=replace_with_another_long_random_secret
# 生产推荐使用 Supabase Auth JWKS
# AUTH_JWT_ISSUER=https://<project-ref>.supabase.co/auth/v1
# AUTH_JWT_JWKS_URL=https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
# 自托管或旧项目兼容才使用强随机 AUTH_JWT_SECRET配置 JWKS 时必须同时配置 issuer。
AUTH_JWT_ISSUER= AUTH_JWT_ISSUER=
AUTH_JWT_AUDIENCE=authenticated AUTH_JWT_AUDIENCE=authenticated
AUTH_JWT_SECRET=development-jwt-secret-change-me AUTH_JWT_SECRET=development-jwt-secret-change-me

View File

@@ -86,6 +86,9 @@ function validateProductionConfig(nextConfig: ApiConfig) {
if (!nextConfig.authJwtJwksUrl && isUnsafeSecret(nextConfig.authJwtSecret, DEFAULT_AUTH_JWT_SECRET)) { if (!nextConfig.authJwtJwksUrl && isUnsafeSecret(nextConfig.authJwtSecret, DEFAULT_AUTH_JWT_SECRET)) {
failures.push('AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification'); failures.push('AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification');
} }
if (nextConfig.authJwtJwksUrl && !nextConfig.authJwtIssuer.trim()) {
failures.push('AUTH_JWT_ISSUER is required when AUTH_JWT_JWKS_URL is configured');
}
if (isUnsafeSecret(nextConfig.platformAdminApiKey, DEFAULT_PLATFORM_ADMIN_API_KEY)) { if (isUnsafeSecret(nextConfig.platformAdminApiKey, DEFAULT_PLATFORM_ADMIN_API_KEY)) {
failures.push('PLATFORM_ADMIN_API_KEY must be a strong production secret until platform JWT is implemented'); failures.push('PLATFORM_ADMIN_API_KEY must be a strong production secret until platform JWT is implemented');
} }

View File

@@ -783,7 +783,9 @@ export async function adjustmentVoucherReportRoute(ctx: RequestContext) {
` `
select status, count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents" select status, count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents"
from public.commerce_adjustment_vouchers from public.commerce_adjustment_vouchers
where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') where tenant_id = $1
and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and created_at < (($3::date + interval '1 day')::timestamp at time zone 'Asia/Shanghai')
group by status group by status
order by status order by status
`, `,
@@ -794,7 +796,9 @@ export async function adjustmentVoucherReportRoute(ctx: RequestContext) {
select adjustment_type as "adjustmentType", direction, select adjustment_type as "adjustmentType", direction,
count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents" count(*)::int as count, coalesce(sum(amount_cents), 0)::int as "amountCents"
from public.commerce_adjustment_vouchers from public.commerce_adjustment_vouchers
where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') where tenant_id = $1
and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and created_at < (($3::date + interval '1 day')::timestamp at time zone 'Asia/Shanghai')
group by adjustment_type, direction group by adjustment_type, direction
order by adjustment_type, direction order by adjustment_type, direction
`, `,
@@ -805,7 +809,9 @@ export async function adjustmentVoucherReportRoute(ctx: RequestContext) {
select source_type as "sourceType", count(*)::int as count, select source_type as "sourceType", count(*)::int as count,
coalesce(sum(amount_cents), 0)::int as "amountCents" coalesce(sum(amount_cents), 0)::int as "amountCents"
from public.commerce_adjustment_vouchers from public.commerce_adjustment_vouchers
where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') where tenant_id = $1
and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and created_at < (($3::date + interval '1 day')::timestamp at time zone 'Asia/Shanghai')
group by source_type group by source_type
order by source_type order by source_type
`, `,
@@ -813,11 +819,13 @@ export async function adjustmentVoucherReportRoute(ctx: RequestContext) {
), ),
query<Record<string, unknown>>( query<Record<string, unknown>>(
` `
select created_at::date::text as date, status, count(*)::int as count, select (created_at at time zone 'Asia/Shanghai')::date::text as date, status, count(*)::int as count,
coalesce(sum(amount_cents), 0)::int as "amountCents" coalesce(sum(amount_cents), 0)::int as "amountCents"
from public.commerce_adjustment_vouchers from public.commerce_adjustment_vouchers
where tenant_id = $1 and created_at >= $2::date and created_at < ($3::date + interval '1 day') where tenant_id = $1
group by created_at::date, status and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and created_at < (($3::date + interval '1 day')::timestamp at time zone 'Asia/Shanghai')
group by (created_at at time zone 'Asia/Shanghai')::date, status
order by date asc, status order by date asc, status
`, `,
[auth.tenantId, startDate, endDate], [auth.tenantId, startDate, endDate],

View File

@@ -75,7 +75,7 @@
### P0上云测试和前端主链路前必须处理 ### P0上云测试和前端主链路前必须处理
- 生产鉴权API 已支持 Supabase Auth JWT继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id``x-platform-admin-key` 兼容入口。 - 生产鉴权API 已支持 Supabase Auth JWT、HS256 本地测试和 JWKS/RS256 集成回归;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id``x-platform-admin-key` 兼容入口。生产配置 JWKS 时必须同时配置 `AUTH_JWT_ISSUER`
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。 - 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
- 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。 - 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
- 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 - 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。

View File

@@ -21,7 +21,7 @@
- `Authorization: Bearer <tk_session>` 会优先解析 `app_private.auth_sessions`,并作为用户身份来源。 - `Authorization: Bearer <tk_session>` 会优先解析 `app_private.auth_sessions`,并作为用户身份来源。
- `Authorization: Bearer <supabase_access_token>` 已支持服务端验签,后端通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射到业务用户和租户成员。 - `Authorization: Bearer <supabase_access_token>` 已支持服务端验签,后端通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射到业务用户和租户成员。
- Supabase JWT 支持 `AUTH_JWT_SECRET``AUTH_JWT_JWKS_URL`;生产推荐优先配置 Supabase Auth JWKS或在自托管兼容模式下配置强随机 JWT secret。 - Supabase JWT 支持 `AUTH_JWT_SECRET``AUTH_JWT_JWKS_URL`;生产推荐优先配置 Supabase Auth JWKS`AUTH_JWT_ISSUER`,或在自托管兼容模式下配置强随机 JWT secret。配置 JWKS 但缺少 issuer 会被生产 fail-fast 阻断。
- JWT 可以在 `app_metadata.tenant_id` 或请求租户上下文中确定当前租户;如果两者冲突,后端拒绝,不允许前端覆盖 token 中的租户声明。 - JWT 可以在 `app_metadata.tenant_id` 或请求租户上下文中确定当前租户;如果两者冲突,后端拒绝,不允许前端覆盖 token 中的租户声明。
- 登录后如果请求中的 `x-user-id`、query/body `userId` 与 session 用户不一致,后端返回 `AUTH_USER_MISMATCH` - 登录后如果请求中的 `x-user-id`、query/body `userId` 与 session 用户不一致,后端返回 `AUTH_USER_MISMATCH`
- 登录后如果请求中的 `x-tenant-id` 与 session 租户不一致,后端返回 `AUTH_TENANT_MISMATCH` - 登录后如果请求中的 `x-tenant-id` 与 session 租户不一致,后端返回 `AUTH_TENANT_MISMATCH`
@@ -42,6 +42,13 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
1. 正式用户鉴权 1. 正式用户鉴权
- 已支持服务端 session 和 Supabase Auth JWT 解析可信 userId。 - 已支持服务端 session 和 Supabase Auth JWT 解析可信 userId。
- 生产前必须用真实 Supabase Auth 项目或自托管 Auth 实例跑一轮云端 JWT 回归。 - 生产前必须用真实 Supabase Auth 项目或自托管 Auth 实例跑一轮云端 JWT 回归。
- 生产推荐配置:
```text
AUTH_JWT_JWKS_URL=https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
AUTH_JWT_ISSUER=https://<project-ref>.supabase.co/auth/v1
AUTH_JWT_AUDIENCE=authenticated
```
- `npm run test:api` 已覆盖本地 HS256 JWT 和本地 JWKS/RS256 验签路径;真实云端仍需用线上 Supabase access token 调 `GET /api/auth/me`、`GET /api/profile/me`、租户后台和平台后台各一条接口。
- 前端禁止通过 query/body/header 指定 userId。 - 前端禁止通过 query/body/header 指定 userId。
- `GET /api/auth/me` 已支持 Supabase JWT后续要补租户成员、角色、权限返回。 - `GET /api/auth/me` 已支持 Supabase JWT后续要补租户成员、角色、权限返回。
@@ -60,6 +67,7 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
- `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。 - `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。
- 禁止默认 `AUTH_SESSION_SECRET`。 - 禁止默认 `AUTH_SESSION_SECRET`。
- 禁止默认 `AUTH_JWT_SECRET`,除非配置了 `AUTH_JWT_JWKS_URL`。 - 禁止默认 `AUTH_JWT_SECRET`,除非配置了 `AUTH_JWT_JWKS_URL`。
- 配置 `AUTH_JWT_JWKS_URL` 时必须同时配置 `AUTH_JWT_ISSUER`。
- 禁止默认 `PLATFORM_ADMIN_API_KEY`。 - 禁止默认 `PLATFORM_ADMIN_API_KEY`。
- 禁止 `CORS_ORIGIN=*`。 - 禁止 `CORS_ORIGIN=*`。
- 禁止 `AUTH_SMS_PROVIDER=mock`。 - 禁止 `AUTH_SMS_PROVIDER=mock`。

View File

@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process';
import http from 'node:http'; import http from 'node:http';
import net from 'node:net'; import net from 'node:net';
import pg from 'pg'; import pg from 'pg';
import { SignJWT } from 'jose'; import { SignJWT, exportJWK } from 'jose';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
@@ -79,6 +79,9 @@ let serverProcess = null;
let serverLogs = ''; let serverLogs = '';
let legacyDisabledServer = null; let legacyDisabledServer = null;
let legacyDisabledServerLogs = ''; let legacyDisabledServerLogs = '';
let jwksAuthServer = null;
let jwksAuthServerLogs = '';
let jwksServer = null;
let fakeWechatServer = null; let fakeWechatServer = null;
let fakeQqServer = null; let fakeQqServer = null;
let fakeWechatPayServer = null; let fakeWechatPayServer = null;
@@ -311,6 +314,64 @@ async function startLegacyDisabledServer() {
return baseUrl; 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() { async function startFakeWechatServer() {
const port = await getFreePort(); const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
@@ -690,6 +751,30 @@ async function createSupabaseJwt(authUserId, options = {}) {
.sign(secret); .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) { async function waitForProcessExit(child, timeoutMs = 5000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => { 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'); 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() { async function testLegacyAuthHeadersDisabled() {
const login = await loginBySms('13800000006'); const login = await loginBySms('13800000006');
const baseUrl = await startLegacyDisabledServer(); const baseUrl = await startLegacyDisabledServer();
@@ -999,6 +1137,13 @@ function stopServer() {
if (legacyDisabledServer && !legacyDisabledServer.killed) { if (legacyDisabledServer && !legacyDisabledServer.killed) {
legacyDisabledServer.kill(); legacyDisabledServer.kill();
} }
if (jwksAuthServer && !jwksAuthServer.killed) {
jwksAuthServer.kill();
}
if (jwksServer) {
jwksServer.close();
jwksServer = null;
}
if (fakeWechatServer) { if (fakeWechatServer) {
fakeWechatServer.close(); fakeWechatServer.close();
fakeWechatServer = null; fakeWechatServer = null;
@@ -7654,6 +7799,7 @@ async function main() {
await check('trusted session identity', testTrustedSessionIdentity); await check('trusted session identity', testTrustedSessionIdentity);
await check('phone binding', testPhoneBinding); await check('phone binding', testPhoneBinding);
await check('Supabase JWT identity', testSupabaseJwtIdentity); await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity);
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
await check('catalog and learning', testCatalogAndLearning); await check('catalog and learning', testCatalogAndLearning);
await check('composite practice questions', testCompositePracticeQuestions); 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_CODE_PEPPER: 's3cure-prod-code-pepper-2026-06-30-abcdef',
AUTH_SESSION_SECRET: 's3cure-prod-session-secret-2026-06-30-ghijkl', 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_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_LEGACY_AUTH_HEADERS: 'false',
ALLOW_PLATFORM_ADMIN_KEY: 'false', ALLOW_PLATFORM_ADMIN_KEY: 'false',
PLATFORM_ADMIN_API_KEY: 's3cure-platform-admin-key-2026-06-30-mnopqr', 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_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB} AUTH_SESSION_SECRET=${strongSecretB}
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json 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_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=${strongSecretC} PLATFORM_ADMIN_API_KEY=${strongSecretC}
@@ -92,4 +93,34 @@ assert.ok(
'env-only readiness should explicitly warn that DB checks are skipped', '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'); console.log('[PASS] production readiness check script');

View File

@@ -202,6 +202,11 @@ function validateEnv() {
} else { } else {
pass('env.auth_jwt_jwks_url', 'AUTH_JWT_JWKS_URL is configured'); 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)) { } 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'); block('env.auth_jwt_secret', 'AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification');
} else { } else {