fix(auth): detect HTTPS via X-Forwarded-Proto for SameSite=None cookies, fix AuthGuard null overwrite

- Cookie SameSite now dynamic: None;Secure when behind HTTPS proxy, Lax otherwise
- AuthGuard useEffect no longer overwrites Zustand store with null from background refetch
- AuthInitializer: add staleTime 30s

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-15 18:17:26 +07:00
co-authored by Claude
parent 22307d44de
commit 51a4cb9ed2
4 changed files with 33 additions and 14 deletions
+16 -4
View File
@@ -28,14 +28,26 @@ function hashToken(token: string) {
return createHash('sha256').update(`${env.sessionSecret}:${token}`).digest('hex'); return createHash('sha256').update(`${env.sessionSecret}:${token}`).digest('hex');
} }
export function createSessionCookie(token: string) { function isSecureRequest(headers?: { get(name: string): string | null }) {
if (env.secureCookies) return true;
// Detect HTTPS behind proxy (X-Forwarded-Proto)
const proto = headers?.get('x-forwarded-proto');
if (proto === 'https') return true;
return false;
}
function buildSameSite(headers?: { get(name: string): string | null }) {
return isSecureRequest(headers) ? 'SameSite=None; Secure' : 'SameSite=Lax';
}
export function createSessionCookie(token: string, headers?: { get(name: string): string | null }) {
const maxAge = 60 * 60 * 24 * 30; const maxAge = 60 * 60 * 24 * 30;
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax'; const sameSite = buildSameSite(headers);
return `${sessionCookieName}=${token}; HttpOnly; Path=/; ${sameSite}; Max-Age=${maxAge}`; return `${sessionCookieName}=${token}; HttpOnly; Path=/; ${sameSite}; Max-Age=${maxAge}`;
} }
export function clearSessionCookie() { export function clearSessionCookie(headers?: { get(name: string): string | null }) {
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax'; const sameSite = buildSameSite(headers);
return `${sessionCookieName}=; HttpOnly; Path=/; ${sameSite}; Max-Age=0`; return `${sessionCookieName}=; HttpOnly; Path=/; ${sameSite}; Max-Age=0`;
} }
+7 -6
View File
@@ -38,7 +38,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
features: getAuthFeatures(), features: getAuthFeatures(),
}; };
}) })
.post('/register', async ({ body, set }) => { .post('/register', async ({ body, set, request }) => {
const req = body as Partial<RegisterRequest> | undefined; const req = body as Partial<RegisterRequest> | undefined;
const email = normalizeEmail(req?.email); const email = normalizeEmail(req?.email);
const name = normalizeName(req?.name); const name = normalizeName(req?.name);
@@ -62,7 +62,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
const user = inserted[0]; const user = inserted[0];
const token = await createSession(user.id); const token = await createSession(user.id);
set.headers['Set-Cookie'] = createSessionCookie(token); set.headers['Set-Cookie'] = createSessionCookie(token, request.headers);
authCounter.labels('register', 'true').inc(); authCounter.labels('register', 'true').inc();
@@ -79,7 +79,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
return serviceUnavailable('Database unavailable'); return serviceUnavailable('Database unavailable');
} }
}) })
.post('/login', async ({ body, set }) => { .post('/login', async ({ body, set, request }) => {
const req = body as Partial<AuthRequest> | undefined; const req = body as Partial<AuthRequest> | undefined;
const email = normalizeEmail(req?.email); const email = normalizeEmail(req?.email);
@@ -98,7 +98,7 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
} }
const token = await createSession(user.id); const token = await createSession(user.id);
set.headers['Set-Cookie'] = createSessionCookie(token); set.headers['Set-Cookie'] = createSessionCookie(token, request.headers);
authCounter.labels('login', 'true').inc(); authCounter.labels('login', 'true').inc();
@@ -116,8 +116,9 @@ export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
} }
}) })
.post('/logout', async ({ request, set }) => { .post('/logout', async ({ request, set }) => {
await deleteSession(readSessionToken(request.headers.get('cookie'))); const cookieHeader = request.headers.get('cookie');
set.headers['Set-Cookie'] = clearSessionCookie(); await deleteSession(readSessionToken(cookieHeader));
set.headers['Set-Cookie'] = clearSessionCookie(request.headers);
return { ok: true }; return { ok: true };
}) })
.get('/google', ({ set }) => { .get('/google', ({ set }) => {
+9 -4
View File
@@ -10,27 +10,32 @@ type AuthGuardProps = {
}; };
export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) { export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) {
const user = useAuthStore((state) => state.user);
const setUser = useAuthStore((state) => state.setUser); const setUser = useAuthStore((state) => state.setUser);
const query = useQuery({ const query = useQuery({
queryKey: ['auth', 'me'], queryKey: ['auth', 'me'],
queryFn: () => apiClient.getMe(), queryFn: () => apiClient.getMe(),
staleTime: 30_000,
}); });
useEffect(() => { useEffect(() => {
if (query.data) { if (query.data?.user) {
setUser(query.data.user); setUser(query.data.user);
} }
}, [query.data, setUser]); }, [query.data, setUser]);
if (query.isLoading) { // Tunjukkan loading hanya jika belum ada user di store
if (query.isLoading && !user) {
return <main className="min-h-screen p-8 text-center text-muted-foreground">Memeriksa sesi...</main>; return <main className="min-h-screen p-8 text-center text-muted-foreground">Memeriksa sesi...</main>;
} }
if (!query.data?.user) { // Cek store dulu, baru query — mencegah redirect saat refetch background
const currentUser = query.data?.user ?? user;
if (!currentUser) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
if (requireExpert && query.data.user.role !== 'expert') { if (requireExpert && currentUser.role !== 'expert') {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
} }
@@ -9,6 +9,7 @@ export function AuthInitializer() {
queryKey: ['auth', 'me'], queryKey: ['auth', 'me'],
queryFn: () => apiClient.getMe(), queryFn: () => apiClient.getMe(),
retry: false, retry: false,
staleTime: 30_000,
}); });
useEffect(() => { useEffect(() => {