diff --git a/.env.local.example b/.env.local.example index 3f6c4d543..2f6a2910f 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,7 +2,13 @@ CMS_ACCESS_TOKEN="" CMS_API_KEY="" CMS_ENVIRONMENT="development" CMS_URL="https://eu-graphql.contentstack.com/stacks/${CMS_API_KEY}?environment=${CMS_ENVIRONMENT}" -CMS_PREVIEW_URL="https://graphql-preview.contentstack.com/stacks/${CMS_API_KEY}?environment=${CMS_ENVIRONMENT}"; +CMS_PREVIEW_URL="https://graphql-preview.contentstack.com/stacks/${CMS_API_KEY}?environment=${CMS_ENVIRONMENT}" CMS_PREVIEW_TOKEN="" ADOBE_SCRIPT_SRC="" +REVALIDATE_SECRET="" DESIGN_SYSTEM_ACCESS_TOKEN="" +CURITY_CLIENT_ID_USER="" +CURITY_CLIENT_SECRET_USER="" +CURITY_ISSUER_USER="https://testlogin.scandichotels.com" +NEXTAUTH_URL="http://localhost:3000/api/auth" +NEXTAUTH_SECRET="secret" diff --git a/.gitignore b/.gitignore index fd3dbb571..f69661531 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +certificates \ No newline at end of file diff --git a/app/[lang]/(live)/(protected)/my-pages/page.tsx b/app/[lang]/(live)/(protected)/my-pages/page.tsx new file mode 100644 index 000000000..f567c8dbf --- /dev/null +++ b/app/[lang]/(live)/(protected)/my-pages/page.tsx @@ -0,0 +1,12 @@ +import { auth } from "@/auth" + +export default async function MyPages() { + const session = await auth() + console.log({ session }) + return ( + <> +

Wilkommen

+
{JSON.stringify(session, null, 2)}
+ + ) +} diff --git a/app/[lang]/(live)/content-page/page.tsx b/app/[lang]/(live)/(public)/content-page/page.tsx similarity index 100% rename from app/[lang]/(live)/content-page/page.tsx rename to app/[lang]/(live)/(public)/content-page/page.tsx diff --git a/app/[lang]/(live)/(public)/login/page.tsx b/app/[lang]/(live)/(public)/login/page.tsx new file mode 100644 index 000000000..4c9e43010 --- /dev/null +++ b/app/[lang]/(live)/(public)/login/page.tsx @@ -0,0 +1,21 @@ +import { signIn } from "@/auth" +import { pageNames } from "@/constants/myPages" + +import type { LangParams, Params } from "@/types/params" + +export default async function Page({ params }: Params) { + async function login() { + "use server" + await signIn("curity", { + redirectTo: `/${params.lang}/${pageNames[params.lang]}`, + }) + } + + return ( +
+
+ +
+
+ ) +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 000000000..bb4896173 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +export { GET, POST } from "@/auth" +export const runtime = "edge" diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index e145e87a2..401ee4586 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -1,7 +1,7 @@ import { env } from "@/env/server" import { revalidateTag } from "next/cache" -import type { NextRequest } from "next/server" +import { NextRequest } from "next/server" export async function POST(request: NextRequest) { try { diff --git a/auth.ts b/auth.ts new file mode 100644 index 000000000..a1d4f9ce2 --- /dev/null +++ b/auth.ts @@ -0,0 +1,116 @@ +import NextAuth from "next-auth" + +import { env } from "@/env/server" + +import type { NextAuthConfig } from "next-auth" + +export const config = { + providers: [ + { + id: "curity", + type: "oidc", + name: "Curity", + // FIXME: This is incorrect. We should not hard code this. + // It should be ${env.CURITY_ISSUER_USER}. + // This change requires sync between Curity deploy and CurrentWeb and NewWeb. + issuer: "https://scandichotels.com", + token: { + url: `${env.CURITY_ISSUER_USER}/oauth/v2/token`, + }, + userinfo: { + url: `${env.CURITY_ISSUER_USER}/oauth/v2/userinfo`, + }, + authorization: { + url: `${env.CURITY_ISSUER_USER}/oauth/v2/authorize`, + params: { + scope: ["openid"], + }, + }, + clientId: env.CURITY_CLIENT_ID_USER, + clientSecret: env.CURITY_CLIENT_SECRET_USER, + + profile(profile: { id: string; sub: string; given_name: string }) { + console.log({ profile }) + return { + id: profile.id, + sub: profile.sub, + given_name: profile.given_name, + } + }, + }, + ], + trustHost: true, + // pages: { + // signIn: "/auth/login", + // }, + // basePath: "/api/auth", + session: { + strategy: "jwt", + }, + callbacks: { + async signIn(...args) { + console.log("****** SIGN IN *******") + console.log(args) + return true + }, + async session(...args) { + console.log(args) + return args[0].session + }, + async redirect({ baseUrl, url }) { + console.log("****** REDIRECT *******") + console.log({ url }) + console.log({ baseUrl }) + // Allows relative callback URLs + if (url.startsWith("/")) { + return `${baseUrl}${url}` + } else if (new URL(url).origin === baseUrl) { + // Allows callback URLs on the same origin + return url + } + return baseUrl + }, + authorized({ auth, request }) { + console.log("****** AUTHORIZED *******") + console.log({ request, auth }) + // const { pathname } = request.nextUrl + // if (pathname === "/middleware-example") return !!auth + return true + }, + jwt({ session, token, trigger }) { + console.log("****** JWT *******") + // if (trigger === "update") token.name = session.user.name + console.log({ token, trigger, session }) + return token + }, + }, + events: { + async signIn(...args) { + console.log({ args }) + }, + async session(...args) { + console.log({ args }) + }, + }, + logger: { + error(code, ...message) { + console.info("ERROR LOGGER") + console.error(code, message) + }, + warn(code, ...message) { + console.info("WARN LOGGER") + console.warn(code, message) + }, + debug(code, ...message) { + console.info("DEBUG LOGGER") + console.debug(code, message) + }, + }, +} satisfies NextAuthConfig + +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth(config) diff --git a/constants/locales.ts b/constants/locales.ts index 3ba2d1a2a..eb44ed649 100644 --- a/constants/locales.ts +++ b/constants/locales.ts @@ -1,4 +1,4 @@ -import { langEnum } from "@/types/lang" +import { langEnum, type Lang } from "@/types/lang" export const localeToLang = { en: langEnum.en, @@ -39,3 +39,13 @@ export const localeToLang = { "nn-NO": langEnum.no, "se-NO": langEnum.no, } + +export const DEFAULT_LOCALE = "en" +export const locales: Lang[] = ["da", "de", DEFAULT_LOCALE, "fi", "no", "sv"] + +export function findLocale(pathname: string) { + let locale = locales.find( + (loc) => pathname.startsWith(`/${loc}/`) || pathname === `/${loc}` + ) + return locale +} diff --git a/constants/myPages.js b/constants/myPages.js new file mode 100644 index 000000000..4c6684f9e --- /dev/null +++ b/constants/myPages.js @@ -0,0 +1,8 @@ +export const pageNames = { + da: "mine-sider", + de: "mein-profil", + en: "my-pages", + fi: "minun-sivujani", + no: "mine-sider", + sv: "mina-sidor", +} diff --git a/constants/myPages.ts b/constants/myPages.ts new file mode 100644 index 000000000..7179ba17d --- /dev/null +++ b/constants/myPages.ts @@ -0,0 +1,10 @@ +import type { Lang } from "@/types/lang" + +export const pageNames: Record = { + da: "mine-sider", + de: "mein-profil", + en: "my-pages", + fi: "minun-sivujani", + no: "mine-sider", + sv: "mina-sidor", +} diff --git a/env/server.ts b/env/server.ts index 841233153..25d0ddfc9 100644 --- a/env/server.ts +++ b/env/server.ts @@ -16,6 +16,9 @@ export const env = createEnv({ PRINT_QUERY: z.boolean().default(false), REVALIDATE_SECRET: z.string(), DESIGN_SYSTEM_ACCESS_TOKEN: z.string(), + CURITY_CLIENT_ID_USER: z.string(), + CURITY_CLIENT_SECRET_USER: z.string(), + CURITY_ISSUER_USER: z.string(), }, emptyStringAsUndefined: true, runtimeEnv: { @@ -32,5 +35,8 @@ export const env = createEnv({ PRINT_QUERY: process.env.PRINT_QUERY, REVALIDATE_SECRET: process.env.REVALIDATE_SECRET, DESIGN_SYSTEM_ACCESS_TOKEN: process.env.DESIGN_SYSTEM_ACCESS_TOKEN, + CURITY_CLIENT_ID_USER: process.env.CURITY_CLIENT_ID_USER, + CURITY_CLIENT_SECRET_USER: process.env.CURITY_CLIENT_SECRET_USER, + CURITY_ISSUER_USER: process.env.CURITY_ISSUER_USER, }, }) diff --git a/middleware.ts b/middleware.ts index c6aa03dce..217269d2e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,42 +1,63 @@ import { NextResponse } from "next/server" -import type { NextRequest } from "next/server" -// This function can be marked `async` if using `await` inside -export async function middleware(request: NextRequest) { - // const locales = await fetch(CMS_API, { - // locales: true - // }) - const locales = ["en", "sv", "no", "fi", "da", "de"] +import { auth } from "@/auth" - const locale = locales.find( - (locale) => - request.nextUrl.pathname.startsWith(`/${locale}/`) || - request.nextUrl.pathname === `/${locale}` - ) +import { findLocale } from "@/constants/locales" +import { pageNames } from "@/constants/myPages" +import { apiAuthPrefix } from "@/routes/api" +// import { publicRoutes } from "@/routes/public" +import { protectedRoutes } from "@/routes/protected" + +export default auth(async function middleware(request) { + const { nextUrl } = request + const isLoggedIn = !!request.auth + + const isApiRoute = nextUrl.pathname.startsWith(apiAuthPrefix) + if (isApiRoute) { + return NextResponse.next() + } + + const locale = findLocale(nextUrl.pathname) if (!locale) { //return return Response.json("Not found!!!", { status: 404 }) } - // const data = await fetch(CMS_API, { - // uri: request.nextUrl.pathname, - // locale - // }).json() + const isProtectedRoute = protectedRoutes.includes(nextUrl.pathname) + if (isProtectedRoute) { + if (isLoggedIn) { + /** + * Temporary hard rewrite to my pages + */ + return NextResponse.rewrite( + new URL(`/${locale}/${pageNames[locale]}`, nextUrl) + ) + } else { + /** + * Redirect to Loginpage + * (Loginpage most likely to be removed) + */ + return NextResponse.redirect(new URL(`/${locale}/login`, nextUrl)) + } + } + + if (nextUrl.pathname.startsWith(`/${locale}/login`)) { + return NextResponse.next() + } + + // const isPublicRoute = publicRoutes.includes(nextUrl.pathname) + // if (!isLoggedIn && !isPublicRoute) { + // return NextResponse.redirect(new URL(`/${locale}/login`, nextUrl)) + // } //const contentType = data.response.meta.contentType; const contentType = "currentContentPage" - - const pathNameWithoutLocale = request.nextUrl.pathname.replace( - `/${locale}`, - "" - ) - + const pathNameWithoutLocale = nextUrl.pathname.replace(`/${locale}`, "") const searchParams = new URLSearchParams(request.nextUrl.searchParams) if (request.nextUrl.pathname.includes("preview")) { searchParams.set("uri", pathNameWithoutLocale.replace("/preview", "")) - return NextResponse.rewrite( new URL( `/${locale}/preview-current?${searchParams.toString()}`, @@ -46,7 +67,6 @@ export async function middleware(request: NextRequest) { } searchParams.set("uri", pathNameWithoutLocale) - switch (contentType) { case "currentContentPage": return NextResponse.rewrite( @@ -56,19 +76,16 @@ export async function middleware(request: NextRequest) { ) ) } - return NextResponse.redirect(new URL("/home", request.url)) -} + + return NextResponse.next() +}) // See "Matching Paths" below to learn more export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - api (API routes) - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - */ - "/((?!api|_next/static|_next/image|_static|imageVault|contentassets|favicon.ico|en/test).*)", - ], + /** + * Copied from Clerk to protect all routes by default and handle + * public routes inside middleware. + * (https://clerk.com/docs/quickstarts/nextjs?utm_source=sponsorship&utm_medium=youtube&utm_campaign=code-with-antonio&utm_content=12-31-2023#add-authentication-to-your-app) + */ + matcher: ["/((?!.+\\.[\\w]+$|_next|en/test).*)", "/", "/(api)(.*)"], } diff --git a/next.config.js b/next.config.js index 8e1c45109..e40cd62d0 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,5 @@ import createJiti from "jiti" - +import { pageNames } from "./constants/myPages.js" const jiti = createJiti(new URL(import.meta.url).pathname) jiti("./env/server") @@ -9,10 +9,6 @@ jiti("./env/client") const nextConfig = { eslint: { ignoreDuringBuilds: true }, - generateBuildId: async () => { - return process.env.BUILD_ID - }, - images: { remotePatterns: [ { @@ -40,6 +36,17 @@ const nextConfig = { return config }, + rewrites() { + return { + beforeFiles: [ + { source: `/da/${pageNames.da}`, destination: "/da/my-pages" }, + { source: `/de/${pageNames.de}`, destination: "/de/my-pages" }, + { source: `/fi/${pageNames.fi}`, destination: "/fi/my-pages" }, + { source: `/no/${pageNames.no}`, destination: "/no/my-pages" }, + { source: `/sv/${pageNames.sv}`, destination: "/sv/my-pages" }, + ], + } + }, } export default nextConfig diff --git a/package-lock.json b/package-lock.json index fad4299e5..04d7c91af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "graphql-request": "^6.1.0", "graphql-tag": "^2.12.6", "next": "^14.1.0", + "next-auth": "^5.0.0-beta.15", "react": "^18", "react-dom": "^18", "server-only": "^0.0.1", @@ -52,6 +53,36 @@ "node": ">=0.10.0" } }, + "node_modules/@auth/core": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.28.0.tgz", + "integrity": "sha512-/fh/tb/L4NMSYcyPoo4Imn8vN6MskcVfgESF8/ndgtI4fhD/7u7i5fTVzWgNRZ4ebIEGHNDbWFRxaTu1NtQgvA==", + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.4.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/runtime": { "version": "7.23.8", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", @@ -625,6 +656,14 @@ "node": ">= 8" } }, + "node_modules/@panva/hkdf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.1.1.tgz", + "integrity": "sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2268,6 +2307,11 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -3692,6 +3736,14 @@ "node": ">= 0.6" } }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookies": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", @@ -6513,6 +6565,14 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.3.tgz", + "integrity": "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7749,6 +7809,32 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.15", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.15.tgz", + "integrity": "sha512-UQggNq8CDu3/w8CYkihKLLnRPNXel98K0j7mtjj9a6XTNYo4Hni8xg/2h1YhElW6vXE8mgtvmH11rU8NKw86jQ==", + "dependencies": { + "@auth/core": "0.28.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14", + "nodemailer": "^6.6.5", + "react": "^18.2.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -7828,6 +7914,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oauth4webapi": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.10.3.tgz", + "integrity": "sha512-9FkXEXfzVKzH63GUOZz1zMr3wBaICSzk6DLXx+CGdrQ10ItNk2ePWzYYc1fdmKq1ayGFb2aX97sRCoZ2s0mkDw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8306,6 +8400,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8342,6 +8456,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", diff --git a/package.json b/package.json index ca9e70694..c148072e8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "next build", - "dev": "next dev", + "dev": "rm -rf .next && next dev", "lint": "next lint && tsc", "prepare": "husky install", "start": "node .next/standalone/server.js", @@ -26,6 +26,7 @@ "graphql-request": "^6.1.0", "graphql-tag": "^2.12.6", "next": "^14.1.0", + "next-auth": "^5.0.0-beta.15", "react": "^18", "react-dom": "^18", "server-only": "^0.0.1", @@ -50,4 +51,4 @@ "engines": { "node": "18" } -} +} \ No newline at end of file diff --git a/routes/api.ts b/routes/api.ts new file mode 100644 index 000000000..041300c66 --- /dev/null +++ b/routes/api.ts @@ -0,0 +1 @@ +export const apiAuthPrefix = "/api/auth" diff --git a/routes/protected.ts b/routes/protected.ts new file mode 100644 index 000000000..cb4eca5b7 --- /dev/null +++ b/routes/protected.ts @@ -0,0 +1,10 @@ +import { pageNames } from "@/constants/myPages" + +import type { Lang } from "@/types/lang" + +/* Authenticated routes */ +export const protectedRoutes: string[] = [ + ...Object.keys(pageNames).map( + (locale) => `/${locale}/${pageNames[locale as Lang]}` + ), +] diff --git a/routes/public.ts b/routes/public.ts new file mode 100644 index 000000000..fc91de55d --- /dev/null +++ b/routes/public.ts @@ -0,0 +1,2 @@ +/* Unauthenticated routes */ +export const publicRoutes: string[] = ["/"]