From bdb01d30f400bbe447ffd88850763382dcd64879 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sat, 28 Mar 2026 20:27:47 +0800 Subject: [PATCH] feat(auth): Bearer token handling and OAuth token extraction (#1501) --- apps/server/src/app.ts | 33 +++++++++- apps/server/src/libs/auth.ts | 9 +-- packages/stage-ui/src/composables/api.ts | 7 ++- packages/stage-ui/src/libs/auth.ts | 61 ++++++++++++++++++- .../providers/providers/official/shared.ts | 9 ++- packages/stage-ui/src/libs/providers/types.ts | 2 +- packages/stage-ui/src/stores/auth.ts | 2 + packages/stage-ui/src/stores/onboarding.ts | 1 + 8 files changed, 111 insertions(+), 13 deletions(-) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 2121f849f..de5b53639 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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. diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 9d5cd6d36..31cfcd480 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -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 diff --git a/packages/stage-ui/src/composables/api.ts b/packages/stage-ui/src/composables/api.ts index 3a4f4ca5a..214a2a343 100644 --- a/packages/stage-ui/src/composables/api.ts +++ b/packages/stage-ui/src/composables/api.ts @@ -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(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', }) }, }) diff --git a/packages/stage-ui/src/libs/auth.ts b/packages/stage-ui/src/libs/auth.ts index 6f700d80a..04bc0d821 100644 --- a/packages/stage-ui/src/libs/auth.ts +++ b/packages/stage-ui/src/libs/auth.ts @@ -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=` 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) { diff --git a/packages/stage-ui/src/libs/providers/providers/official/shared.ts b/packages/stage-ui/src/libs/providers/providers/official/shared.ts index c97926c9e..0962b5002 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/shared.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/shared.ts @@ -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', }) } } diff --git a/packages/stage-ui/src/libs/providers/types.ts b/packages/stage-ui/src/libs/providers/types.ts index 9358a72db..d7897981e 100644 --- a/packages/stage-ui/src/libs/providers/types.ts +++ b/packages/stage-ui/src/libs/providers/types.ts @@ -143,7 +143,7 @@ export interface ProviderDefinition { /** * 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 diff --git a/packages/stage-ui/src/stores/auth.ts b/packages/stage-ui/src/stores/auth.ts index 8833acbc9..47981b348 100644 --- a/packages/stage-ui/src/stores/auth.ts +++ b/packages/stage-ui/src/stores/auth.ts @@ -19,6 +19,7 @@ export const useAuthStore = defineStore('auth', () => { serializer: StorageSerializers.object, }) const session = useLocalStorage('auth/v1/session', null, { serializer: StorageSerializers.object }) + const token = useLocalStorage('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, diff --git a/packages/stage-ui/src/stores/onboarding.ts b/packages/stage-ui/src/stores/onboarding.ts index 2f557cbc9..fa817ff46 100644 --- a/packages/stage-ui/src/stores/onboarding.ts +++ b/packages/stage-ui/src/stores/onboarding.ts @@ -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),