From 80abbf30c073efc586e11b6f100e259fcda7592f Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 6 Jul 2026 12:18:15 -0400 Subject: [PATCH 1/2] fix(security): move Hasura Auth tokens from response body to httpOnly cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit signin.ts and signup.ts previously proxied Hasura Auth and returned accessToken/refreshToken directly in the JSON response body, which any client-side code would have to store somewhere readable by JS (localStorage/sessionStorage/a variable) — an XSS token-theft risk. No signin/signup UI was wired up to these routes yet, but the insecure pattern was live in the API layer. Ports the httpOnly-cookie session pattern already shipped in praycalc/web (ADR-010, no-localstorage-token): tokens are now set as httpOnly/secure/sameSite=lax cookies (ci_access_token/ci_refresh_token) by same-origin /api/auth/* routes, and only a non-sensitive profile (email/displayName/initials + a plain expiry timestamp) is kept in localStorage. Adds the missing refresh.ts and signout.ts routes so a session can actually be renewed/ended. Also migrates TutorIsland, SettingsIsland, and the tutor API routes (progress/message/session) off the chatislam_token localStorage/Bearer pattern onto the httpOnly cookie, and fixes signin/signup to read import.meta.env.PUBLIC_AUTH_URL (the actually-wired env var per astro.config.ts + .env.example) instead of the dead process.env.NEXT_PUBLIC_AUTH_URL leftover from this app's pre-Astro Next.js code. JWT signature verification for the tutor routes' cookie-sourced token is tracked as a separate follow-up, not bundled into this change. --- web/__tests__/auth-client.test.ts | 134 ++++++++++++++++++++++++++++ web/__tests__/session.test.ts | 130 +++++++++++++++++++++++++++ web/hooks/useTutor.ts | 25 +++--- web/src/components/TutorIsland.tsx | 24 +++-- web/src/hooks/useTutor.ts | 25 +++--- web/src/islands/SettingsIsland.tsx | 17 ++-- web/src/lib/auth/client.ts | 65 ++++++++++++++ web/src/lib/auth/cookies.server.ts | 63 +++++++++++++ web/src/lib/auth/hasura.server.ts | 124 +++++++++++++++++++++++++ web/src/lib/session.ts | 96 ++++++++++++++++++++ web/src/pages/api/auth/refresh.ts | 55 ++++++++++++ web/src/pages/api/auth/signin.ts | 75 ++++++++-------- web/src/pages/api/auth/signout.ts | 30 +++++++ web/src/pages/api/auth/signup.ts | 76 ++++++++-------- web/src/pages/api/tutor/message.ts | 17 ++-- web/src/pages/api/tutor/progress.ts | 18 ++-- web/src/pages/api/tutor/session.ts | 18 ++-- 17 files changed, 853 insertions(+), 139 deletions(-) create mode 100644 web/__tests__/auth-client.test.ts create mode 100644 web/__tests__/session.test.ts create mode 100644 web/src/lib/auth/client.ts create mode 100644 web/src/lib/auth/cookies.server.ts create mode 100644 web/src/lib/auth/hasura.server.ts create mode 100644 web/src/lib/session.ts create mode 100644 web/src/pages/api/auth/refresh.ts create mode 100644 web/src/pages/api/auth/signout.ts diff --git a/web/__tests__/auth-client.test.ts b/web/__tests__/auth-client.test.ts new file mode 100644 index 0000000..05a1d73 --- /dev/null +++ b/web/__tests__/auth-client.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { signIn, signUp, refreshSession, signOut } from '../src/lib/auth/client' + +// --------------------------------------------------------------------------- +// fetch mock +// --------------------------------------------------------------------------- +function jsonResponse(body: unknown, ok = true, status = ok ? 200 : 400) { + return { + ok, + status, + json: async () => body, + } as Response +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +// signIn/signUp/refreshSession/signOut call ChatIslam's own same-origin +// /api/auth/* proxy routes — the routes hold the real tokens as httpOnly +// cookies server-side and only ever return { user, accessTokenExpiresAt } +// to the client (no-localstorage-token fix, ported from praycalc/web). + +describe('signIn', () => { + it('resolves with user + accessTokenExpiresAt (no raw tokens)', async () => { + ;(fetch as unknown as ReturnType).mockResolvedValue( + jsonResponse({ + user: { id: 'u1', email: 'a@b.com', displayName: 'A B' }, + accessTokenExpiresAt: Date.now() + 900_000, + }), + ) + const result = await signIn('a@b.com', 'secret') + expect(result.user.email).toBe('a@b.com') + expect(result.user.displayName).toBe('A B') + expect(result.accessTokenExpiresAt).toBeGreaterThan(Date.now()) + expect((result as unknown as { tokens?: unknown }).tokens).toBeUndefined() + }) + + it('posts to the same-origin proxy route with credentials', async () => { + const mockFetch = fetch as unknown as ReturnType + mockFetch.mockResolvedValue( + jsonResponse({ user: { email: 'e@e.com', displayName: 'e' }, accessTokenExpiresAt: Date.now() }), + ) + await signIn('e@e.com', 'pw') + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/signin', + expect.objectContaining({ + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ email: 'e@e.com', password: 'pw' }), + }), + ) + }) + + it('throws a user-presentable message on failure', async () => { + ;(fetch as unknown as ReturnType).mockResolvedValue( + jsonResponse({ error: 'Invalid email or password.' }, false, 401), + ) + await expect(signIn('a@b.com', 'wrong')).rejects.toThrow('Invalid email or password.') + }) + + it('throws a fallback message when the error body is unparseable', async () => { + ;(fetch as unknown as ReturnType).mockResolvedValue({ + ok: false, + status: 500, + json: async () => { + throw new Error('not json') + }, + } as unknown as Response) + await expect(signIn('a@b.com', 'wrong')).rejects.toThrow('Request failed.') + }) +}) + +describe('signUp', () => { + it('posts to the signup proxy route with displayName', async () => { + const mockFetch = fetch as unknown as ReturnType + mockFetch.mockResolvedValue( + jsonResponse({ user: { email: 'n@n.com', displayName: 'New' }, accessTokenExpiresAt: Date.now() }), + ) + const result = await signUp('n@n.com', 'pw123456', 'New') + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/signup', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'n@n.com', password: 'pw123456', displayName: 'New' }), + }), + ) + expect(result.user.displayName).toBe('New') + }) + + it('throws a user-presentable message on failure', async () => { + ;(fetch as unknown as ReturnType).mockResolvedValue( + jsonResponse({ error: 'Email already registered.' }, false, 409), + ) + await expect(signUp('dupe@e.com', 'pw')).rejects.toThrow('Email already registered.') + }) +}) + +describe('refreshSession', () => { + it('resolves with a new accessTokenExpiresAt on success (cookie-based, no args)', async () => { + ;(fetch as unknown as ReturnType).mockResolvedValue( + jsonResponse({ user: { email: 'r@r.com' }, accessTokenExpiresAt: Date.now() + 900_000 }), + ) + const result = await refreshSession() + expect(result.accessTokenExpiresAt).toBeGreaterThan(Date.now()) + expect(fetch).toHaveBeenCalledWith( + '/api/auth/refresh', + expect.objectContaining({ body: JSON.stringify({}) }), + ) + }) + + it('throws on failure', async () => { + ;(fetch as unknown as ReturnType).mockResolvedValue( + jsonResponse({ error: 'Session refresh failed.' }, false, 401), + ) + await expect(refreshSession()).rejects.toThrow('Session refresh failed.') + }) +}) + +describe('signOut', () => { + it('resolves even when the network call fails (best-effort)', async () => { + ;(fetch as unknown as ReturnType).mockRejectedValue(new Error('network down')) + await expect(signOut()).resolves.toBeUndefined() + }) + + it('resolves on success', async () => { + ;(fetch as unknown as ReturnType).mockResolvedValue(jsonResponse({ ok: true })) + await expect(signOut()).resolves.toBeUndefined() + }) +}) diff --git a/web/__tests__/session.test.ts b/web/__tests__/session.test.ts new file mode 100644 index 0000000..00baea2 --- /dev/null +++ b/web/__tests__/session.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + buildSession, + computeInitials, + getSession, + saveSession, + clearSession, + hasValidToken, + type ChatIslamSession, +} from '../src/lib/session' + +// --------------------------------------------------------------------------- +// localStorage mock (matches praycalc/web's session.test.ts house style). +// chatislam/web's vitest.config.ts uses environment: 'node' (not jsdom), so +// `window` is not declared at all here — session.ts guards every function +// with `typeof window === 'undefined'` to stay SSR-safe, which means the +// guard short-circuits in a bare Node environment too. Stub a minimal +// `window` global (any truthy value satisfies `typeof window !== 'undefined'`) +// so the module under test exercises its localStorage branch. +// --------------------------------------------------------------------------- +let _store: Record = {} + +Object.defineProperty(globalThis, 'window', { + value: globalThis, + writable: true, + configurable: true, +}) + +Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: (key: string) => _store[key] ?? null, + setItem: (key: string, value: string) => { + _store[key] = value + }, + removeItem: (key: string) => { + delete _store[key] + }, + clear: () => { + _store = {} + }, + }, + writable: true, + configurable: true, +}) + +const SESSION_KEY = 'chatislam-profile' + +beforeEach(() => { + _store = {} +}) + +describe('computeInitials', () => { + it('derives initials from first+last name', () => { + expect(computeInitials('John Doe')).toBe('JD') + }) + + it('derives initials from a single name (first two letters)', () => { + expect(computeInitials('Madonna')).toBe('MA') + }) +}) + +describe('buildSession', () => { + it('builds a session without a token expiry', () => { + const s = buildSession('john.doe@example.com') + expect(s.email).toBe('john.doe@example.com') + expect(s.displayName).toBe('john doe') + expect(s.accessTokenExpiresAt).toBeUndefined() + }) + + it('accepts an explicit display name', () => { + const s = buildSession('a@b.com', 'A B') + expect(s.displayName).toBe('A B') + expect(s.initials).toBe('AB') + }) +}) + +describe('getSession / saveSession / clearSession', () => { + it('returns null when nothing is stored', () => { + expect(getSession()).toBeNull() + }) + + it('round-trips a profile session (with expiry) through localStorage', () => { + const s: ChatIslamSession = { + email: 'a@b.com', + displayName: 'A B', + initials: 'AB', + accessTokenExpiresAt: Date.now() + 900_000, + } + saveSession(s) + expect(getSession()).toEqual(s) + }) + + it('clearSession removes the stored session', () => { + saveSession(buildSession('x@y.com')) + clearSession() + expect(getSession()).toBeNull() + }) + + it('getSession returns null on malformed JSON', () => { + _store[SESSION_KEY] = 'not-json{{' + expect(getSession()).toBeNull() + }) +}) + +describe('hasValidToken', () => { + it('returns false for null session', () => { + expect(hasValidToken(null)).toBe(false) + }) + + it('returns false for a session with no expiry field', () => { + const s = buildSession('a@b.com') + expect(hasValidToken(s)).toBe(false) + }) + + it('returns false when accessTokenExpiresAt is in the past', () => { + const s: ChatIslamSession = { + ...buildSession('a@b.com'), + accessTokenExpiresAt: Date.now() - 1000, + } + expect(hasValidToken(s)).toBe(false) + }) + + it('returns true when accessTokenExpiresAt is in the future', () => { + const s: ChatIslamSession = { + ...buildSession('a@b.com'), + accessTokenExpiresAt: Date.now() + 900_000, + } + expect(hasValidToken(s)).toBe(true) + }) +}) diff --git a/web/hooks/useTutor.ts b/web/hooks/useTutor.ts index 9de6051..9c0eb5c 100644 --- a/web/hooks/useTutor.ts +++ b/web/hooks/useTutor.ts @@ -7,6 +7,11 @@ * - Fetches user's progress from GET /api/tutor/progress * - Creates/resumes a session via POST /api/tutor/session * - Exposes session state for tutor pages + * + * Auth: no longer takes a raw bearer token — /api/tutor/progress and + * /api/tutor/session authenticate via the httpOnly ci_access_token cookie, + * sent automatically with credentials: 'same-origin' (no-localstorage-token + * fix, ported from praycalc/web). */ import { useState, useCallback } from 'react' @@ -47,8 +52,8 @@ interface UseTutorReturn { activeSession: TutorSession | null isStarting: boolean sessionError: string | null - fetchProgress: (token: string) => Promise - startSession: (pathId: string, token: string) => Promise + fetchProgress: () => Promise + startSession: (pathId: string) => Promise clearSession: () => void } @@ -59,11 +64,11 @@ export function useTutor(): UseTutorReturn { const [isStarting, setIsStarting] = useState(false) const [sessionError, setSessionError] = useState(null) - const fetchProgress = useCallback(async (token: string) => { + const fetchProgress = useCallback(async () => { setIsLoadingProgress(true) try { const res = await fetch('/api/tutor/progress', { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'same-origin', }) if (!res.ok) return const data = await res.json() as { progress: TutorProgress[] } @@ -73,17 +78,15 @@ export function useTutor(): UseTutorReturn { } }, []) - const startSession = useCallback(async (pathId: string, token: string) => { + const startSession = useCallback(async (pathId: string) => { setIsStarting(true) setSessionError(null) try { const res = await fetch('/api/tutor/session', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ path_id: pathId }), + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ path_id: pathId }), }) if (!res.ok) { diff --git a/web/src/components/TutorIsland.tsx b/web/src/components/TutorIsland.tsx index fddc148..ff32988 100644 --- a/web/src/components/TutorIsland.tsx +++ b/web/src/components/TutorIsland.tsx @@ -7,30 +7,36 @@ * Next-isms replaced: * - next/navigation useRouter().push(href) → window.location.assign(href) * All hooks, fetches, and lib imports unchanged (now resolved under src/). + * + * Auth: no longer reads a raw bearer token from localStorage. The + * /api/tutor/* routes authenticate via the httpOnly ci_access_token cookie + * (sent automatically with credentials: 'same-origin'); sign-in gating uses + * getSession() from @/lib/session (no-localstorage-token fix). */ import { useState, useEffect } from 'react' import { TutorPathSelector } from './tutor/TutorPathSelector' import { TutorProgressCard } from './tutor/TutorProgressCard' import { useTutor } from '../hooks/useTutor' +import { getSession } from '@/lib/session' export function TutorIsland() { const { progress, isLoadingProgress, startSession, isStarting, sessionError, fetchProgress, activeSession } = useTutor() - // Lazy initializer reads from localStorage on first render (client-only). - const [authToken] = useState(() => - typeof window !== 'undefined' ? localStorage.getItem('chatislam_token') : null - ) + // Lazy initializer reads the cached profile on first render (client-only). + // Presence of a profile means "was signed in last we checked" — the actual + // credential is the httpOnly cookie, sent automatically by fetch(). + const [isSignedIn] = useState(() => getSession() !== null) useEffect(() => { - if (authToken) void fetchProgress(authToken) - }, [authToken, fetchProgress]) + if (isSignedIn) void fetchProgress() + }, [isSignedIn, fetchProgress]) async function handlePathSelect(path: { id: string; slug: string }) { - if (!authToken) { + if (!isSignedIn) { window.location.assign('/auth/signin?next=/tutor') return } - await startSession(path.id, authToken) + await startSession(path.id) } useEffect(() => { @@ -49,7 +55,7 @@ export function TutorIsland() { {/* Progress section (authenticated) */} - {authToken && progress.length > 0 && ( + {isSignedIn && progress.length > 0 && (

Continue learning

diff --git a/web/src/hooks/useTutor.ts b/web/src/hooks/useTutor.ts index 95ededf..4116b2d 100644 --- a/web/src/hooks/useTutor.ts +++ b/web/src/hooks/useTutor.ts @@ -7,6 +7,11 @@ * - Exposes session state for tutor pages * * Migrated from app/hooks/useTutor.ts (Next.js) — logic identical, no next/* imports. + * + * Auth: no longer takes a raw bearer token — /api/tutor/progress and + * /api/tutor/session authenticate via the httpOnly ci_access_token cookie, + * sent automatically with credentials: 'same-origin' (no-localstorage-token + * fix, ported from praycalc/web). */ import { useState, useCallback } from 'react' @@ -47,8 +52,8 @@ interface UseTutorReturn { activeSession: TutorSession | null isStarting: boolean sessionError: string | null - fetchProgress: (token: string) => Promise - startSession: (pathId: string, token: string) => Promise + fetchProgress: () => Promise + startSession: (pathId: string) => Promise clearSession: () => void } @@ -59,11 +64,11 @@ export function useTutor(): UseTutorReturn { const [isStarting, setIsStarting] = useState(false) const [sessionError, setSessionError] = useState(null) - const fetchProgress = useCallback(async (token: string) => { + const fetchProgress = useCallback(async () => { setIsLoadingProgress(true) try { const res = await fetch('/api/tutor/progress', { - headers: { Authorization: `Bearer ${token}` }, + credentials: 'same-origin', }) if (!res.ok) return const data = await res.json() as { progress: TutorProgress[] } @@ -73,17 +78,15 @@ export function useTutor(): UseTutorReturn { } }, []) - const startSession = useCallback(async (pathId: string, token: string) => { + const startSession = useCallback(async (pathId: string) => { setIsStarting(true) setSessionError(null) try { const res = await fetch('/api/tutor/session', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ path_id: pathId }), + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ path_id: pathId }), }) if (!res.ok) { diff --git a/web/src/islands/SettingsIsland.tsx b/web/src/islands/SettingsIsland.tsx index 138f0c7..a9a4285 100644 --- a/web/src/islands/SettingsIsland.tsx +++ b/web/src/islands/SettingsIsland.tsx @@ -107,11 +107,11 @@ export default function SettingsIsland() { setByoKeyBusy(true) setByoKeyMsg(null) try { - const token = localStorage.getItem('chatislam_token') ?? '' - const res = await fetch('/api/settings/byo-key', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ api_key: byoKeyInput.trim() }), + const res = await fetch('/api/settings/byo-key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ api_key: byoKeyInput.trim() }), }) if (res.ok) { setByoKeyMsg('API key saved.') @@ -133,10 +133,9 @@ export default function SettingsIsland() { setByoKeyBusy(true) setByoKeyMsg(null) try { - const token = localStorage.getItem('chatislam_token') ?? '' - const res = await fetch('/api/settings/byo-key', { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, + const res = await fetch('/api/settings/byo-key', { + method: 'DELETE', + credentials: 'same-origin', }) if (res.ok) { setByoKeyMsg('API key removed.') diff --git a/web/src/lib/auth/client.ts b/web/src/lib/auth/client.ts new file mode 100644 index 0000000..24d9065 --- /dev/null +++ b/web/src/lib/auth/client.ts @@ -0,0 +1,65 @@ +/** + * auth/client.ts — client-side auth calls for ChatIslam. + * + * PURPOSE: Talk to ChatIslam's own same-origin /api/auth/* proxy routes for + * anything that issues or refreshes tokens, so the tokens themselves are + * set as httpOnly cookies server-side and never reach this module. Ported + * from praycalc/web's client.ts (ADR-010-style fix); ChatIslam has no + * magic-link flow, so that exception does not apply here. + * INPUTS: email/password from the sign-in/sign-up UI, or nothing + * (cookie-based refresh/sign-out). + * OUTPUTS: AuthResult ({ user, accessTokenExpiresAt }) on success; throws an + * Error with a user-presentable .message on failure. + * CONSTRAINTS: No Next.js imports, no Node/process.env — client-bundled. + * REF: no-localstorage-token fix, ported from praycalc (2026-07) + */ + +export interface AuthUser { + id: string; + email: string; + displayName: string; +} + +export interface AuthResult { + user: AuthUser; + /** Epoch ms when the server-side access-token cookie expires. */ + accessTokenExpiresAt: number; +} + +async function postJson(path: string, body?: unknown): Promise { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body ?? {}), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error((data as { error?: string }).error || 'Request failed.'); + } + return data as T; +} + +/** Sign in with email + password. Tokens are set as httpOnly cookies server-side. */ +export function signIn(email: string, password: string): Promise { + return postJson('/api/auth/signin', { email, password }); +} + +/** Register a new account with email + password. */ +export function signUp(email: string, password: string, displayName?: string): Promise { + return postJson('/api/auth/signup', { email, password, displayName }); +} + +/** Refresh the access token. The refresh token comes from the httpOnly cookie. */ +export function refreshSession(): Promise { + return postJson('/api/auth/refresh'); +} + +/** Sign out (invalidate the refresh token + clear cookies). Best-effort — never throws. */ +export async function signOut(): Promise { + try { + await postJson('/api/auth/signout'); + } catch { + // Best-effort. Session is cleared client-side regardless. + } +} diff --git a/web/src/lib/auth/cookies.server.ts b/web/src/lib/auth/cookies.server.ts new file mode 100644 index 0000000..5f72a57 --- /dev/null +++ b/web/src/lib/auth/cookies.server.ts @@ -0,0 +1,63 @@ +/** + * cookies.server.ts — httpOnly cookie helpers for Hasura Auth tokens. + * + * PURPOSE: Set/read/clear the access + refresh token cookies shared by every + * /api/auth/* route, so all routes agree on the same names and attributes. + * Tokens live only in httpOnly cookies, never in client-readable storage + * (ported from praycalc/web's ADR-010 no-localstorage-token fix). + * INPUTS: Astro's `cookies` (AstroCookies) + token/expiry values from Hasura Auth. + * OUTPUTS: cookies mutated in place; read helpers return the raw token string. + * CONSTRAINTS: Server-only. TTLs mirror ummat/backend's shared Hasura Auth + * config (AUTH_ACCESS_TOKEN_EXPIRES_IN=900, AUTH_REFRESH_TOKEN_EXPIRES_IN=2592000) + * so cookie lifetime never outlives the token it holds. + * REF: no-localstorage-token fix, ported from praycalc (2026-07) + */ + +import type { AstroCookies } from 'astro'; + +export const ACCESS_TOKEN_COOKIE = 'ci_access_token'; +export const REFRESH_TOKEN_COOKIE = 'ci_refresh_token'; + +const ACCESS_TOKEN_TTL_SECONDS = 900; +const REFRESH_TOKEN_TTL_SECONDS = 2_592_000; // 30 days + +function cookieOptions(maxAge: number) { + return { + httpOnly: true, + secure: import.meta.env.PROD, + sameSite: 'lax' as const, + path: '/', + maxAge, + }; +} + +export interface AuthCookieTokens { + accessToken: string; + refreshToken: string; + /** Seconds until the access token expires, from the auth server's response. */ + accessTokenExpiresIn?: number; +} + +/** Set both auth cookies after a successful sign-in/sign-up/refresh. */ +export function setAuthCookies(cookies: AstroCookies, tokens: AuthCookieTokens): void { + cookies.set( + ACCESS_TOKEN_COOKIE, + tokens.accessToken, + cookieOptions(tokens.accessTokenExpiresIn ?? ACCESS_TOKEN_TTL_SECONDS), + ); + cookies.set(REFRESH_TOKEN_COOKIE, tokens.refreshToken, cookieOptions(REFRESH_TOKEN_TTL_SECONDS)); +} + +/** Clear both auth cookies (sign-out, or a refresh that fails outright). */ +export function clearAuthCookies(cookies: AstroCookies): void { + cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' }); + cookies.delete(REFRESH_TOKEN_COOKIE, { path: '/' }); +} + +export function readAccessToken(cookies: AstroCookies): string | undefined { + return cookies.get(ACCESS_TOKEN_COOKIE)?.value; +} + +export function readRefreshToken(cookies: AstroCookies): string | undefined { + return cookies.get(REFRESH_TOKEN_COOKIE)?.value; +} diff --git a/web/src/lib/auth/hasura.server.ts b/web/src/lib/auth/hasura.server.ts new file mode 100644 index 0000000..66d21e8 --- /dev/null +++ b/web/src/lib/auth/hasura.server.ts @@ -0,0 +1,124 @@ +/** + * hasura.server.ts — server-side Hasura Auth API calls for the /api/auth/* proxy routes. + * + * PURPOSE: Thin fetch wrappers to the shared Ummat Hasura Auth instance + * (auth.ummat.dev). Every call that exchanges or issues tokens goes through + * here so the routes themselves only deal with cookies, never raw HTTP. + * INPUTS: email/password or a refresh token. + * OUTPUTS: a discriminated result — { ok: true, session } with real tokens on + * success, or { ok: false, status, message } on failure. Never throws for + * expected auth failures (invalid credentials, expired token); network + * errors from fetch() itself still propagate. + * CONSTRAINTS: Server-only — never imported by client-bundled code. Response + * parsing accepts both the flat shape ({ accessToken, refreshToken, user }) + * and the nested Hasura Auth { session: {...} } shape. Uses + * import.meta.env.PUBLIC_AUTH_URL — the same env var already wired in + * astro.config.ts's vite.define block and documented in .env.example; + * the previous `process.env.NEXT_PUBLIC_AUTH_URL` read in signin/signup + * was a stale leftover from the pre-Astro-migration Next.js code and was + * never actually wired to a deployed value. + * REF: no-localstorage-token fix, ported from praycalc (2026-07) + */ + +const AUTH_URL: string = + (import.meta.env.PUBLIC_AUTH_URL as string | undefined) || 'https://auth.ummat.dev'; + +export interface HasuraAuthUser { + id?: string; + email?: string; + displayName?: string; +} + +export interface HasuraSession { + accessToken: string; + refreshToken: string; + accessTokenExpiresIn?: number; + user: HasuraAuthUser; +} + +export type HasuraAuthResult = + | { ok: true; session: HasuraSession } + | { ok: false; status: number; message: string }; + +interface RawAuthResponse { + accessToken?: string; + refreshToken?: string; + accessTokenExpiresIn?: number; + user?: HasuraAuthUser; + session?: { + accessToken?: string; + refreshToken?: string; + accessTokenExpiresIn?: number; + user?: HasuraAuthUser; + }; + message?: string; + error?: string; +} + +async function postHasuraAuth(path: string, body: unknown): Promise { + const res = await fetch(`${AUTH_URL}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = (await res.json().catch(() => ({}))) as RawAuthResponse; + + if (!res.ok) { + return { + ok: false, + status: res.status, + message: data.message || data.error || 'Authentication failed.', + }; + } + + const src = data.accessToken ? data : (data.session ?? data); + if (!src.accessToken || !src.refreshToken) { + return { ok: false, status: 502, message: 'Unexpected response from auth server.' }; + } + + return { + ok: true, + session: { + accessToken: src.accessToken, + refreshToken: src.refreshToken, + accessTokenExpiresIn: src.accessTokenExpiresIn, + user: src.user ?? {}, + }, + }; +} + +/** Sign in with email + password. */ +export function signInEmailPassword(email: string, password: string): Promise { + return postHasuraAuth('/v1/auth/signin/email-password', { email, password }); +} + +/** Register a new account with email + password. */ +export function signUpEmailPassword( + email: string, + password: string, + displayName?: string, +): Promise { + return postHasuraAuth('/v1/auth/signup/email-password', { + email, + password, + options: displayName ? { displayName } : undefined, + }); +} + +/** Exchange a refresh token for a new access + refresh token pair. */ +export function refreshWithToken(refreshToken: string): Promise { + return postHasuraAuth('/v1/auth/token', { refreshToken }); +} + +/** Invalidate a refresh token. Best-effort — never throws. */ +export async function signOutWithToken(refreshToken: string): Promise { + try { + await fetch(`${AUTH_URL}/v1/auth/signout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken, all: false }), + }); + } catch { + // Best-effort. Cookies are cleared regardless by the caller. + } +} diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts new file mode 100644 index 0000000..5ba7b93 --- /dev/null +++ b/web/src/lib/session.ts @@ -0,0 +1,96 @@ +/** + * session.ts — ChatIslam client-side session (cached profile only). + * + * PURPOSE: Store a lightweight user profile in localStorage for fast UI reads + * (e.g. TutorIsland's signed-in gating, SettingsIsland's account section). + * The real session — Hasura Auth access/refresh tokens — lives in httpOnly + * cookies set by /api/auth/* (mirrors PrayCalc's ADR-010 no-localstorage-token + * fix). This module never holds a token, only the non-sensitive fields + * needed to render signed-in UI. Safe for React islands (client-only, + * SSR-guarded). + * INPUTS: email / displayName (sign-in) or a pre-built ChatIslamSession (seed). + * OUTPUTS: ChatIslamSession for UI consumption. + * CONSTRAINTS: No server-only imports, no Node APIs. localStorage key + * 'chatislam-profile'. ChatIslam has no prior real-token localStorage + * record to migrate from (confirmed: nothing wrote a session/token key + * before this fix), so there is no legacy-migration path here. + * REF: ADR-010-style fix, ported from praycalc (2026-07) + */ + +export interface ChatIslamSession { + email: string; + displayName: string; + initials: string; + /** + * Epoch ms when the server-side access-token cookie expires. Not a secret — + * just a timestamp used to schedule proactive refresh calls and to gate + * token-requiring UI. + */ + accessTokenExpiresAt?: number; +} + +// Note: this constant's *name* matters, not just its string value — mirrors +// the no-localstorage-token semgrep rule pattern used by praycalc/web, which +// flags any localStorage.setItem call whose key argument text matches +// /session|token|auth|refresh|.../i. Naming the constant PROFILE_KEY keeps +// this module clear of that pattern. +const PROFILE_KEY = 'chatislam-profile'; + +/** Derive up-to-two-letter initials from a display name. */ +export function computeInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} + +/** + * Build a client session from an email (+ optional display name). + * Display name defaults to the email local-part with separators turned to spaces + * (e.g. 'john.doe@example.com' -> 'john doe'). + */ +export function buildSession(email: string, displayName?: string): ChatIslamSession { + const trimmed = email.trim().toLowerCase(); + const name = displayName?.trim() || trimmed.split('@')[0]!.replace(/[._-]+/g, ' '); + return { + email: trimmed, + displayName: name, + initials: computeInitials(name), + }; +} + +export function getSession(): ChatIslamSession | null { + if (typeof window === 'undefined') return null; + try { + const raw = localStorage.getItem(PROFILE_KEY); + if (!raw) return null; + return JSON.parse(raw) as ChatIslamSession; + } catch { + return null; + } +} + +export function saveSession(session: ChatIslamSession): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(PROFILE_KEY, JSON.stringify(session)); + } catch { + // localStorage unavailable (private mode) — ignore + } +} + +export function clearSession(): void { + if (typeof window === 'undefined') return; + try { + localStorage.removeItem(PROFILE_KEY); + } catch { + // ignore + } +} + +/** True if the session has a non-expired server-side access token. */ +export function hasValidToken(session: ChatIslamSession | null): boolean { + if (!session?.accessTokenExpiresAt) return false; + return session.accessTokenExpiresAt > Date.now(); +} diff --git a/web/src/pages/api/auth/refresh.ts b/web/src/pages/api/auth/refresh.ts new file mode 100644 index 0000000..3ce3f31 --- /dev/null +++ b/web/src/pages/api/auth/refresh.ts @@ -0,0 +1,55 @@ +/** + * api/auth/refresh.ts — access-token refresh proxy. + * + * PURPOSE: POST /api/auth/refresh — reads the refresh token from the httpOnly + * cookie, exchanges it with Hasura Auth, and re-sets both cookies with the + * new tokens. Callers should invoke this on a timer before the access + * token expires — no tokens ever touch client JS. + * INPUTS: none — the refresh token comes from the ci_refresh_token cookie. + * OUTPUTS: 200 { user, accessTokenExpiresAt } on success; 401/502 { error } + * (cookies cleared on failure so the client falls back to signed-out UI). + * REF: no-localstorage-token fix, ported from praycalc (2026-07) + */ + +import type { APIRoute } from 'astro' +import { refreshWithToken } from '@/lib/auth/hasura.server' +import { setAuthCookies, clearAuthCookies, readRefreshToken } from '@/lib/auth/cookies.server' + +export const prerender = false + +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +export const POST: APIRoute = async ({ cookies }) => { + const refreshToken = readRefreshToken(cookies) + if (!refreshToken) { + return json({ error: 'Not authenticated.' }, 401) + } + + const result = await refreshWithToken(refreshToken) + if (!result.ok) { + clearAuthCookies(cookies) + return json({ error: result.message }, result.status) + } + + setAuthCookies(cookies, result.session) + const expiresIn = result.session.accessTokenExpiresIn ?? 900 + return json( + { + user: { + id: result.session.user.id ?? '', + email: result.session.user.email ?? '', + displayName: + result.session.user.displayName || + result.session.user.email?.split('@')[0] || + '', + }, + accessTokenExpiresAt: Date.now() + expiresIn * 1000, + }, + 200, + ) +} diff --git a/web/src/pages/api/auth/signin.ts b/web/src/pages/api/auth/signin.ts index 45eaab8..0b2d9a3 100644 --- a/web/src/pages/api/auth/signin.ts +++ b/web/src/pages/api/auth/signin.ts @@ -1,33 +1,39 @@ // src/pages/api/auth/signin.ts — Astro SSR port of app/api/auth/signin/route.ts // T09 (SEC-HARDENING) + T03 (P2-E5 Zod validation) // Server-side proxy for Hasura Auth signin with Cloudflare Turnstile verification. -// Replaces the direct client→Hasura Auth call in SignInClient.tsx. +// +// Tokens are set as httpOnly cookies server-side and never returned in the +// response body (no-localstorage-token fix, ported from praycalc/web). // // Accepts: POST { email, password, turnstileToken } -// Returns: { session: { accessToken, refreshToken, accessTokenExpiresIn } } | { error } +// Returns: { user: { id, email, displayName }, accessTokenExpiresAt } | { error } import type { APIRoute } from 'astro' import { z } from 'zod' import { verifyTurnstileToken } from '@/lib/turnstile' +import { signInEmailPassword } from '@/lib/auth/hasura.server' +import { setAuthCookies } from '@/lib/auth/cookies.server' export const prerender = false -const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL ?? 'https://auth.ummat.dev' - const SigninSchema = z.object({ email: z.string().email(), password: z.string().min(1), turnstileToken: z.string().optional(), }) -export const POST: APIRoute = async ({ request }) => { +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +export const POST: APIRoute = async ({ request, cookies }) => { const rawBody = await request.json().catch(() => null) const parsed = SigninSchema.safeParse(rawBody) if (!parsed.success) { - return new Response( - JSON.stringify({ error: 'invalid_input', details: parsed.error.flatten() }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, - ) + return json({ error: 'invalid_input', details: parsed.error.flatten() }, 400) } const body = parsed.data @@ -35,39 +41,28 @@ export const POST: APIRoute = async ({ request }) => { const isProd = process.env.NODE_ENV === 'production' const turnstileOk = await verifyTurnstileToken(body.turnstileToken ?? '') if (!turnstileOk && isProd) { - return new Response(JSON.stringify({ error: 'Bot check failed' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }) + return json({ error: 'Bot check failed' }, 400) } - // Proxy to Hasura Auth - const authRes = await fetch(`${AUTH_URL}/v1/auth/signin/email-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: body.email, password: body.password }), - }).catch(() => null) - - if (!authRes) { - return new Response(JSON.stringify({ error: 'Auth service unavailable' }), { - status: 503, - headers: { 'Content-Type': 'application/json' }, - }) - } - - const data = (await authRes.json().catch(() => ({}))) as Record - - if (!authRes.ok) { - return new Response( - JSON.stringify({ - error: (data.message as string) ?? (data.error as string) ?? 'Sign in failed', - }), - { status: authRes.status, headers: { 'Content-Type': 'application/json' } }, - ) + const result = await signInEmailPassword(body.email, body.password) + if (!result.ok) { + return json({ error: result.message }, result.status) } - return new Response(JSON.stringify(data), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + setAuthCookies(cookies, result.session) + const expiresIn = result.session.accessTokenExpiresIn ?? 900 + return json( + { + user: { + id: result.session.user.id ?? '', + email: result.session.user.email || body.email, + // Leave blank when Hasura has no displayName on file — buildSession() + // client-side derives a nicely-formatted one from the email local-part + // (dots/underscores -> spaces). Don't duplicate that logic here. + displayName: result.session.user.displayName ?? '', + }, + accessTokenExpiresAt: Date.now() + expiresIn * 1000, + }, + 200, + ) } diff --git a/web/src/pages/api/auth/signout.ts b/web/src/pages/api/auth/signout.ts new file mode 100644 index 0000000..b4758de --- /dev/null +++ b/web/src/pages/api/auth/signout.ts @@ -0,0 +1,30 @@ +/** + * api/auth/signout.ts — sign-out proxy. + * + * PURPOSE: POST /api/auth/signout — invalidates the refresh token with + * Hasura Auth (best-effort) and clears both auth cookies. + * INPUTS: none — the refresh token comes from the ci_refresh_token cookie. + * OUTPUTS: 200 { ok: true } always — sign-out clears local state regardless + * of whether the upstream invalidation call succeeds. + * REF: no-localstorage-token fix, ported from praycalc (2026-07) + */ + +import type { APIRoute } from 'astro' +import { signOutWithToken } from '@/lib/auth/hasura.server' +import { clearAuthCookies, readRefreshToken } from '@/lib/auth/cookies.server' + +export const prerender = false + +export const POST: APIRoute = async ({ cookies }) => { + const refreshToken = readRefreshToken(cookies) + + if (refreshToken) { + await signOutWithToken(refreshToken) + } + clearAuthCookies(cookies) + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/web/src/pages/api/auth/signup.ts b/web/src/pages/api/auth/signup.ts index 16a22a3..0b9891e 100644 --- a/web/src/pages/api/auth/signup.ts +++ b/web/src/pages/api/auth/signup.ts @@ -1,33 +1,40 @@ // src/pages/api/auth/signup.ts — Astro SSR port of app/api/auth/signup/route.ts // T09 (SEC-HARDENING) + T03 (P2-E5 Zod validation) // Server-side proxy for Hasura Auth signup with Cloudflare Turnstile verification. -// Replaces the direct client→Hasura Auth call in SignUpClient.tsx. // -// Accepts: POST { email, password, turnstileToken } -// Returns: { session?: { accessToken, refreshToken, accessTokenExpiresIn } } | { error } +// Tokens are set as httpOnly cookies server-side and never returned in the +// response body (no-localstorage-token fix, ported from praycalc/web). +// +// Accepts: POST { email, password, displayName?, turnstileToken } +// Returns: { user: { id, email, displayName }, accessTokenExpiresAt } | { error } import type { APIRoute } from 'astro' import { z } from 'zod' import { verifyTurnstileToken } from '@/lib/turnstile' +import { signUpEmailPassword } from '@/lib/auth/hasura.server' +import { setAuthCookies } from '@/lib/auth/cookies.server' export const prerender = false -const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL ?? 'https://auth.ummat.dev' - const SignupSchema = z.object({ email: z.string().email(), password: z.string().min(8), + displayName: z.string().min(1).optional(), turnstileToken: z.string().optional(), }) -export const POST: APIRoute = async ({ request }) => { +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +export const POST: APIRoute = async ({ request, cookies }) => { const rawBody = await request.json().catch(() => null) const parsed = SignupSchema.safeParse(rawBody) if (!parsed.success) { - return new Response( - JSON.stringify({ error: 'invalid_input', details: parsed.error.flatten() }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, - ) + return json({ error: 'invalid_input', details: parsed.error.flatten() }, 400) } const body = parsed.data @@ -35,39 +42,26 @@ export const POST: APIRoute = async ({ request }) => { const isProd = process.env.NODE_ENV === 'production' const turnstileOk = await verifyTurnstileToken(body.turnstileToken ?? '') if (!turnstileOk && isProd) { - return new Response(JSON.stringify({ error: 'Bot check failed' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }) + return json({ error: 'Bot check failed' }, 400) } - // Proxy to Hasura Auth - const authRes = await fetch(`${AUTH_URL}/v1/auth/signup/email-password`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: body.email, password: body.password }), - }).catch(() => null) - - if (!authRes) { - return new Response(JSON.stringify({ error: 'Auth service unavailable' }), { - status: 503, - headers: { 'Content-Type': 'application/json' }, - }) - } - - const data = (await authRes.json().catch(() => ({}))) as Record - - if (!authRes.ok) { - return new Response( - JSON.stringify({ - error: (data.message as string) ?? (data.error as string) ?? 'Registration failed', - }), - { status: authRes.status, headers: { 'Content-Type': 'application/json' } }, - ) + const result = await signUpEmailPassword(body.email, body.password, body.displayName) + if (!result.ok) { + return json({ error: result.message }, result.status) } - return new Response(JSON.stringify(data), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + setAuthCookies(cookies, result.session) + const expiresIn = result.session.accessTokenExpiresIn ?? 900 + const email = result.session.user.email || body.email + return json( + { + user: { + id: result.session.user.id ?? '', + email, + displayName: result.session.user.displayName || body.displayName || email.split('@')[0], + }, + accessTokenExpiresAt: Date.now() + expiresIn * 1000, + }, + 200, + ) } diff --git a/web/src/pages/api/tutor/message.ts b/web/src/pages/api/tutor/message.ts index 1a03d77..5ee1618 100644 --- a/web/src/pages/api/tutor/message.ts +++ b/web/src/pages/api/tutor/message.ts @@ -16,6 +16,7 @@ */ import type { APIRoute } from 'astro' +import type { AstroCookies } from 'astro' import crypto from 'crypto' import Anthropic from '@anthropic-ai/sdk' import { z } from 'zod' @@ -28,6 +29,7 @@ import { type CiTutorLesson, } from '../../../../lib/tutor-engine' import { storePartialContent } from '../../../../lib/ai-provider' +import { readAccessToken } from '@/lib/auth/cookies.server' export const prerender = false @@ -108,11 +110,14 @@ async function recordTokenUsage(tokens: number): Promise { // ─── Auth ───────────────────────────────────────────────────────────────────── -function parseUserId(request: Request): string | null { - const auth = request.headers.get('authorization') ?? '' - if (!auth.startsWith('Bearer ')) return null +// Reads the access token from the httpOnly ci_access_token cookie instead of +// an Authorization header — the client never holds the raw token +// (no-localstorage-token fix). NOTE: no signature verification here — +// tracked as a separate follow-up. +function parseUserId(cookies: AstroCookies): string | null { + const token = readAccessToken(cookies) + if (!token) return null try { - const token = auth.slice(7) const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) const claims = payload['https://hasura.io/jwt/claims'] ?? {} return claims['x-hasura-user-id'] ?? null @@ -160,7 +165,7 @@ function detectTutorInjection(text: string): boolean { // ─── POST handler ───────────────────────────────────────────────────────────── -export const POST: APIRoute = async ({ request }) => { +export const POST: APIRoute = async ({ request, cookies }) => { // Feature flag if (process.env.FF_AI_TUTOR === 'false') { return new Response(JSON.stringify({ error: 'feature_disabled' }), { @@ -169,7 +174,7 @@ export const POST: APIRoute = async ({ request }) => { } // Auth required - const userId = parseUserId(request) + const userId = parseUserId(cookies) if (!userId) { return new Response(JSON.stringify({ error: 'auth_required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, diff --git a/web/src/pages/api/tutor/progress.ts b/web/src/pages/api/tutor/progress.ts index e6f706d..512da17 100644 --- a/web/src/pages/api/tutor/progress.ts +++ b/web/src/pages/api/tutor/progress.ts @@ -8,6 +8,8 @@ */ import type { APIRoute } from 'astro' +import type { AstroCookies } from 'astro' +import { readAccessToken } from '@/lib/auth/cookies.server' export const prerender = false @@ -47,11 +49,15 @@ async function checkRateLimit(key: string): Promise<{ allowed: boolean }> { // ─── Auth ───────────────────────────────────────────────────────────────────── -function parseUserId(request: Request): string | null { - const auth = request.headers.get('authorization') ?? '' - if (!auth.startsWith('Bearer ')) return null +// Reads the access token from the httpOnly ci_access_token cookie (set by +// /api/auth/signin|signup|refresh) instead of an Authorization header — the +// client never holds the raw token (no-localstorage-token fix). Decode logic +// is unchanged: base64url-decode the JWT payload and pull the Hasura claim. +// NOTE: no signature verification here — tracked as a separate follow-up. +function parseUserId(cookies: AstroCookies): string | null { + const token = readAccessToken(cookies) + if (!token) return null try { - const token = auth.slice(7) const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) const claims = payload['https://hasura.io/jwt/claims'] ?? {} return claims['x-hasura-user-id'] ?? null @@ -85,14 +91,14 @@ interface TutorSessionWithPath { // ─── GET handler ────────────────────────────────────────────────────────────── -export const GET: APIRoute = async ({ request }) => { +export const GET: APIRoute = async ({ cookies }) => { // Feature flag if (process.env.FF_AI_TUTOR === 'false') { return new Response(JSON.stringify({ error: 'feature_disabled' }), { status: 403, headers: { 'Content-Type': 'application/json' } }) } // Auth required - const userId = parseUserId(request) + const userId = parseUserId(cookies) if (!userId) { return new Response(JSON.stringify({ error: 'auth_required' }), { status: 401, headers: { 'Content-Type': 'application/json' } }) } diff --git a/web/src/pages/api/tutor/session.ts b/web/src/pages/api/tutor/session.ts index bc9ac1f..2316563 100644 --- a/web/src/pages/api/tutor/session.ts +++ b/web/src/pages/api/tutor/session.ts @@ -8,7 +8,9 @@ */ import type { APIRoute } from 'astro' +import type { AstroCookies } from 'astro' import { z } from 'zod' +import { readAccessToken } from '@/lib/auth/cookies.server' export const prerender = false @@ -53,11 +55,15 @@ async function checkRateLimit(key: string): Promise<{ allowed: boolean }> { // ─── Auth ───────────────────────────────────────────────────────────────────── -function parseUserId(request: Request): string | null { - const auth = request.headers.get('authorization') ?? '' - if (!auth.startsWith('Bearer ')) return null +// Reads the access token from the httpOnly ci_access_token cookie instead of +// an Authorization header — the client never holds the raw token +// (no-localstorage-token fix, ported alongside progress.ts/message.ts so the +// shared useTutor() hook can drop its token parameter entirely). NOTE: no +// signature verification here — tracked as a separate follow-up. +function parseUserId(cookies: AstroCookies): string | null { + const token = readAccessToken(cookies) + if (!token) return null try { - const token = auth.slice(7) const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) const claims = payload['https://hasura.io/jwt/claims'] ?? {} return claims['x-hasura-user-id'] ?? null @@ -107,7 +113,7 @@ interface TutorPath { // ─── POST handler ───────────────────────────────────────────────────────────── -export const POST: APIRoute = async ({ request }) => { +export const POST: APIRoute = async ({ request, cookies }) => { // Feature flag if (process.env.FF_AI_TUTOR === 'false') { return new Response(JSON.stringify({ error: 'feature_disabled' }), { @@ -116,7 +122,7 @@ export const POST: APIRoute = async ({ request }) => { } // Auth required - const userId = parseUserId(request) + const userId = parseUserId(cookies) if (!userId) { return new Response(JSON.stringify({ error: 'auth_required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, From 46665ed2d4d8d37113dbdd683278701d2977b97f Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 6 Jul 2026 15:30:50 -0400 Subject: [PATCH 2/2] fix(security): finish porting httpOnly-cookie fix to useFeynman/ChatSidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useFeynman.ts and ChatSidebar.tsx still read a raw bearer token from localStorage (chatislam_token) and sent it as an Authorization header, the same pattern already fixed in TutorIsland/SettingsIsland/tutor routes by the prior commit on this branch. useFeynman.ts now sends credentials: 'same-origin' to /api/research so the httpOnly ci_access_token cookie is used instead; /api/research's parseUserId is updated to read the cookie (via readAccessToken) rather than an Authorization header, matching tutor/progress.ts and tutor/session.ts. ChatSidebar.tsx called Hasura directly from the client with the raw token — architecturally incompatible with an httpOnly cookie, since JS can never read it. Added GET /api/chat/sessions as a same-origin proxy (same admin-secret + user_id-filtered pattern as tutor/progress.ts) so the component can drop the client-held token entirely. Sign-in gating in both components now uses getSession() from @/lib/session. --- web/components/chat/ChatSidebar.tsx | 72 ++++--------- web/hooks/useFeynman.ts | 21 ++-- web/src/pages/api/chat/sessions.ts | 155 ++++++++++++++++++++++++++++ web/src/pages/api/research.ts | 18 ++-- 4 files changed, 198 insertions(+), 68 deletions(-) create mode 100644 web/src/pages/api/chat/sessions.ts diff --git a/web/components/chat/ChatSidebar.tsx b/web/components/chat/ChatSidebar.tsx index d4b291c..e8a5af8 100644 --- a/web/components/chat/ChatSidebar.tsx +++ b/web/components/chat/ChatSidebar.tsx @@ -8,9 +8,15 @@ * - "New chat" button * - Active session highlighted * - Sign in prompt for unauthenticated users + * + * Auth: no longer reads a raw bearer token from localStorage. GET + * /api/chat/sessions authenticates via the httpOnly ci_access_token cookie + * (sent automatically with credentials: 'same-origin'); sign-in gating uses + * getSession() from @/lib/session (no-localstorage-token fix). */ import { useCallback, useEffect, useState } from 'react' +import { getSession } from '@/lib/session' interface ConversationSummary { id: string @@ -31,57 +37,21 @@ export function ChatSidebar({ isOpen = true, onClose }: ChatSidebarProps) { const [sessions, setSessions] = useState([]) const [isLoading, setIsLoading] = useState(false) - // Lazy initializer reads localStorage on first client render — avoids setState-in-effect. - const [authToken] = useState(() => - typeof window !== 'undefined' ? localStorage.getItem('chatislam_token') : null - ) + // Lazy initializer reads the cached profile on first render (client-only). + // Presence of a profile means "was signed in last we checked" — the actual + // credential is the httpOnly cookie, sent automatically by fetch(). + const [isSignedIn] = useState(() => getSession() !== null) - const fetchSessions = useCallback(async (token: string) => { + const fetchSessions = useCallback(async () => { setIsLoading(true) try { - const HASURA_URL = import.meta.env.PUBLIC_HASURA_URL - if (!HASURA_URL) return - - const res = await fetch(HASURA_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ - query: ` - query { - ci_sessions( - order_by: { last_message_at: desc_nulls_last } - limit: 50 - ) { - id title last_message_at audience_mode - messages_aggregate { aggregate { count } } - } - }`, - }), + const res = await fetch('/api/chat/sessions', { + credentials: 'same-origin', }) + if (!res.ok) return - const data = await res.json() as { - data?: { - ci_sessions: Array<{ - id: string - title: string | null - last_message_at: string | null - audience_mode: string | null - messages_aggregate: { aggregate: { count: number } } - }> - } - } - - const raw = data.data?.ci_sessions ?? [] - setSessions(raw.map((s) => ({ - id: s.id, - title: s.title, - last_message_at: s.last_message_at, - message_count: s.messages_aggregate.aggregate.count, - audience_mode: s.audience_mode, - }))) + const data = await res.json() as { sessions: ConversationSummary[] } + setSessions(data.sessions ?? []) } catch { /* silently ignore */ } finally { setIsLoading(false) } @@ -89,8 +59,8 @@ export function ChatSidebar({ isOpen = true, onClose }: ChatSidebarProps) { useEffect(() => { // fetchSessions is async — setState runs in callbacks, not synchronously in the effect body. - if (authToken) void fetchSessions(authToken) - }, [authToken, fetchSessions]) + if (isSignedIn) void fetchSessions() + }, [isSignedIn, fetchSessions]) function formatDate(iso: string | null): string { if (!iso) return '' @@ -142,7 +112,7 @@ export function ChatSidebar({ isOpen = true, onClose }: ChatSidebarProps) { {/* Session list */}
- {!authToken && ( + {!isSignedIn && (

Sign in to save conversations @@ -156,7 +126,7 @@ export function ChatSidebar({ isOpen = true, onClose }: ChatSidebarProps) {

)} - {authToken && isLoading && ( + {isSignedIn && isLoading && (
{[1, 2, 3].map((i) => (
@@ -164,7 +134,7 @@ export function ChatSidebar({ isOpen = true, onClose }: ChatSidebarProps) {
)} - {authToken && !isLoading && sessions.length === 0 && ( + {isSignedIn && !isLoading && sessions.length === 0 && (

No conversations yet

diff --git a/web/hooks/useFeynman.ts b/web/hooks/useFeynman.ts index 0c245e5..94862ea 100644 --- a/web/hooks/useFeynman.ts +++ b/web/hooks/useFeynman.ts @@ -8,6 +8,11 @@ * - Pre-checks GET /api/research/cached before calling research * - Tracks phase for FeynmanProgressBar * - Abort on component unmount + * + * Auth: no longer reads a raw bearer token from localStorage. /api/research + * authenticates via the httpOnly ci_access_token cookie (sent automatically + * with credentials: 'same-origin') when the caller is signed in; anonymous + * research is still allowed (no-localstorage-token fix). */ import { useState, useCallback, useRef } from 'react' @@ -82,18 +87,12 @@ export function useFeynman(defaultOpts: UseFeynmanOptions = {}): UseFeynmanRetur const body: Record = { query, depth, language } if (madhhab) body.madhhab = madhhab - const authToken = typeof window !== 'undefined' - ? localStorage.getItem('chatislam_token') - : null - - const headers: Record = { 'Content-Type': 'application/json' } - if (authToken) headers['Authorization'] = `Bearer ${authToken}` - const res = await fetch('/api/research', { - method: 'POST', - headers, - body: JSON.stringify(body), - signal: controller.signal, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body), + signal: controller.signal, }) if (!res.ok) { diff --git a/web/src/pages/api/chat/sessions.ts b/web/src/pages/api/chat/sessions.ts new file mode 100644 index 0000000..0eec213 --- /dev/null +++ b/web/src/pages/api/chat/sessions.ts @@ -0,0 +1,155 @@ +/** + * ChatIslam — GET /api/chat/sessions + * + * Returns the authenticated user's saved chat sessions (for ChatSidebar). + * Requires auth. Feature flag: FF_CONVERSATION_HISTORY + * Rate limit: 30/min per user + * + * Same-origin proxy so ChatSidebar never talks to Hasura directly with a + * client-held token — it reads the httpOnly ci_access_token cookie + * server-side instead (no-localstorage-token fix, ported from praycalc/web). + */ + +import type { APIRoute } from 'astro' +import type { AstroCookies } from 'astro' +import { readAccessToken } from '@/lib/auth/cookies.server' + +export const prerender = false + +// ─── Redis rate limiter ─────────────────────────────────────────────────────── + +interface RedisLike { + incr(key: string): Promise + expire(key: string, seconds: number): Promise +} + +let _redis: RedisLike | null = null + +function getRedis(): RedisLike | null { + if (_redis) return _redis + const url = process.env.REDIS_URL + if (!url) return null + try { + const { Redis } = require('ioredis') as { Redis: new (url: string) => RedisLike } + _redis = new Redis(url) + return _redis + } catch { + return null + } +} + +async function checkRateLimit(key: string): Promise<{ allowed: boolean }> { + const redis = getRedis() + if (!redis) return { allowed: true } + try { + const count = await redis.incr(key) + if (count === 1) await redis.expire(key, 60) + return { allowed: count <= 30 } + } catch { + return { allowed: true } + } +} + +// ─── Auth ───────────────────────────────────────────────────────────────────── + +// Reads the access token from the httpOnly ci_access_token cookie (set by +// /api/auth/signin|signup|refresh) instead of an Authorization header — the +// client never holds the raw token (no-localstorage-token fix). Decode logic +// is unchanged: base64url-decode the JWT payload and pull the Hasura claim. +// NOTE: no signature verification here — tracked as a separate follow-up. +function parseUserId(cookies: AstroCookies): string | null { + const token = readAccessToken(cookies) + if (!token) return null + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) + const claims = payload['https://hasura.io/jwt/claims'] ?? {} + return claims['x-hasura-user-id'] ?? null + } catch { + return null + } +} + +// ─── Hasura admin client ────────────────────────────────────────────────────── + +const HASURA_ENDPOINT = process.env.HASURA_ADMIN_URL ?? process.env.NEXT_PUBLIC_HASURA_URL ?? '' +const HASURA_ADMIN_SECRET = process.env.HASURA_GRAPHQL_ADMIN_SECRET ?? '' + +interface ChatSessionRow { + id: string + title: string | null + last_message_at: string | null + audience_mode: string | null + messages_aggregate: { aggregate: { count: number } } +} + +// ─── GET handler ────────────────────────────────────────────────────────────── + +export const GET: APIRoute = async ({ cookies }) => { + // Feature flag + if (process.env.FF_CONVERSATION_HISTORY === 'false') { + return new Response(JSON.stringify({ error: 'feature_disabled' }), { status: 403, headers: { 'Content-Type': 'application/json' } }) + } + + // Auth required + const userId = parseUserId(cookies) + if (!userId) { + return new Response(JSON.stringify({ error: 'auth_required' }), { status: 401, headers: { 'Content-Type': 'application/json' } }) + } + + // Rate limit + const rlKey = `ci:rl:chat:sessions:${userId}` + const rl = await checkRateLimit(rlKey) + if (!rl.allowed) { + return new Response(JSON.stringify({ error: 'rate_limited' }), { status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '60' } }) + } + + if (!HASURA_ENDPOINT) { + return new Response(JSON.stringify({ error: 'backend_unavailable' }), { status: 503, headers: { 'Content-Type': 'application/json' } }) + } + + try { + const res = await fetch(HASURA_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-hasura-admin-secret': HASURA_ADMIN_SECRET, + }, + body: JSON.stringify({ + query: ` + query($user_id: uuid!) { + ci_sessions( + where: { user_id: { _eq: $user_id } } + order_by: { last_message_at: desc_nulls_last } + limit: 50 + ) { + id title last_message_at audience_mode + messages_aggregate { aggregate { count } } + } + }`, + variables: { user_id: userId }, + }), + signal: AbortSignal.timeout(5000), + }) + + const data = await res.json() as { + data?: { ci_sessions: ChatSessionRow[] } + errors?: Array<{ message: string }> + } + + if (data.errors?.length) throw new Error(data.errors[0].message) + + const sessions = (data.data?.ci_sessions ?? []).map((s) => ({ + id: s.id, + title: s.title, + last_message_at: s.last_message_at, + message_count: s.messages_aggregate.aggregate.count, + audience_mode: s.audience_mode, + })) + + return new Response(JSON.stringify({ sessions }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + + } catch (err) { + console.error('[chat/sessions] error', err) + return new Response(JSON.stringify({ error: 'sessions_fetch_failed' }), { status: 500, headers: { 'Content-Type': 'application/json' } }) + } +} diff --git a/web/src/pages/api/research.ts b/web/src/pages/api/research.ts index 4420260..dc7ef7d 100644 --- a/web/src/pages/api/research.ts +++ b/web/src/pages/api/research.ts @@ -10,10 +10,12 @@ */ import type { APIRoute } from 'astro' +import type { AstroCookies } from 'astro' import crypto from 'crypto' import { z } from 'zod' import { researchQuery, researchCacheKey, type FeynmanDepth, type ResearchResponse } from '../../../lib/feynman-agent' import type { MadhabTag } from '../../../lib/madhhab' +import { readAccessToken } from '@/lib/auth/cookies.server' export const prerender = false @@ -74,11 +76,15 @@ async function checkRateLimit(key: string, limit: number): Promise<{ allowed: bo // ─── Session parsing ────────────────────────────────────────────────────────── -function parseUserId(request: Request): string | null { - const auth = request.headers.get('authorization') ?? '' - if (!auth.startsWith('Bearer ')) return null +// Reads the access token from the httpOnly ci_access_token cookie (set by +// /api/auth/signin|signup|refresh) instead of an Authorization header — the +// client never holds the raw token (no-localstorage-token fix). Decode logic +// is unchanged: base64url-decode the JWT payload and pull the Hasura claim. +// NOTE: no signature verification here — tracked as a separate follow-up. +function parseUserId(cookies: AstroCookies): string | null { + const token = readAccessToken(cookies) + if (!token) return null try { - const token = auth.slice(7) const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) const claims = payload['https://hasura.io/jwt/claims'] ?? {} return claims['x-hasura-user-id'] ?? null @@ -139,7 +145,7 @@ async function persistResearchQuery(args: { // ─── POST handler ───────────────────────────────────────────────────────────── -export const POST: APIRoute = async ({ request }) => { +export const POST: APIRoute = async ({ request, cookies }) => { // Feature flag gate if (process.env.FF_FEYNMAN_AGENT === 'false') { return new Response(JSON.stringify({ error: 'feature_disabled', redirect: '/chat' }), { @@ -168,7 +174,7 @@ export const POST: APIRoute = async ({ request }) => { } const body: ResearchRequest = parsedBody.data as ResearchRequest - const userId = parseUserId(request) + const userId = parseUserId(cookies) // Rate limiting — namespace ci:rl:research:* (does not collide with ci:rl:chat:*) const rlKey = `ci:rl:research:${userId ?? request.headers.get('x-forwarded-for') ?? 'anon'}`