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/db": "workspace:*",
"@glossa/shared": "workspace:*", "@glossa/shared": "workspace:*",
"better-auth": "^1.6.2", "better-auth": "^1.6.2",
"cors": "^2.8.6",
"express": "^5.2.1" "express": "^5.2.1"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"supertest": "^7.2.2", "supertest": "^7.2.2",

View file

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

View file

@ -1,9 +1,11 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@glossa/db"; import { db } from "@glossa/db";
import * as schema from "@glossa/db/schema";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }), database: drizzleAdapter(db, { provider: "pg", schema }),
trustedOrigins: ["http://localhost:5173"],
socialProviders: { socialProviders: {
google: { google: {
clientId: process.env["GOOGLE_CLIENT_ID"] as string, 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 express from "express";
import type { Router } from "express"; import type { Router } from "express";
import { createGame, submitAnswer } from "../controllers/gameController.js"; import { createGame, submitAnswer } from "../controllers/gameController.js";
import { requireAuth } from "../middleware/authMiddleware.js";
export const gameRouter: Router = express.Router(); export const gameRouter: Router = express.Router();
gameRouter.use(requireAuth);
gameRouter.post("/start", createGame); gameRouter.post("/start", createGame);
gameRouter.post("/answer", submitAnswer); 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 rootRouteImport } from './routes/__root'
import { Route as PlayRouteImport } from './routes/play' import { Route as PlayRouteImport } from './routes/play'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AboutRouteImport } from './routes/about' import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
@ -18,6 +19,11 @@ const PlayRoute = PlayRouteImport.update({
path: '/play', path: '/play',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AboutRoute = AboutRouteImport.update({ const AboutRoute = AboutRouteImport.update({
id: '/about', id: '/about',
path: '/about', path: '/about',
@ -32,30 +38,34 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/about': typeof AboutRoute '/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute '/play': typeof PlayRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/about': typeof AboutRoute '/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute '/play': typeof PlayRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/about': typeof AboutRoute '/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute '/play': typeof PlayRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' | '/play' fullPaths: '/' | '/about' | '/login' | '/play'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/play' to: '/' | '/about' | '/login' | '/play'
id: '__root__' | '/' | '/about' | '/play' id: '__root__' | '/' | '/about' | '/login' | '/play'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
PlayRoute: typeof PlayRoute PlayRoute: typeof PlayRoute
} }
@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlayRouteImport preLoaderRoute: typeof PlayRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/about': { '/about': {
id: '/about' id: '/about'
path: '/about' path: '/about'
@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AboutRoute: AboutRoute, AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
PlayRoute: PlayRoute, PlayRoute: PlayRoute,
} }
export const routeTree = rootRouteImport 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 { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { useSession, signOut } from "../lib/auth-client";
const RootLayout = () => ( const RootLayout = () => {
<> const { data: session } = useSession();
<div className="p-2 flex gap-2"> const navigate = useNavigate();
<Link to="/" className="[&.active]:font-bold">
Home return (
</Link>{" "} <>
<Link to="/about" className="[&.active]:font-bold"> <div className="p-2 flex gap-2 items-center">
About <Link to="/" className="[&.active]:font-bold">
</Link> Home
</div> </Link>
<hr /> <Link to="/about" className="[&.active]:font-bold">
<Outlet /> About
<TanStackRouterDevtools /> </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 }); 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 { useState, useCallback } from "react";
import type { GameSession, GameRequest, AnswerResult } from "@glossa/shared"; import type { GameSession, GameRequest, AnswerResult } from "@glossa/shared";
import { QuestionCard } from "../components/game/QuestionCard"; import { QuestionCard } from "../components/game/QuestionCard";
import { ScoreScreen } from "../components/game/ScoreScreen"; import { ScoreScreen } from "../components/game/ScoreScreen";
import { GameSetup } from "../components/game/GameSetup"; import { GameSetup } from "../components/game/GameSetup";
import { authClient } from "../lib/auth-client";
function Play() { function Play() {
const [gameSession, setGameSession] = useState<GameSession | null>(null); 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", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"composite": false,
"declaration": false,
"declarationMap": false,
"lib": ["ES2023", "DOM", "DOM.Iterable"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx", "jsx": "react-jsx",
"module": "ESNext", "module": "ESNext",
@ -9,7 +12,7 @@
"noEmit": true, "noEmit": true,
"target": "ES2023", "target": "ES2023",
"types": ["vite/client", "vitest/globals"], "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 ## 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 ### resolve deps problem
﬌ pnpm --filter web add better-auth ﬌ pnpm --filter web add better-auth

28
pnpm-lock.yaml generated
View file

@ -56,10 +56,16 @@ importers:
better-auth: better-auth:
specifier: ^1.6.2 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))) 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: express:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1 version: 5.2.1
devDependencies: devDependencies:
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@types/express': '@types/express':
specifier: ^5.0.6 specifier: ^5.0.6
version: 5.0.6 version: 5.0.6
@ -1243,6 +1249,9 @@ packages:
'@types/cookiejar@2.1.5': '@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} 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': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@ -1662,6 +1671,10 @@ packages:
cookiejar@2.1.4: cookiejar@2.1.4:
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} 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: crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@ -2397,6 +2410,10 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'} 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: object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3821,6 +3838,10 @@ snapshots:
'@types/cookiejar@2.1.5': {} '@types/cookiejar@2.1.5': {}
'@types/cors@2.8.19':
dependencies:
'@types/node': 24.12.0
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {} '@types/esrecurse@4.3.1': {}
@ -4306,6 +4327,11 @@ snapshots:
cookiejar@2.1.4: {} cookiejar@2.1.4: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
crc-32@1.2.2: {} crc-32@1.2.2: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
@ -4985,6 +5011,8 @@ snapshots:
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
obug@2.1.1: {} obug@2.1.1: {}