feat(auth): Bearer token handling and OAuth token extraction (#1501)
This commit is contained in:
+32
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user