From f728f4d63956842dec31043a1d064d1eb9235657 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 1 Jun 2026 18:15:18 +0800 Subject: [PATCH] feat(server): add testing-only bearer token bypass for authentication --- apps/server/docs/ai-context/auth-and-oidc.md | 2 + apps/server/src/libs/env.ts | 9 +++ apps/server/src/libs/request-auth.ts | 49 +++++++++++++++ apps/server/src/libs/tests/env.test.ts | 29 +++++++++ .../src/libs/tests/request-auth.test.ts | 62 ++++++++++++++++++- 5 files changed, 150 insertions(+), 1 deletion(-) diff --git a/apps/server/docs/ai-context/auth-and-oidc.md b/apps/server/docs/ai-context/auth-and-oidc.md index 2922ef287..ea9a6b0c4 100644 --- a/apps/server/docs/ai-context/auth-and-oidc.md +++ b/apps/server/docs/ai-context/auth-and-oidc.md @@ -88,6 +88,8 @@ OIDC_CLIENT_ID_POCKET **为什么现在可以直接用 OIDC access token?** 因为服务端的 `resolveRequestAuth()` 已经统一支持两条路径:先走 `auth.api.getSession()` 解析 better-auth session;如果没有 session,再用 `jose.jwtVerify()` 本地验证 JWT 签名、issuer、audience、过期时间,然后通过 `findUserById()` 补齐用户信息。对业务路由来说,拿到的仍然是统一的 `{ user, session }` 结构。 +**测试环境登录绕过:** 设置 `TEST_AUTH_TOKEN` 后,业务 API 可以直接带 `Authorization: Bearer $TEST_AUTH_TOKEN` 进入 `resolveRequestAuth()`,无需走 UI 登录或 better-auth session。默认虚拟用户为 `test-user / test@example.com / Test User`,可用 `TEST_AUTH_USER_ID`、`TEST_AUTH_USER_EMAIL`、`TEST_AUTH_USER_NAME`、`TEST_AUTH_USER_ROLE` 覆盖;需要访问 `/api/admin/*` 时把 `TEST_AUTH_USER_ROLE=admin`。该 token 只接入业务鉴权链路,不改变 `/api/auth/*` better-auth 登录/OIDC 端点;生产环境保持 unset。 + **JWT 签发条件:** 前端在 authorize/token 请求中传递 `resource` 参数(值为 `API_SERVER_URL`),oauthProvider 据此签发 JWT 而非 opaque token。JWKS 通过 `/api/auth/jwks` 端点获取并缓存。 **撤销策略:** JWT 1 小时 TTL + refresh token rotation。signout 时撤销 refresh token,JWT 等自然过期。不使用 denylist 或 Redis。 diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index e56a1a571..1e93d1fb4 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -110,6 +110,15 @@ const EnvSchema = object({ AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')), AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')), + // Testing-only bearer token bypass. Keep unset in production. When set, + // Authorization: Bearer $TEST_AUTH_TOKEN resolves to the virtual user below + // through resolveRequestAuth without creating a better-auth session row. + TEST_AUTH_TOKEN: optional(string(), ''), + TEST_AUTH_USER_ID: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_ID must not be empty when set')), 'test-user'), + TEST_AUTH_USER_EMAIL: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_EMAIL must not be empty when set')), 'test@example.com'), + TEST_AUTH_USER_NAME: optional(pipe(string(), nonEmpty('TEST_AUTH_USER_NAME must not be empty when set')), 'Test User'), + TEST_AUTH_USER_ROLE: optional(string(), ''), + // Resend transactional email. RESEND_API_KEY required when emailAndPassword // sign-up / forgot-password / change-email / magic-link is exercised. Service // boots without it but those flows will throw at send-time. diff --git a/apps/server/src/libs/request-auth.ts b/apps/server/src/libs/request-auth.ts index f3da85aa5..a06a68a9b 100644 --- a/apps/server/src/libs/request-auth.ts +++ b/apps/server/src/libs/request-auth.ts @@ -2,6 +2,9 @@ import type auth from '../scripts/auth' import type { AuthInstance } from './auth' import type { Env } from './env' +import { Buffer } from 'node:buffer' +import { timingSafeEqual } from 'node:crypto' + import { createRemoteJWKSet, jwtVerify } from 'jose' export interface RequestAuthSession { @@ -34,6 +37,48 @@ function readBearerToken(headers: Headers): string | null { return token.length > 0 ? token : null } +function timingSafeStringEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} + +function resolveTestAuthToken(env: Env, accessToken: string): RequestAuthSession | null { + if (!env.TEST_AUTH_TOKEN || !timingSafeStringEqual(accessToken, env.TEST_AUTH_TOKEN)) + return null + + const now = new Date() + const expiresAt = new Date(now.getTime() + 60 * 60 * 1000) + const role = env.TEST_AUTH_USER_ROLE.trim() + + return { + user: { + id: env.TEST_AUTH_USER_ID, + email: env.TEST_AUTH_USER_EMAIL.toLowerCase(), + name: env.TEST_AUTH_USER_NAME, + emailVerified: true, + image: null, + role: role || null, + banned: false, + banReason: null, + banExpires: null, + lastSeenAt: now, + createdAt: now, + updatedAt: now, + } as RequestAuthSession['user'], + session: { + id: `test-auth:${env.TEST_AUTH_USER_ID}`, + token: accessToken, + userId: env.TEST_AUTH_USER_ID, + createdAt: now, + updatedAt: now, + expiresAt, + ipAddress: null, + userAgent: null, + } as RequestAuthSession['session'], + } +} + let cachedJWKS: ReturnType | null = null function getJWKS(env: Env): ReturnType { @@ -124,6 +169,10 @@ export async function resolveSessionIgnoringBan( if (!accessToken) return null + const testSession = resolveTestAuthToken(env, accessToken) + if (testSession) + return testSession + return await resolveJWTAccessToken(auth, env, accessToken) } diff --git a/apps/server/src/libs/tests/env.test.ts b/apps/server/src/libs/tests/env.test.ts index 5ee33afd8..6a593cc5e 100644 --- a/apps/server/src/libs/tests/env.test.ts +++ b/apps/server/src/libs/tests/env.test.ts @@ -56,6 +56,35 @@ describe('parseEnv', () => { ]) }) + it('parses TEST_AUTH_TOKEN with default virtual user settings', () => { + const env = parseEnv({ + ...baseEnv(), + TEST_AUTH_TOKEN: 'local-test-token', + }) + + expect(env.TEST_AUTH_TOKEN).toBe('local-test-token') + expect(env.TEST_AUTH_USER_ID).toBe('test-user') + expect(env.TEST_AUTH_USER_EMAIL).toBe('test@example.com') + expect(env.TEST_AUTH_USER_NAME).toBe('Test User') + expect(env.TEST_AUTH_USER_ROLE).toBe('') + }) + + it('parses TEST_AUTH_TOKEN virtual user overrides', () => { + const env = parseEnv({ + ...baseEnv(), + TEST_AUTH_TOKEN: 'local-test-token', + TEST_AUTH_USER_ID: 'admin-user', + TEST_AUTH_USER_EMAIL: 'admin@example.com', + TEST_AUTH_USER_NAME: 'Admin User', + TEST_AUTH_USER_ROLE: 'admin', + }) + + expect(env.TEST_AUTH_USER_ID).toBe('admin-user') + expect(env.TEST_AUTH_USER_EMAIL).toBe('admin@example.com') + expect(env.TEST_AUTH_USER_NAME).toBe('Admin User') + expect(env.TEST_AUTH_USER_ROLE).toBe('admin') + }) + it('lLM_ROUTER_MASTER_KEY decodes a valid 32-byte base64 value into a Buffer', () => { const key = Buffer.alloc(32, 0xAB).toString('base64') const env = parseEnv({ diff --git a/apps/server/src/libs/tests/request-auth.test.ts b/apps/server/src/libs/tests/request-auth.test.ts index 37a7bb426..dc1537939 100644 --- a/apps/server/src/libs/tests/request-auth.test.ts +++ b/apps/server/src/libs/tests/request-auth.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolveRequestAuth } from '../request-auth' @@ -13,9 +13,18 @@ const mockedJwtVerify = vi.mocked(jwtVerify) const mockEnv = { API_SERVER_URL: 'http://localhost:3000', + TEST_AUTH_TOKEN: '', + TEST_AUTH_USER_ID: 'test-user', + TEST_AUTH_USER_EMAIL: 'test@example.com', + TEST_AUTH_USER_NAME: 'Test User', + TEST_AUTH_USER_ROLE: '', } as any describe('resolveRequestAuth', () => { + beforeEach(() => { + mockedJwtVerify.mockReset() + }) + it('rejects a banned principal even when the session resolves (immediate revocation)', async () => { // `user.banned` comes from the better-auth admin plugin and is loaded with // the user row, so the hot-path gate is a field check (no extra query). @@ -140,6 +149,57 @@ describe('resolveRequestAuth', () => { expect(result).toBeNull() }) + it('returns the configured test user when bearer token matches TEST_AUTH_TOKEN', async () => { + const auth = { + api: { + getSession: vi.fn().mockResolvedValue(null), + }, + } + + const result = await resolveRequestAuth( + auth as any, + { + ...mockEnv, + TEST_AUTH_TOKEN: 'test-secret', + TEST_AUTH_USER_ID: 'test-user-1', + TEST_AUTH_USER_EMAIL: 'Test@Example.com', + TEST_AUTH_USER_NAME: 'Local Test User', + TEST_AUTH_USER_ROLE: 'admin', + }, + new Headers({ Authorization: 'Bearer test-secret' }), + ) + + expect(result?.user.id).toBe('test-user-1') + expect(result?.user.email).toBe('test@example.com') + expect(result?.user.name).toBe('Local Test User') + expect(result?.user.role).toBe('admin') + expect(result?.session.userId).toBe('test-user-1') + expect(result?.session.token).toBe('test-secret') + expect(mockedJwtVerify).not.toHaveBeenCalled() + }) + + it('falls through to JWT verification when TEST_AUTH_TOKEN does not match', async () => { + mockedJwtVerify.mockRejectedValue(new Error('invalid signature')) + + const auth = { + api: { + getSession: vi.fn().mockResolvedValue(null), + }, + } + + const result = await resolveRequestAuth( + auth as any, + { + ...mockEnv, + TEST_AUTH_TOKEN: 'test-secret', + }, + new Headers({ Authorization: 'Bearer different-secret' }), + ) + + expect(result).toBeNull() + expect(mockedJwtVerify).toHaveBeenCalled() + }) + it('returns null when no Authorization header is present', async () => { const auth = { api: {