forked from wangziqi/gongxue-base
test: add supabase jwks auth regression
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 容器部署说明。
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user