From 42c39907cd6ace5c0d7874fae19b1041e7575cd5 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 6 Apr 2026 11:37:20 -0400 Subject: [PATCH] feat: integrate Logto auth with middleware and login page (#36) - Logto config matching la-compagnie-maximus pattern - API routes: sign-in, callback, sign-out - Next.js middleware protecting all routes except /auth and /api - Auth helper to extract userId (sub) from Logto context - Login page with Compte Maximus branding Co-Authored-By: Claude Opus 4.6 (1M context) --- web/package-lock.json | 161 ++++++++++++++++++++++++ web/package.json | 1 + web/src/app/api/logto/callback/route.ts | 17 +++ web/src/app/api/logto/sign-in/route.ts | 9 ++ web/src/app/api/logto/sign-out/route.ts | 8 ++ web/src/app/auth/page.tsx | 20 +++ web/src/lib/auth.ts | 16 +++ web/src/lib/logto.ts | 11 ++ web/src/middleware.ts | 33 +++++ 9 files changed, 276 insertions(+) create mode 100644 web/src/app/api/logto/callback/route.ts create mode 100644 web/src/app/api/logto/sign-in/route.ts create mode 100644 web/src/app/api/logto/sign-out/route.ts create mode 100644 web/src/app/auth/page.tsx create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/lib/logto.ts create mode 100644 web/src/middleware.ts diff --git a/web/package-lock.json b/web/package-lock.json index 9c212d4..9ae3d7b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "web", "version": "0.1.0", "dependencies": { + "@logto/next": "^4.2.9", "@types/pg": "^8.20.0", "dotenv": "^17.4.1", "drizzle-orm": "^0.45.2", @@ -288,6 +289,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@edge-runtime/cookies": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@edge-runtime/cookies/-/cookies-5.0.2.tgz", + "integrity": "sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -1911,6 +1921,53 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@logto/client": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@logto/client/-/client-3.1.7.tgz", + "integrity": "sha512-t/5wXMhiXtmbmP6Cmcl4uMsYetq21vSZuYZztPHXv6QX0dx7lSKBvYi/65ERoS+fmNmtV2/i4Ojf1U41o0TLPQ==", + "license": "MIT", + "dependencies": { + "@logto/js": "^6.1.1", + "@silverhand/essentials": "^2.9.3", + "camelcase-keys": "^9.1.3", + "jose": "^5.2.2" + } + }, + "node_modules/@logto/js": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@logto/js/-/js-6.1.1.tgz", + "integrity": "sha512-G0lRS7VyOXdB06WYajEh9Kq2E3m11JshiKIKLj6LRPI1qZ06JYQ+Jsej3K60/4OIZMSzUas4FVnY+ORrhDdktA==", + "license": "MIT", + "dependencies": { + "@silverhand/essentials": "^2.9.3", + "camelcase-keys": "^9.1.3" + } + }, + "node_modules/@logto/next": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@logto/next/-/next-4.2.9.tgz", + "integrity": "sha512-5HPt6UaQ23u+SNvmMN1Qhvxcuv9Yb7Ldz/I078Y8pLVCUWjhbgmZp/qCGg49fHIdilV3u1rjPFf5ndvj06Y8wg==", + "license": "MIT", + "dependencies": { + "@edge-runtime/cookies": "^5.0.0", + "@logto/node": "^3.1.9", + "cookie": "^1.0.0" + }, + "peerDependencies": { + "next": ">=12" + } + }, + "node_modules/@logto/node": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@logto/node/-/node-3.1.9.tgz", + "integrity": "sha512-ApbUf3tWZYtMt6KJZo+bfms+5WcR7Cuz3dE9mVoPuo/joA08aU18fSe3L7VXqFu0nUJnG8BZi7ngoqCJEkQTig==", + "license": "MIT", + "dependencies": { + "@logto/client": "^3.1.7", + "@silverhand/essentials": "^2.9.3", + "js-base64": "^3.7.4" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2123,6 +2180,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@silverhand/essentials": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@silverhand/essentials/-/essentials-2.9.3.tgz", + "integrity": "sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==", + "license": "MIT", + "engines": { + "node": ">=18.12.0", + "pnpm": "^10.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3469,6 +3536,36 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz", + "integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==", + "license": "MIT", + "dependencies": { + "camelcase": "^8.0.0", + "map-obj": "5.0.0", + "quick-lru": "^6.1.1", + "type-fest": "^4.3.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001786", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", @@ -3546,6 +3643,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5528,6 +5638,21 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5972,6 +6097,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/map-obj": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz", + "integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6654,6 +6791,18 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -7971,6 +8120,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", diff --git a/web/package.json b/web/package.json index 73ca10d..c380719 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@logto/next": "^4.2.9", "@types/pg": "^8.20.0", "dotenv": "^17.4.1", "drizzle-orm": "^0.45.2", diff --git a/web/src/app/api/logto/callback/route.ts b/web/src/app/api/logto/callback/route.ts new file mode 100644 index 0000000..d2e6be7 --- /dev/null +++ b/web/src/app/api/logto/callback/route.ts @@ -0,0 +1,17 @@ +import { handleSignIn } from '@logto/next/server-actions'; +import { logtoConfig } from '@/lib/logto'; +import { NextRequest } from 'next/server'; +import { redirect } from 'next/navigation'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + + try { + await handleSignIn(logtoConfig, searchParams); + redirect('/'); + } catch { + redirect('/?error=auth'); + } +} diff --git a/web/src/app/api/logto/sign-in/route.ts b/web/src/app/api/logto/sign-in/route.ts new file mode 100644 index 0000000..dfebc30 --- /dev/null +++ b/web/src/app/api/logto/sign-in/route.ts @@ -0,0 +1,9 @@ +import { signIn } from '@logto/next/server-actions'; +import { logtoConfig } from '@/lib/logto'; +import { NextRequest } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + await signIn(logtoConfig, `${logtoConfig.baseUrl}/api/logto/callback`); +} diff --git a/web/src/app/api/logto/sign-out/route.ts b/web/src/app/api/logto/sign-out/route.ts new file mode 100644 index 0000000..dc24a01 --- /dev/null +++ b/web/src/app/api/logto/sign-out/route.ts @@ -0,0 +1,8 @@ +import { signOut } from '@logto/next/server-actions'; +import { logtoConfig } from '@/lib/logto'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + await signOut(logtoConfig, `${logtoConfig.baseUrl}`); +} diff --git a/web/src/app/auth/page.tsx b/web/src/app/auth/page.tsx new file mode 100644 index 0000000..9774a5b --- /dev/null +++ b/web/src/app/auth/page.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link'; + +export default function AuthPage() { + return ( +
+
+

Simpl-Liste

+

+ Connectez-vous avec votre Compte Maximus +

+ + Se connecter + +
+
+ ); +} diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 0000000..d80e615 --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,16 @@ +import { getLogtoContext } from '@logto/next/server-actions'; +import { logtoConfig } from './logto'; + +export async function getAuthenticatedUser() { + const context = await getLogtoContext(logtoConfig); + + if (!context.isAuthenticated || !context.claims?.sub) { + return null; + } + + return { + userId: context.claims.sub, + email: context.claims.email, + name: context.claims.name, + }; +} diff --git a/web/src/lib/logto.ts b/web/src/lib/logto.ts new file mode 100644 index 0000000..df8bb75 --- /dev/null +++ b/web/src/lib/logto.ts @@ -0,0 +1,11 @@ +import type { LogtoNextConfig } from '@logto/next'; + +export const logtoConfig: LogtoNextConfig = { + endpoint: process.env.LOGTO_ENDPOINT!, + appId: process.env.LOGTO_APP_ID!, + appSecret: process.env.LOGTO_APP_SECRET!, + baseUrl: process.env.LOGTO_BASE_URL || 'http://localhost:3000', + cookieSecret: process.env.LOGTO_COOKIE_SECRET!, + cookieSecure: process.env.NODE_ENV === 'production', + scopes: ['openid', 'profile', 'email'], +}; diff --git a/web/src/middleware.ts b/web/src/middleware.ts new file mode 100644 index 0000000..6e933bb --- /dev/null +++ b/web/src/middleware.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Let Logto API routes and health endpoint pass through + if (pathname.startsWith('/api/logto') || pathname.startsWith('/api/health')) { + return NextResponse.next(); + } + + // Protected routes: check for Logto session cookie + // The Logto SDK stores session data in a cookie named `logto:` + const hasSession = request.cookies.getAll().some( + (cookie) => cookie.name.startsWith('logto:') + ); + + if (!hasSession && !pathname.startsWith('/auth')) { + return NextResponse.redirect(new URL('/auth', request.url)); + } + + if (hasSession && pathname === '/auth') { + return NextResponse.redirect(new URL('/', request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|api/logto|api/health).*)', + ], +};