feat(api): add auth middleware to protect game endpoints

- Add requireAuth middleware using Better Auth session validation
- Apply to all game routes (start, answer)
- Unauthenticated requests return 401
This commit is contained in:
lila 2026-04-12 13:38:32 +02:00
parent 91a3112d8b
commit a3685a9e68
13 changed files with 196 additions and 24 deletions

View file

@ -13,9 +13,11 @@
"@glossa/db": "workspace:*",
"@glossa/shared": "workspace:*",
"better-auth": "^1.6.2",
"cors": "^2.8.6",
"express": "^5.2.1"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/supertest": "^7.2.0",
"supertest": "^7.2.2",

View file

@ -4,10 +4,12 @@ import { toNodeHandler } from "better-auth/node";
import { auth } from "./lib/auth.js";
import { apiRouter } from "./routes/apiRouter.js";
import { errorHandler } from "./middleware/errorHandler.js";
import cors from "cors";
export function createApp() {
const app: Express = express();
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
app.all("/api/auth/*splat", toNodeHandler(auth));
app.use(express.json());
app.use("/api/v1", apiRouter);

View file

@ -1,9 +1,11 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@glossa/db";
import * as schema from "@glossa/db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
database: drizzleAdapter(db, { provider: "pg", schema }),
trustedOrigins: ["http://localhost:5173"],
socialProviders: {
google: {
clientId: process.env["GOOGLE_CLIENT_ID"] as string,

View file

@ -0,0 +1,20 @@
import type { Request, Response, NextFunction } from "express";
import { fromNodeHeaders } from "better-auth/node";
import { auth } from "../lib/auth.js";
export const requireAuth = async (
req: Request,
res: Response,
next: NextFunction,
) => {
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (!session) {
res.status(401).json({ success: false, error: "Unauthorized" });
return;
}
next();
};

View file

@ -1,8 +1,10 @@
import express from "express";
import type { Router } from "express";
import { createGame, submitAnswer } from "../controllers/gameController.js";
import { requireAuth } from "../middleware/authMiddleware.js";
export const gameRouter: Router = express.Router();
gameRouter.use(requireAuth);
gameRouter.post("/start", createGame);
gameRouter.post("/answer", submitAnswer);

View file

@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: "http://localhost:3000",
});
export const { signIn, signOut, useSession } = authClient;

View file

@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as PlayRouteImport } from './routes/play'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
@ -18,6 +19,11 @@ const PlayRoute = PlayRouteImport.update({
path: '/play',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
@ -32,30 +38,34 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' | '/play'
fullPaths: '/' | '/about' | '/login' | '/play'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/play'
id: '__root__' | '/' | '/about' | '/play'
to: '/' | '/about' | '/login' | '/play'
id: '__root__' | '/' | '/about' | '/login' | '/play'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
PlayRoute: typeof PlayRoute
}
@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlayRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
id: '/about'
path: '/about'
@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
PlayRoute: PlayRoute,
}
export const routeTree = rootRouteImport

View file

@ -1,20 +1,51 @@
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
import {
createRootRoute,
Link,
Outlet,
useNavigate,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { useSession, signOut } from "../lib/auth-client";
const RootLayout = () => (
const RootLayout = () => {
const { data: session } = useSession();
const navigate = useNavigate();
return (
<>
<div className="p-2 flex gap-2">
<div className="p-2 flex gap-2 items-center">
<Link to="/" className="[&.active]:font-bold">
Home
</Link>{" "}
</Link>
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
<div className="ml-auto">
{session ? (
<button
className="text-sm text-gray-600 hover:text-gray-900"
onClick={async () => {
await signOut();
navigate({ to: "/" });
}}
>
Sign out ({session.user.name})
</button>
) : (
<Link
to="/login"
className="text-sm text-blue-600 hover:text-blue-800"
>
Sign in
</Link>
)}
</div>
</div>
<hr />
<Outlet />
<TanStackRouterDevtools />
</>
);
};
export const Route = createRootRoute({ component: RootLayout });

View file

@ -0,0 +1,44 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { signIn, useSession } from "../lib/auth-client";
const LoginPage = () => {
const { data: session, isPending } = useSession();
const navigate = useNavigate();
if (isPending) return <div className="p-4">Loading...</div>;
if (session) {
navigate({ to: "/" });
return null;
}
return (
<div className="flex flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-bold">Sign in to Glossa</h1>
<button
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
onClick={() =>
signIn.social({
provider: "github",
callbackURL: "http://localhost:5173",
})
}
>
Continue with GitHub
</button>
<button
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
onClick={() =>
signIn.social({
provider: "google",
callbackURL: "http://localhost:5173",
})
}
>
Continue with Google
</button>
</div>
);
};
export const Route = createFileRoute("/login")({ component: LoginPage });

View file

@ -1,9 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useState, useCallback } from "react";
import type { GameSession, GameRequest, AnswerResult } from "@glossa/shared";
import { QuestionCard } from "../components/game/QuestionCard";
import { ScoreScreen } from "../components/game/ScoreScreen";
import { GameSetup } from "../components/game/GameSetup";
import { authClient } from "../lib/auth-client";
function Play() {
const [gameSession, setGameSession] = useState<GameSession | null>(null);
@ -105,4 +106,12 @@ function Play() {
);
}
export const Route = createFileRoute("/play")({ component: Play });
export const Route = createFileRoute("/play")({
component: Play,
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
}
},
});

View file

@ -2,6 +2,9 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"composite": false,
"declaration": false,
"declarationMap": false,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
@ -9,7 +12,7 @@
"noEmit": true,
"target": "ES2023",
"types": ["vite/client", "vitest/globals"],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
},
"include": ["src", "vitest.config.ts"]
"include": ["src", "vitest.config.ts"],
}

View file

@ -7,6 +7,10 @@
## problems+thoughts
### try now option
there should be an option to try the app without an account so users can see what they would get when creating an account
### resolve deps problem
﬌ pnpm --filter web add better-auth

28
pnpm-lock.yaml generated
View file

@ -56,10 +56,16 @@ importers:
better-auth:
specifier: ^1.6.2
version: 1.6.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))
cors:
specifier: ^2.8.6
version: 2.8.6
express:
specifier: ^5.2.1
version: 5.2.1
devDependencies:
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@types/express':
specifier: ^5.0.6
version: 5.0.6
@ -1243,6 +1249,9 @@ packages:
'@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@ -1662,6 +1671,10 @@ packages:
cookiejar@2.1.4:
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
@ -2397,6 +2410,10 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@ -3821,6 +3838,10 @@ snapshots:
'@types/cookiejar@2.1.5': {}
'@types/cors@2.8.19':
dependencies:
'@types/node': 24.12.0
'@types/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {}
@ -4306,6 +4327,11 @@ snapshots:
cookiejar@2.1.4: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
crc-32@1.2.2: {}
cross-spawn@7.0.6:
@ -4985,6 +5011,8 @@ snapshots:
normalize-path@3.0.0: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
obug@2.1.1: {}