feat(server): add testing-only bearer token bypass for authentication
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user