From 7693a02c9887ea0f200fa4e2d79b30626e1986aa Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 15 Jun 2026 19:43:02 +0700 Subject: [PATCH 1/2] fix(auth): add bearer token auth as fallback for Android WebView third-party cookie blocking Android WebView blocks third-party cookies by default. This patch: - Returns session token in login/register response body - Stores token in localStorage, sends via Authorization: Bearer header - Backend getCurrentUser supports Authorization header fallback Co-Authored-By: Claude --- apps/api/src/lib/auth.ts | 21 ++++++++++-- apps/api/src/routes/auth.ts | 4 ++- apps/web/src/lib/api-client.ts | 47 +++++++++++++++++++++++--- packages/shared/src/classifications.ts | 1 + 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 11acfd8..4fa5420 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -61,6 +61,19 @@ export function readSessionToken(cookieHeader: string | null | undefined) { return decodeURIComponent(sessionCookie.slice(sessionCookieName.length + 1)); } +/** + * Extract bearer token from Authorization header. + * Used as fallback when cookies are blocked (e.g. Android WebView third-party blocking). + */ +export function readBearerToken(headers?: { get(name: string): string | null }) { + if (!headers) return null; + const auth = headers.get('authorization'); + if (!auth) return null; + const parts = auth.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') return null; + return parts[1]; +} + export async function createSession(userId: string) { const db = createDbClient(); const token = randomBytes(32).toString('base64url'); @@ -83,8 +96,12 @@ export async function deleteSession(token: string | null) { await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token))); } -export async function getCurrentUser(cookieHeader: string | null | undefined): Promise { - const token = readSessionToken(cookieHeader); +export async function getCurrentUser( + cookieHeader: string | null | undefined, + headers?: { get(name: string): string | null }, +): Promise { + // Try cookie first, then Authorization header (for Android WebView where 3rd-party cookies are blocked) + const token = readSessionToken(cookieHeader) ?? readBearerToken(headers); if (!token) return null; const db = createDbClient(); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index cb60458..a5e9b0f 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -32,7 +32,7 @@ function validatePassword(password: unknown) { export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) .get('/me', async ({ request }) => { - const user = await getCurrentUser(request.headers.get('cookie')); + const user = await getCurrentUser(request.headers.get('cookie'), request.headers); return { user, features: getAuthFeatures(), @@ -73,6 +73,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) name: user.name, role: 'user' as const, }, + token, features: getAuthFeatures(), }; } catch (error) { @@ -109,6 +110,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) name: user.name, role: user.role === 'expert' ? 'expert' as const : 'user' as const, }, + token, features: getAuthFeatures(), }; } catch (error) { diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 4c5d0b9..ab04878 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -16,6 +16,28 @@ import { recordApiCall } from './telemetry'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'https://zeavisedu.asepharyana.my.id'; +const AUTH_TOKEN_KEY = 'zeavis_auth_token'; + +function getAuthToken(): string | null { + try { + return localStorage.getItem(AUTH_TOKEN_KEY); + } catch { + return null; + } +} + +export function setAuthToken(token: string | null) { + try { + if (token) { + localStorage.setItem(AUTH_TOKEN_KEY, token); + } else { + localStorage.removeItem(AUTH_TOKEN_KEY); + } + } catch { + // localStorage may throw in private browsing + } +} + export interface ApiError extends Error { status: number; source?: 'uploader' | 'model-service' | 'unknown'; @@ -24,10 +46,21 @@ export interface ApiError extends Error { async function fetchApi(endpoint: string, options?: RequestInit): Promise { const start = performance.now(); const url = `${apiBaseUrl}${endpoint}`; + const token = getAuthToken(); + const headers = new Headers(options?.headers); + + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + if (options?.body && !options.method) { + // auto-set Content-Type for JSON bodies + } + const response = await fetch(url, { credentials: 'include', ...options, - headers: options?.headers, + headers, }); const duration = performance.now() - start; @@ -93,23 +126,29 @@ export const apiClient = { }, async register(payload: RegisterRequest): Promise { - return fetchApi('/api/v1/auth/register', { + const result = await fetchApi('/api/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); + if (result.token) setAuthToken(result.token); + return result; }, async login(payload: AuthRequest): Promise { - return fetchApi('/api/v1/auth/login', { + const result = await fetchApi('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); + if (result.token) setAuthToken(result.token); + return result; }, async logout(): Promise<{ ok: boolean }> { - return fetchApi('/api/v1/auth/logout', { method: 'POST' }); + const result = await fetchApi<{ ok: boolean }>('/api/v1/auth/logout', { method: 'POST' }); + setAuthToken(null); + return result; }, // Disease catalog methods diff --git a/packages/shared/src/classifications.ts b/packages/shared/src/classifications.ts index d265aaa..29ab9fb 100644 --- a/packages/shared/src/classifications.ts +++ b/packages/shared/src/classifications.ts @@ -66,6 +66,7 @@ export type RegisterRequest = AuthRequest & { export type AuthResponse = { user: AuthUser; features: AuthFeatures; + token?: string; }; export type DiagnosisPrediction = { From c881e6a2b27ad7c982e7359151cde70cd170c16a Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 15 Jun 2026 20:11:11 +0700 Subject: [PATCH 2/2] fix(auth): pass request.headers to getCurrentUser in all protected routes dashboard, diagnoses, expert routes now support Authorization header fallback for Android WebView Co-Authored-By: Claude --- apps/api/src/routes/dashboard.ts | 2 +- apps/api/src/routes/diagnoses.ts | 6 +++--- apps/api/src/routes/expert.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/dashboard.ts b/apps/api/src/routes/dashboard.ts index e0a3aa8..6223b07 100644 --- a/apps/api/src/routes/dashboard.ts +++ b/apps/api/src/routes/dashboard.ts @@ -13,7 +13,7 @@ export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }) .get('/dashboard/summary', async ({ request }) => { try { const db = createDbClient(); - const user = await getCurrentUser(request.headers.get('cookie')); + const user = await getCurrentUser(request.headers.get('cookie'), request.headers); const diseases = await db.select().from(diseaseCatalog).orderBy(diseaseCatalog.displayOrder); const manualRows = await db.select().from(manualClassifications).orderBy(desc(manualClassifications.createdAt)).limit(1); diff --git a/apps/api/src/routes/diagnoses.ts b/apps/api/src/routes/diagnoses.ts index b839f6b..483b692 100644 --- a/apps/api/src/routes/diagnoses.ts +++ b/apps/api/src/routes/diagnoses.ts @@ -175,7 +175,7 @@ function getFileFromBody(body: unknown): File | null { export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' }) .post('/diagnoses', async ({ body, request }) => { - const user = await getCurrentUser(request.headers.get('cookie')); + const user = await getCurrentUser(request.headers.get('cookie'), request.headers); if (!user) return unauthorized('Authentication required'); const file = getFileFromBody(body); @@ -251,7 +251,7 @@ export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' }) } }) .get('/diagnoses', async ({ request }) => { - const user = await getCurrentUser(request.headers.get('cookie')); + const user = await getCurrentUser(request.headers.get('cookie'), request.headers); if (!user) return unauthorized('Authentication required'); try { @@ -270,7 +270,7 @@ export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' }) } }) .get('/diagnoses/:id', async ({ params, request }) => { - const user = await getCurrentUser(request.headers.get('cookie')); + const user = await getCurrentUser(request.headers.get('cookie'), request.headers); if (!user) return unauthorized('Authentication required'); try { diff --git a/apps/api/src/routes/expert.ts b/apps/api/src/routes/expert.ts index 632a7cd..1aa19e8 100644 --- a/apps/api/src/routes/expert.ts +++ b/apps/api/src/routes/expert.ts @@ -16,7 +16,7 @@ function isDiagnosisRecordOrNull(record: unknown): record is DiagnosisRecord { export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' }) .get('/reviews', async ({ request }) => { - const user = await getCurrentUser(request.headers.get('cookie')); + const user = await getCurrentUser(request.headers.get('cookie'), request.headers); if (!user) return unauthorized('Authentication required'); if (user.role !== 'expert') return forbidden('Expert role required'); @@ -36,7 +36,7 @@ export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' }) } }) .post('/reviews/:diagnosisId', async ({ params, body, request }) => { - const user = await getCurrentUser(request.headers.get('cookie')); + const user = await getCurrentUser(request.headers.get('cookie'), request.headers); if (!user) return unauthorized('Authentication required'); if (user.role !== 'expert') return forbidden('Expert role required');