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 }` 结构。
|
**为什么现在可以直接用 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 签发条件:** 前端在 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。
|
**撤销策略:** 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_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
|
||||||
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET 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
|
// Resend transactional email. RESEND_API_KEY required when emailAndPassword
|
||||||
// sign-up / forgot-password / change-email / magic-link is exercised. Service
|
// sign-up / forgot-password / change-email / magic-link is exercised. Service
|
||||||
// boots without it but those flows will throw at send-time.
|
// 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 { AuthInstance } from './auth'
|
||||||
import type { Env } from './env'
|
import type { Env } from './env'
|
||||||
|
|
||||||
|
import { Buffer } from 'node:buffer'
|
||||||
|
import { timingSafeEqual } from 'node:crypto'
|
||||||
|
|
||||||
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
||||||
|
|
||||||
export interface RequestAuthSession {
|
export interface RequestAuthSession {
|
||||||
@@ -34,6 +37,48 @@ function readBearerToken(headers: Headers): string | null {
|
|||||||
return token.length > 0 ? token : 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
|
let cachedJWKS: ReturnType<typeof createRemoteJWKSet> | null = null
|
||||||
|
|
||||||
function getJWKS(env: Env): ReturnType<typeof createRemoteJWKSet> {
|
function getJWKS(env: Env): ReturnType<typeof createRemoteJWKSet> {
|
||||||
@@ -124,6 +169,10 @@ export async function resolveSessionIgnoringBan(
|
|||||||
if (!accessToken)
|
if (!accessToken)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
const testSession = resolveTestAuthToken(env, accessToken)
|
||||||
|
if (testSession)
|
||||||
|
return testSession
|
||||||
|
|
||||||
return await resolveJWTAccessToken(auth, env, accessToken)
|
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', () => {
|
it('lLM_ROUTER_MASTER_KEY decodes a valid 32-byte base64 value into a Buffer', () => {
|
||||||
const key = Buffer.alloc(32, 0xAB).toString('base64')
|
const key = Buffer.alloc(32, 0xAB).toString('base64')
|
||||||
const env = parseEnv({
|
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'
|
import { resolveRequestAuth } from '../request-auth'
|
||||||
|
|
||||||
@@ -13,9 +13,18 @@ const mockedJwtVerify = vi.mocked(jwtVerify)
|
|||||||
|
|
||||||
const mockEnv = {
|
const mockEnv = {
|
||||||
API_SERVER_URL: 'http://localhost:3000',
|
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
|
} as any
|
||||||
|
|
||||||
describe('resolveRequestAuth', () => {
|
describe('resolveRequestAuth', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedJwtVerify.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects a banned principal even when the session resolves (immediate revocation)', async () => {
|
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
|
// `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).
|
// the user row, so the hot-path gate is a field check (no extra query).
|
||||||
@@ -140,6 +149,57 @@ describe('resolveRequestAuth', () => {
|
|||||||
expect(result).toBeNull()
|
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 () => {
|
it('returns null when no Authorization header is present', async () => {
|
||||||
const auth = {
|
const auth = {
|
||||||
api: {
|
api: {
|
||||||
|
|||||||
Reference in New Issue
Block a user