feat(auth): Bearer token handling and OAuth token extraction (#1501)

This commit is contained in:
RainbowBird
2026-03-28 20:27:47 +08:00
committed by GitHub
parent 1353a2c660
commit bdb01d30f4
8 changed files with 111 additions and 13 deletions
+32 -1
View File
@@ -148,7 +148,38 @@ async function buildApp(deps: AppDeps) {
windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'),
keyGenerator: c => c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown',
}))
.on(['POST', 'GET'], '/api/auth/*', c => deps.auth.handler(c.req.raw))
.on(['POST', 'GET'], '/api/auth/*', async (c) => {
const response: Response = await deps.auth.handler(c.req.raw)
// NOTICE: On OAuth callback redirects, the bearer plugin adds the session
// token to the `set-auth-token` header. But browsers don't expose headers
// from 302 redirects to JS. We append the token to the Location URL's
// fragment (#) so the client can extract it. Fragments are never sent to
// the server, so they won't leak into CDN/proxy logs or Referer headers.
if (response.status === 302) {
const token = response.headers.get('set-auth-token')
const location = response.headers.get('location')
if (token && location) {
try {
const url = new URL(location)
url.hash = `auth_token=${encodeURIComponent(token)}`
const headers = new Headers(response.headers)
headers.set('location', url.toString())
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}
catch (error) {
// If URL parsing fails, return the original response
logger.withError(error).warn('Failed to parse redirect URL, cannot append auth_token', { location })
}
}
}
return response
})
/**
* Character routes are handled by the character service.
+1 -8
View File
@@ -34,14 +34,7 @@ export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null)
baseURL: env.API_SERVER_URL,
trustedOrigins: request => getAuthTrustedOrigins(env, request),
// To skip state-mismatch errors
// https://github.com/better-auth/better-auth/issues/4969#issuecomment-3397804378
advanced: {
defaultCookieAttributes: {
sameSite: 'None', // this enables cross-site cookies
secure: true, // required for SameSite=None
},
},
advanced: {},
// NOTICE: skipStateCookieCheck required for Capacitor mobile apps.
// Default state strategy is 'database' (we have a DB), but better-auth
+6 -1
View File
@@ -2,15 +2,20 @@ import type { AppType } from '../../../../apps/server/src/app'
import { hc } from 'hono/client'
import { getAuthToken } from '../libs/auth'
import { SERVER_URL } from '../libs/server'
export const client = hc<AppType>(SERVER_URL, {
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
const token = getAuthToken()
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return fetch(input, {
...init,
headers,
credentials: 'include', // Send cookies with request (for sessions, etc)
credentials: 'omit',
})
},
})
+60 -1
View File
@@ -5,9 +5,37 @@ import { SERVER_URL } from './server'
export type OAuthProvider = 'google' | 'github'
// NOTICE: reads the same localStorage key ('auth/v1/token') that useAuthStore's
// `token` ref writes via useLocalStorage. We bypass the store here because
// authClient is initialized at module scope, before Pinia is active — calling
// useAuthStore() at this point would throw. The two stay in sync because
// useLocalStorage and raw localStorage share the same underlying storage entry.
export function getAuthToken(): string | null {
return localStorage.getItem('auth/v1/token')
}
export const authClient = createAuthClient({
baseURL: SERVER_URL,
credentials: 'include',
fetchOptions: {
// NOTICE: better-auth's client hardcodes `credentials: "include"` by default
// (config.mjs L40), which causes cookies to be sent alongside the Authorization
// header. We override with "omit" so only the Bearer token is used for auth.
// This works because restOfFetchOptions is spread AFTER the default (L47).
// OAuth flow delivers the token via URL query param (`auth_token`) instead.
credentials: 'omit',
auth: {
type: 'Bearer',
token: () => getAuthToken() ?? '',
},
// Capture session token from bearer plugin's `set-auth-token` response header
// (returned on sign-in/sign-up API calls that aren't redirects).
onResponse(context) {
const token = context.response.headers.get('set-auth-token')
if (token) {
useAuthStore().token = token
}
},
},
})
let initialized = false
@@ -16,10 +44,39 @@ export function initializeAuth() {
if (initialized)
return
// Pick up auth_token from OAuth callback redirect URL
extractTokenFromURL()
fetchSession().catch(() => {})
initialized = true
}
/**
* After OAuth callback, the server appends `#auth_token=<token>` to the
* redirect URL. Fragments are never sent to the server, avoiding leakage
* into CDN/proxy logs or Referer headers. Extract it, persist, and clean.
*/
function extractTokenFromURL() {
const hash = window.location.hash.slice(1) // remove leading '#'
if (!hash)
return
const params = new URLSearchParams(hash)
const token = params.get('auth_token')
if (!token)
return
// Persist through the Pinia store ref so reactive consumers (e.g.
// needsOnboarding) observe the change immediately. Writing to the
// useLocalStorage ref updates both the Vue reactivity system and
// the underlying localStorage entry in one step.
const authStore = useAuthStore()
authStore.token = decodeURIComponent(token)
// Clean the fragment from the URL to avoid leaking it in browser history
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`)
}
export async function fetchSession() {
const { data } = await authClient.getSession()
const authStore = useAuthStore()
@@ -33,6 +90,7 @@ export async function fetchSession() {
// Session expired or invalid — clear stale auth state from localStorage
authStore.user = null
authStore.session = null
authStore.token = null
return false
}
@@ -46,6 +104,7 @@ export async function signOut() {
const authStore = useAuthStore()
authStore.user = null
authStore.session = null
authStore.token = null
}
export async function signIn(provider: OAuthProvider) {
@@ -1,14 +1,21 @@
import { createOpenAI } from '@xsai-ext/providers/create'
import { getAuthToken } from '../../../../libs/auth'
import { SERVER_URL } from '../../../../libs/server'
export const OFFICIAL_ICON = 'i-solar:star-bold-duotone'
export function withCredentials() {
return (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
const token = getAuthToken()
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return globalThis.fetch(input, {
...init,
credentials: 'include',
headers,
credentials: 'omit',
})
}
}
@@ -143,7 +143,7 @@ export interface ProviderDefinition<TConfig extends any = any> {
/**
* If false, the provider does not require user-provided credentials (e.g. API keys).
* Used for built-in providers that authenticate via session cookies.
* Used for built-in providers that authenticate via JWT Bearer tokens.
*/
requiresCredentials?: boolean
+2
View File
@@ -19,6 +19,7 @@ export const useAuthStore = defineStore('auth', () => {
serializer: StorageSerializers.object,
})
const session = useLocalStorage<Session | null>('auth/v1/session', null, { serializer: StorageSerializers.object })
const token = useLocalStorage<string | null>('auth/v1/token', null)
const isAuthenticated = computed(() => !!user.value && !!session.value)
const userId = computed(() => user.value?.id ?? 'local')
@@ -109,6 +110,7 @@ export const useAuthStore = defineStore('auth', () => {
user,
userId,
session,
token,
isAuthenticated,
credits,
updateCredits,
@@ -46,6 +46,7 @@ export const useOnboardingStore = defineStore('onboarding', () => {
const skipOnboardingPath = ['/auth/login']
const needsOnboarding = computed(() =>
!authStore.isAuthenticated
&& !authStore.token
&& !hasSkippedSetup.value
&& !hasCompletedSetup.value
&& !skipOnboardingPath.includes(document.location.pathname),