refactor(auth): require bearer token only, drop legacy token header and cookie
Authenticate requests solely from the Authorization: Bearer header on the server; stop accepting the legacy `token` header and `usertoken` cookie. The frontend no longer sets auth cookies (only clears legacy ones), omits credentials on all requests, and drops the redundant `token` header. Project event SSE now streams over fetch so it can carry the Authorization header, which EventSource cannot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<T>(path: string, body: unknown): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "omit",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
@@ -30,7 +31,7 @@ async function request<T>(path: string, body: unknown): Promise<T> {
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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<AuthUser>): 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<string, string>;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -159,7 +159,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 <accessToken>`.
|
||||
|
||||
`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 <accessToken>`. 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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user