feat(server): add testing-only bearer token bypass for authentication

This commit is contained in:
RainbowBird
2026-06-01 18:16:21 +08:00
parent 7021572cb8
commit f728f4d639
5 changed files with 150 additions and 1 deletions
@@ -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 tokenJWT 等自然过期。不使用 denylist 或 Redis。
+9
View File
@@ -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.
+49
View File
@@ -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<typeof createRemoteJWKSet> | null = null
function getJWKS(env: Env): ReturnType<typeof createRemoteJWKSet> {
@@ -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)
}
+29
View File
@@ -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({
@@ -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: {