diff --git a/frontend/src/infrastructure/authGateway.ts b/frontend/src/infrastructure/authGateway.ts index eb28cbf..c9f8924 100644 --- a/frontend/src/infrastructure/authGateway.ts +++ b/frontend/src/infrastructure/authGateway.ts @@ -2,14 +2,15 @@ import type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUse import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages"; const sessionStorageKey = "img-infinite-canvas.auth.session"; -const accessTokenCookie = "usertoken"; -const refreshTokenCookie = "refreshToken"; +const legacyAccessTokenCookie = "usertoken"; +const legacyRefreshTokenCookie = "refreshToken"; async function request(path: string, body: unknown): Promise { let response: Response; try { response = await fetch(path, { method: "POST", + credentials: "omit", headers: { "Content-Type": "application/json" }, @@ -30,7 +31,7 @@ async function request(path: string, body: unknown): Promise { async function get(path: string): Promise { let response: Response; try { - response = await fetch(path, { cache: "no-store" }); + response = await fetch(path, { cache: "no-store", credentials: "omit" }); } catch (err) { throw createNetworkFailure("auth", err); } @@ -55,7 +56,8 @@ async function apiRequest(path: string, init: RequestInit = {}): Promise { response = await fetch(path, { ...init, headers, - cache: "no-store" + cache: "no-store", + credentials: "omit" }); } catch (err) { throw createNetworkFailure("auth", err); @@ -131,6 +133,7 @@ export const authGateway = { if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) { await fetch(upload.uploadUrl, { method: "PUT", + credentials: "omit", headers: upload.headers, body: file }); @@ -141,6 +144,7 @@ export const authGateway = { export function readStoredAuthSession(): StoredAuthSession | null { if (typeof window === "undefined") return null; + clearLegacyAuthCookies(); const raw = window.localStorage.getItem(sessionStorageKey); if (!raw) return null; try { @@ -160,10 +164,7 @@ export function storeAuthSession(session: AuthSession): StoredAuthSession { const stored = { ...session, savedAt: Date.now() }; if (typeof window !== "undefined") { window.localStorage.setItem(sessionStorageKey, JSON.stringify(stored)); - setCookie(accessTokenCookie, session.accessToken, session.expiresIn); - if (session.refreshToken && session.refreshExpiresIn) { - setCookie(refreshTokenCookie, session.refreshToken, session.refreshExpiresIn); - } + clearLegacyAuthCookies(); } return stored; } @@ -188,8 +189,7 @@ export function updateStoredAuthUser(userPatch: Partial): StoredAuthSe export function clearStoredAuthSession() { if (typeof window === "undefined") return; window.localStorage.removeItem(sessionStorageKey); - expireCookie(accessTokenCookie); - expireCookie(refreshTokenCookie); + clearLegacyAuthCookies(); } export function getAuthAccessToken() { @@ -197,11 +197,10 @@ export function getAuthAccessToken() { } export function authHeaders() { - const token = getAuthAccessToken(); - if (!token) return {}; + const accessToken = getAuthAccessToken(); + if (!accessToken) return {}; return { - Authorization: `Bearer ${token}`, - token + Authorization: `Bearer ${accessToken}` } satisfies Record; } @@ -210,8 +209,9 @@ function isSessionExpired(session: StoredAuthSession) { return expiresIn > 0 && session.savedAt + expiresIn * 1000 <= Date.now() + 30_000; } -function setCookie(name: string, value: string, maxAge: number) { - document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${Math.max(0, Math.floor(maxAge))}; SameSite=Lax`; +function clearLegacyAuthCookies() { + expireCookie(legacyAccessTokenCookie); + expireCookie(legacyRefreshTokenCookie); } function expireCookie(name: string) { diff --git a/frontend/src/infrastructure/designGateway.ts b/frontend/src/infrastructure/designGateway.ts index aebe8fd..42bb2f7 100644 --- a/frontend/src/infrastructure/designGateway.ts +++ b/frontend/src/infrastructure/designGateway.ts @@ -159,7 +159,8 @@ async function request(path: string, init?: RequestInit): Promise { try { response = await fetch(path, { ...init, - headers + headers, + credentials: "omit" }); } catch (err) { throw createNetworkFailure("design", err); @@ -393,43 +394,56 @@ export const designGateway = { }, projectEvents(projectId: string, handlers: ProjectEventHandlers, options: ProjectEventsOptions = {}) { - const source = new EventSource(projectEventsUrl(projectId, options)); + const controller = new AbortController(); let settled = false; - const readProject = (event: MessageEvent) => { - const payload = JSON.parse(event.data) as { project: Project }; + const readProject = (data: string) => { + const payload = JSON.parse(data) as { project: Project }; return payload.project; }; - - source.addEventListener("project", (event) => handlers.onProject?.(readProject(event as MessageEvent))); - source.addEventListener("thinking", (event) => handlers.onThinking?.(JSON.parse((event as MessageEvent).data) as AgentMessage)); - source.addEventListener("text_extraction", (event) => handlers.onTextExtraction?.(JSON.parse((event as MessageEvent).data) as ExtractNodeTextResponse)); - source.addEventListener("done", (event) => { + const close = () => { settled = true; - handlers.onDone?.(readProject(event as MessageEvent)); - source.close(); - }); - source.addEventListener("timeout", (event) => { - settled = true; - handlers.onTimeout?.(readProject(event as MessageEvent)); - source.close(); - }); - source.addEventListener("stream_error", (event) => { - settled = true; - if ((event as MessageEvent).data) { - const payload = JSON.parse((event as MessageEvent).data) as { error?: string }; - handlers.onError?.(new Error(payload.error ?? "Event stream failed")); - } else { - handlers.onError?.(new Error("Event stream failed")); + controller.abort(); + }; + const onEvent = (event: string, data: string) => { + if (event === "project") { + handlers.onProject?.(readProject(data)); + return; + } + if (event === "thinking") { + handlers.onThinking?.(JSON.parse(data) as AgentMessage); + return; + } + if (event === "text_extraction") { + handlers.onTextExtraction?.(JSON.parse(data) as ExtractNodeTextResponse); + return; + } + if (event === "done") { + settled = true; + handlers.onDone?.(readProject(data)); + controller.abort(); + return; + } + if (event === "timeout") { + settled = true; + handlers.onTimeout?.(readProject(data)); + controller.abort(); + return; + } + if (event === "stream_error") { + settled = true; + const payload = data ? (JSON.parse(data) as { error?: string }) : {}; + handlers.onError?.(new Error(payload.error ?? "Event stream failed")); + controller.abort(); } - source.close(); - }); - source.onerror = () => { - if (settled || source.readyState === EventSource.CLOSED) return; - handlers.onError?.(new Error("Event stream failed")); - source.close(); }; - return () => source.close(); + void streamProjectEvents(projectEventsUrl(projectId, options), controller.signal, onEvent).catch((error: Error) => { + if (settled || controller.signal.aborted) return; + settled = true; + handlers.onError?.(error); + }); + + return close; }, saveCanvas( @@ -507,6 +521,7 @@ export const designGateway = { if (upload.uploadUrl && !upload.uploadUrl.includes("/memory-assets/")) { await fetch(upload.uploadUrl, { method: "PUT", + credentials: "omit", headers: upload.headers, body: file }); @@ -866,3 +881,56 @@ function projectEventsUrl(projectId: string, options: ProjectEventsOptions) { const query = params.toString(); return `/api/projects/${projectId}/events${query ? `?${query}` : ""}`; } + +async function streamProjectEvents(url: string, signal: AbortSignal, onEvent: (event: string, data: string) => void) { + const headers = new Headers(); + Object.entries(authHeaders()).forEach(([key, value]) => headers.set(key, value)); + const response = await fetch(url, { + headers, + signal, + cache: "no-store", + credentials: "omit" + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({ error: response.statusText })); + throw createApiFailure("design", response, payload); + } + if (!response.body) { + throw new Error("Event stream failed"); + } + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ""; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buffer += value; + const parts = buffer.split(/\r?\n\r?\n/); + buffer = parts.pop() ?? ""; + parts.forEach((part) => emitServerSentEvent(part, onEvent)); + } + if (buffer.trim()) { + emitServerSentEvent(buffer, onEvent); + } + } finally { + reader.releaseLock(); + } +} + +function emitServerSentEvent(raw: string, onEvent: (event: string, data: string) => void) { + let event = "message"; + const data: string[] = []; + raw.split(/\r?\n/).forEach((line) => { + if (line.startsWith("event:")) { + event = line.slice("event:".length).trim(); + return; + } + if (line.startsWith("data:")) { + data.push(line.slice("data:".length).trimStart()); + } + }); + if (data.length > 0) { + onEvent(event, data.join("\n")); + } +} diff --git a/server/API.md b/server/API.md index b0ee78c..d9c5869 100644 --- a/server/API.md +++ b/server/API.md @@ -86,7 +86,7 @@ Development response includes an email preview so the frontend can render the sa } ``` -Returns an authenticated session with `accessToken`, `refreshToken`, and the user profile. Project creation, generation, upload, save, and deletion require this login token. +Returns an authenticated session with `accessToken`, `refreshToken`, and the user profile. Project creation, generation, upload, save, and deletion require `Authorization: Bearer `. `POST /api/auth/global/google-login` @@ -124,7 +124,7 @@ After binding succeeds, later QR logins use only `code` and `state`. ## Account -All account endpoints require the bearer token returned by login. Account profile state is owned by the auth module and persisted in PostgreSQL or the configured in-memory store. +All account endpoints require `Authorization: Bearer `. Cookie auth and the legacy `token` header are not used. Account profile state is owned by the auth module and persisted in PostgreSQL or the configured in-memory store. `GET /api/account/profile` @@ -151,7 +151,7 @@ Removes non-current devices when a future server-side session registry exists. I `POST /api/account/logout` -Records a logout request server-side and returns `{"removed": 0}`. The frontend then clears its local access token and cookies. +Records a logout request server-side and returns `{"removed": 0}`. The frontend then clears its locally stored session. ## Projects diff --git a/server/internal/handler/user_context_middleware.go b/server/internal/handler/user_context_middleware.go index d40ef1b..5c4fa7b 100644 --- a/server/internal/handler/user_context_middleware.go +++ b/server/internal/handler/user_context_middleware.go @@ -34,20 +34,7 @@ func UserContextMiddleware(authenticator bearerAuthenticator) func(http.HandlerF } func userIDFromRequestAuth(ctx context.Context, r *http.Request, authenticator bearerAuthenticator) (string, bool) { - if tokenUserID, ok := authenticator.UserIDFromBearer(ctx, r.Header.Get("Authorization")); ok { - return tokenUserID, true - } - if token := strings.TrimSpace(r.Header.Get("token")); token != "" { - if tokenUserID, ok := authenticator.UserIDFromBearer(ctx, "Bearer "+token); ok { - return tokenUserID, true - } - } - if cookie, err := r.Cookie("usertoken"); err == nil && strings.TrimSpace(cookie.Value) != "" { - if tokenUserID, ok := authenticator.UserIDFromBearer(ctx, "Bearer "+cookie.Value); ok { - return tokenUserID, true - } - } - return "", false + return authenticator.UserIDFromBearer(ctx, r.Header.Get("Authorization")) } func requiresAuthenticatedUser(r *http.Request) bool { diff --git a/server/internal/handler/user_context_middleware_test.go b/server/internal/handler/user_context_middleware_test.go new file mode 100644 index 0000000..2472b8a --- /dev/null +++ b/server/internal/handler/user_context_middleware_test.go @@ -0,0 +1,60 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "img_infinite_canvas/internal/domain/design" +) + +type fakeBearerAuthenticator struct{} + +func (fakeBearerAuthenticator) UserIDFromBearer(_ context.Context, authorization string) (string, bool) { + if authorization == "Bearer valid" { + return "91cd197b-8255-4172-9981-77391fd38f33", true + } + return "", false +} + +func TestUserContextMiddlewareUsesAuthorizationHeader(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/projects", nil) + req.Header.Set("Authorization", "Bearer valid") + rec := httptest.NewRecorder() + + called := false + UserContextMiddleware(fakeBearerAuthenticator{})(func(w http.ResponseWriter, r *http.Request) { + called = true + if got := design.UserIDFromContext(r.Context()); got != "91cd197b-8255-4172-9981-77391fd38f33" { + t.Fatalf("expected authenticated user in context, got %s", got) + } + w.WriteHeader(http.StatusNoContent) + })(rec, req) + + if !called { + t.Fatal("expected next handler to be called") + } + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code) + } +} + +func TestUserContextMiddlewareIgnoresLegacyTokenHeaderAndCookie(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/projects", nil) + req.Header.Set("token", "valid") + req.AddCookie(&http.Cookie{Name: "usertoken", Value: "valid"}) + rec := httptest.NewRecorder() + + called := false + UserContextMiddleware(fakeBearerAuthenticator{})(func(w http.ResponseWriter, r *http.Request) { + called = true + })(rec, req) + + if called { + t.Fatal("expected next handler not to be called") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) + } +}