aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-13 23:46:16 +0900
committernsfisis <nsfisis@gmail.com>2026-02-13 23:46:16 +0900
commit7258ca81812a24edd382438ce6e9ebc538549427 (patch)
tree9bbc034be62777a2412d871211188268d7c56da4 /frontend
parent7757f26295cbf19c4d6fa068e2cb6bdc2589d01a (diff)
downloadphperkaigi-2026-albatross-7258ca81812a24edd382438ce6e9ebc538549427.tar.gz
phperkaigi-2026-albatross-7258ca81812a24edd382438ce6e9ebc538549427.tar.zst
phperkaigi-2026-albatross-7258ca81812a24edd382438ce6e9ebc538549427.zip
feat(auth): store JWT in HTTP-only cookie instead of JS-accessible cookie
Prevent XSS-based token theft by making the JWT inaccessible to JavaScript. The backend now sets/clears the cookie via Set-Cookie headers, and the frontend retrieves user info from /api/me instead of decoding the JWT directly. - Add JWTCookieMiddleware to parse cookie and inject claims into context - Add /me and /logout endpoints to OpenAPI spec and handlers - Update PostLogin to return user object + Set-Cookie header - Replace Authorization header auth with cookie-based auth throughout - Rewrite frontend auth to use /api/me instead of jwt-decode - Remove jwt-decode dependency - Configure CORS with credentials for local dev Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/api/client.ts37
-rw-r--r--frontend/app/api/schema.d.ts112
-rw-r--r--frontend/app/auth.ts42
-rw-r--r--frontend/app/components/ProtectedRoute.tsx6
-rw-r--r--frontend/app/components/PublicOnlyRoute.tsx6
-rw-r--r--frontend/app/hooks/useAuth.ts63
-rw-r--r--frontend/app/pages/DashboardPage.tsx9
-rw-r--r--frontend/app/pages/GolfPlayPage.tsx9
-rw-r--r--frontend/app/pages/GolfWatchPage.tsx9
-rw-r--r--frontend/app/pages/TournamentPage.tsx5
-rw-r--r--frontend/package-lock.json10
-rw-r--r--frontend/package.json1
12 files changed, 139 insertions, 170 deletions
diff --git a/frontend/app/api/client.ts b/frontend/app/api/client.ts
index 86f2506..26c20d1 100644
--- a/frontend/app/api/client.ts
+++ b/frontend/app/api/client.ts
@@ -9,6 +9,7 @@ const apiOrigin =
const client = createClient<paths>({
baseUrl: `${apiOrigin}${API_BASE_PATH}`,
+ credentials: "include",
});
export async function apiLogin(username: string, password: string) {
@@ -22,15 +23,20 @@ export async function apiLogin(username: string, password: string) {
return data;
}
-class AuthenticatedApiClient {
- constructor(public readonly token: string) {}
+export async function apiLogout() {
+ const { error } = await client.POST("/logout");
+ if (error) throw new Error(error.message);
+}
+export async function apiGetMe() {
+ const { data, error } = await client.GET("/me");
+ if (error) return null;
+ return data;
+}
+
+class AuthenticatedApiClient {
async getGames() {
- const { data, error } = await client.GET("/games", {
- params: {
- header: this._getAuthorizationHeader(),
- },
- });
+ const { data, error } = await client.GET("/games");
if (error) throw new Error(error.message);
return data;
}
@@ -38,7 +44,6 @@ class AuthenticatedApiClient {
async getGame(gameId: number) {
const { data, error } = await client.GET("/games/{game_id}", {
params: {
- header: this._getAuthorizationHeader(),
path: { game_id: gameId },
},
});
@@ -51,7 +56,6 @@ class AuthenticatedApiClient {
"/games/{game_id}/play/latest_state",
{
params: {
- header: this._getAuthorizationHeader(),
path: { game_id: gameId },
},
},
@@ -63,7 +67,6 @@ class AuthenticatedApiClient {
async postGamePlayCode(gameId: number, code: string) {
const { error } = await client.POST("/games/{game_id}/play/code", {
params: {
- header: this._getAuthorizationHeader(),
path: { game_id: gameId },
},
body: { code },
@@ -74,7 +77,6 @@ class AuthenticatedApiClient {
async postGamePlaySubmit(gameId: number, code: string) {
const { data, error } = await client.POST("/games/{game_id}/play/submit", {
params: {
- header: this._getAuthorizationHeader(),
path: { game_id: gameId },
},
body: { code },
@@ -86,7 +88,6 @@ class AuthenticatedApiClient {
async getGameWatchRanking(gameId: number) {
const { data, error } = await client.GET("/games/{game_id}/watch/ranking", {
params: {
- header: this._getAuthorizationHeader(),
path: { game_id: gameId },
},
});
@@ -99,7 +100,6 @@ class AuthenticatedApiClient {
"/games/{game_id}/watch/latest_states",
{
params: {
- header: this._getAuthorizationHeader(),
path: { game_id: gameId },
},
},
@@ -117,21 +117,18 @@ class AuthenticatedApiClient {
) {
const { data, error } = await client.GET("/tournament", {
params: {
- header: this._getAuthorizationHeader(),
query: { game1, game2, game3, game4, game5 },
},
});
if (error) throw new Error(error.message);
return data;
}
-
- _getAuthorizationHeader() {
- return { Authorization: `Bearer ${this.token}` };
- }
}
-export function createApiClient(token: string) {
- return new AuthenticatedApiClient(token);
+const apiClient = new AuthenticatedApiClient();
+
+export function createApiClient() {
+ return apiClient;
}
export const ApiClientContext = createContext<AuthenticatedApiClient | null>(
diff --git a/frontend/app/api/schema.d.ts b/frontend/app/api/schema.d.ts
index 04bfc10..6f9e270 100644
--- a/frontend/app/api/schema.d.ts
+++ b/frontend/app/api/schema.d.ts
@@ -21,6 +21,40 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/logout": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** User logout */
+ post: operations["postLogout"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/me": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get current user */
+ get: operations["getMe"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/games": {
parameters: {
query?: never;
@@ -291,7 +325,6 @@ export interface components {
};
};
parameters: {
- header_authorization: string;
path_game_id: number;
};
requestBodies: never;
@@ -325,20 +358,59 @@ export interface operations {
};
content: {
"application/json": {
- /** @example xxxxx.xxxxx.xxxxx */
- token: string;
+ user: components["schemas"]["User"];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
- getGames: {
+ postLogout: {
parameters: {
query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successfully logged out */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
};
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ getMe: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Current user info */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ user: components["schemas"]["User"];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ getGames: {
+ parameters: {
+ query?: never;
+ header?: never;
path?: never;
cookie?: never;
};
@@ -362,9 +434,7 @@ export interface operations {
getGame: {
parameters: {
query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
+ header?: never;
path: {
game_id: components["parameters"]["path_game_id"];
};
@@ -391,9 +461,7 @@ export interface operations {
getGamePlayLatestState: {
parameters: {
query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
+ header?: never;
path: {
game_id: components["parameters"]["path_game_id"];
};
@@ -420,9 +488,7 @@ export interface operations {
postGamePlayCode: {
parameters: {
query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
+ header?: never;
path: {
game_id: components["parameters"]["path_game_id"];
};
@@ -452,9 +518,7 @@ export interface operations {
postGamePlaySubmit: {
parameters: {
query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
+ header?: never;
path: {
game_id: components["parameters"]["path_game_id"];
};
@@ -484,9 +548,7 @@ export interface operations {
getGameWatchRanking: {
parameters: {
query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
+ header?: never;
path: {
game_id: components["parameters"]["path_game_id"];
};
@@ -513,9 +575,7 @@ export interface operations {
getGameWatchLatestStates: {
parameters: {
query?: never;
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
+ header?: never;
path: {
game_id: components["parameters"]["path_game_id"];
};
@@ -550,9 +610,7 @@ export interface operations {
game4: number;
game5: number;
};
- header: {
- Authorization: components["parameters"]["header_authorization"];
- };
+ header?: never;
path?: never;
cookie?: never;
};
diff --git a/frontend/app/auth.ts b/frontend/app/auth.ts
index 7a3d10d..769ac27 100644
--- a/frontend/app/auth.ts
+++ b/frontend/app/auth.ts
@@ -1,45 +1,3 @@
-import { type JwtPayload, jwtDecode } from "jwt-decode";
import type { components } from "./api/schema";
export type User = components["schemas"]["User"];
-
-const COOKIE_NAME = "albatross_token";
-
-export function getToken(): string | null {
- const match = document.cookie
- .split("; ")
- .find((row) => row.startsWith(`${COOKIE_NAME}=`));
- if (!match) return null;
- return match.split("=").slice(1).join("=");
-}
-
-export function setToken(token: string): void {
- document.cookie = `${COOKIE_NAME}=${token}; path=/; SameSite=Lax`;
-}
-
-export function clearToken(): void {
- document.cookie = `${COOKIE_NAME}=; path=/; SameSite=Lax; max-age=0`;
-}
-
-export function getUserFromToken(): User | null {
- const token = getToken();
- if (!token) return null;
- try {
- return jwtDecode<User & JwtPayload>(token);
- } catch {
- return null;
- }
-}
-
-export function isTokenExpired(): boolean {
- const token = getToken();
- if (!token) return true;
- try {
- const decoded = jwtDecode<JwtPayload>(token);
- if (decoded.exp == null) return false;
- // If the token will expire in less than an hour, treat it as expired.
- return new Date((decoded.exp - 3600) * 1000) < new Date();
- } catch {
- return true;
- }
-}
diff --git a/frontend/app/components/ProtectedRoute.tsx b/frontend/app/components/ProtectedRoute.tsx
index 3aeaebc..b943696 100644
--- a/frontend/app/components/ProtectedRoute.tsx
+++ b/frontend/app/components/ProtectedRoute.tsx
@@ -6,7 +6,11 @@ export default function ProtectedRoute({
}: {
children: React.ReactNode;
}) {
- const { isLoggedIn } = useAuth();
+ const { isLoggedIn, isLoading } = useAuth();
+
+ if (isLoading) {
+ return null;
+ }
if (!isLoggedIn) {
return <Redirect to="/login" />;
diff --git a/frontend/app/components/PublicOnlyRoute.tsx b/frontend/app/components/PublicOnlyRoute.tsx
index 2527918..7b3ef9d 100644
--- a/frontend/app/components/PublicOnlyRoute.tsx
+++ b/frontend/app/components/PublicOnlyRoute.tsx
@@ -6,7 +6,11 @@ export default function PublicOnlyRoute({
}: {
children: React.ReactNode;
}) {
- const { isLoggedIn } = useAuth();
+ const { isLoggedIn, isLoading } = useAuth();
+
+ if (isLoading) {
+ return null;
+ }
if (isLoggedIn) {
return <Redirect to="/dashboard" />;
diff --git a/frontend/app/hooks/useAuth.ts b/frontend/app/hooks/useAuth.ts
index 8762734..7913a0e 100644
--- a/frontend/app/hooks/useAuth.ts
+++ b/frontend/app/hooks/useAuth.ts
@@ -1,58 +1,33 @@
-import { useCallback, useSyncExternalStore } from "react";
-import { apiLogin } from "../api/client";
-import {
- type User,
- clearToken,
- getToken,
- getUserFromToken,
- isTokenExpired,
- setToken,
-} from "../auth";
-
-// Simple external store to trigger re-renders when auth state changes.
-let authVersion = 0;
-const listeners = new Set<() => void>();
-
-function subscribe(callback: () => void) {
- listeners.add(callback);
- return () => listeners.delete(callback);
-}
-
-function getSnapshot() {
- return authVersion;
-}
-
-function notifyAuthChange() {
- authVersion++;
- for (const listener of listeners) {
- listener();
- }
-}
+import { useCallback, useEffect, useState } from "react";
+import { apiGetMe, apiLogin, apiLogout } from "../api/client";
+import type { User } from "../auth";
export function useAuth(): {
user: User | null;
- token: string | null;
isLoggedIn: boolean;
+ isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
- logout: () => void;
+ logout: () => Promise<void>;
} {
- useSyncExternalStore(subscribe, getSnapshot);
+ const [user, setUser] = useState<User | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
- const token = getToken();
- const isExpired = isTokenExpired();
- const user = isExpired ? null : getUserFromToken();
- const isLoggedIn = user !== null && !isExpired;
+ useEffect(() => {
+ apiGetMe()
+ .then((data) => setUser(data?.user ?? null))
+ .catch(() => setUser(null))
+ .finally(() => setIsLoading(false));
+ }, []);
const login = useCallback(async (username: string, password: string) => {
- const { token } = await apiLogin(username, password);
- setToken(token);
- notifyAuthChange();
+ const { user } = await apiLogin(username, password);
+ setUser(user);
}, []);
- const logout = useCallback(() => {
- clearToken();
- notifyAuthChange();
+ const logout = useCallback(async () => {
+ await apiLogout();
+ setUser(null);
}, []);
- return { user, token: isLoggedIn ? token : null, isLoggedIn, login, logout };
+ return { user, isLoggedIn: user !== null, isLoading, login, logout };
}
diff --git a/frontend/app/pages/DashboardPage.tsx b/frontend/app/pages/DashboardPage.tsx
index c81014d..708a867 100644
--- a/frontend/app/pages/DashboardPage.tsx
+++ b/frontend/app/pages/DashboardPage.tsx
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import { useLocation } from "wouter";
import { createApiClient } from "../api/client";
import type { components } from "../api/schema";
-import { getToken } from "../auth";
import BorderedContainerWithCaption from "../components/BorderedContainerWithCaption";
import NavigateLink from "../components/NavigateLink";
import UserIcon from "../components/UserIcon";
@@ -22,17 +21,15 @@ export default function DashboardPage() {
const [loading, setLoading] = useState(true);
useEffect(() => {
- const token = getToken();
- if (!token) return;
- const apiClient = createApiClient(token);
+ const apiClient = createApiClient();
apiClient
.getGames()
.then(({ games }) => setGames(games))
.finally(() => setLoading(false));
}, []);
- function handleLogout() {
- logout();
+ async function handleLogout() {
+ await logout();
navigate("/");
}
diff --git a/frontend/app/pages/GolfPlayPage.tsx b/frontend/app/pages/GolfPlayPage.tsx
index 3fddbf8..c183ac8 100644
--- a/frontend/app/pages/GolfPlayPage.tsx
+++ b/frontend/app/pages/GolfPlayPage.tsx
@@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ApiClientContext, createApiClient } from "../api/client";
import type { components } from "../api/schema";
-import { getToken } from "../auth";
import GolfPlayApp from "../components/GolfPlayApp";
import { APP_NAME } from "../config";
import { useAuth } from "../hooks/useAuth";
@@ -29,9 +28,7 @@ export default function GolfPlayPage({ gameId }: { gameId: string }) {
);
useEffect(() => {
- const token = getToken();
- if (!token) return;
- const apiClient = createApiClient(token);
+ const apiClient = createApiClient();
Promise.all([
apiClient.getGame(gameIdNum),
apiClient.getGamePlayLatestState(gameIdNum),
@@ -57,11 +54,9 @@ export default function GolfPlayPage({ gameId }: { gameId: string }) {
);
}
- const token = getToken()!;
-
return (
<JotaiProvider store={store}>
- <ApiClientContext.Provider value={createApiClient(token)}>
+ <ApiClientContext.Provider value={createApiClient()}>
<GolfPlayApp
key={game.game_id}
game={game}
diff --git a/frontend/app/pages/GolfWatchPage.tsx b/frontend/app/pages/GolfWatchPage.tsx
index 317f860..519a030 100644
--- a/frontend/app/pages/GolfWatchPage.tsx
+++ b/frontend/app/pages/GolfWatchPage.tsx
@@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from "react";
import { useLocation } from "wouter";
import { ApiClientContext, createApiClient } from "../api/client";
import type { components } from "../api/schema";
-import { getToken } from "../auth";
import GolfWatchApp from "../components/GolfWatchApp";
import { APP_NAME } from "../config";
import { usePageTitle } from "../hooks/usePageTitle";
@@ -31,9 +30,7 @@ export default function GolfWatchPage({ gameId }: { gameId: string }) {
);
useEffect(() => {
- const token = getToken();
- if (!token) return;
- const apiClient = createApiClient(token);
+ const apiClient = createApiClient();
Promise.all([
apiClient.getGame(gameIdNum),
apiClient.getGameWatchRanking(gameIdNum),
@@ -61,11 +58,9 @@ export default function GolfWatchPage({ gameId }: { gameId: string }) {
);
}
- const token = getToken()!;
-
return (
<JotaiProvider store={store}>
- <ApiClientContext.Provider value={createApiClient(token)}>
+ <ApiClientContext.Provider value={createApiClient()}>
<GolfWatchApp
key={game.game_id}
game={game}
diff --git a/frontend/app/pages/TournamentPage.tsx b/frontend/app/pages/TournamentPage.tsx
index 43ea790..404fa2d 100644
--- a/frontend/app/pages/TournamentPage.tsx
+++ b/frontend/app/pages/TournamentPage.tsx
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { createApiClient } from "../api/client";
import type { components } from "../api/schema";
-import { getToken } from "../auth";
import BorderedContainer from "../components/BorderedContainer";
import UserIcon from "../components/UserIcon";
import { APP_NAME } from "../config";
@@ -241,9 +240,7 @@ export default function TournamentPage() {
setPlayerIDs(pIDs);
- const token = getToken();
- if (!token) return;
- const apiClient = createApiClient(token);
+ const apiClient = createApiClient();
apiClient
.getTournament(game1, game2, game3, game4, game5)
.then(({ tournament }) => setTournament(tournament))
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index cd06b43..b4799d1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,7 +12,6 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"hast-util-to-jsx-runtime": "^2.3.6",
"jotai": "^2.12.1",
- "jwt-decode": "^4.0.0",
"openapi-fetch": "^0.13.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -5364,15 +5363,6 @@
"node": ">=4.0"
}
},
- "node_modules/jwt-decode": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
- "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c024f7a..b7b5f09 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -20,7 +20,6 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"hast-util-to-jsx-runtime": "^2.3.6",
"jotai": "^2.12.1",
- "jwt-decode": "^4.0.0",
"openapi-fetch": "^0.13.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",