From f265a7271fa3a0ee9a442bb6ffe40db44ec40fb0 Mon Sep 17 00:00:00 2001 From: MocicaRazvan <125140159+MocicaRazvan@users.noreply.github.com> Date: Tue, 16 Jan 2024 10:08:22 +0200 Subject: [PATCH] Add support for Microsoft 365 authentication using NextAuth.js (#14) Co-authored-by: Gabriel Majeri --- .env.development | 5 + README.md | 20 +++ package-lock.json | 128 +++++++++++++++++- package.json | 1 + src/app/[locale]/auth/layout.tsx | 21 +++ src/app/[locale]/auth/signin/SignInButton.tsx | 31 +++++ src/app/[locale]/auth/signin/page.tsx | 9 ++ src/app/[locale]/layout.tsx | 12 +- src/app/api/auth/[...nextauth]/route.ts | 94 +++++++++++++ src/messages/en.json | 7 + src/messages/ro.json | 7 + src/middleware.ts | 34 ++++- src/types/next-auth.d.ts | 23 ++++ 13 files changed, 378 insertions(+), 14 deletions(-) create mode 100644 .env.development create mode 100644 src/app/[locale]/auth/layout.tsx create mode 100644 src/app/[locale]/auth/signin/SignInButton.tsx create mode 100644 src/app/[locale]/auth/signin/page.tsx create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/types/next-auth.d.ts diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..67e0c64 --- /dev/null +++ b/.env.development @@ -0,0 +1,5 @@ +# Tell NextAuth what is our app's canonical URL +NEXTAUTH_URL=http://localhost:3000 + +# Configure a secret key for encrypting session tokens +NEXTAUTH_SECRET=dev diff --git a/README.md b/README.md index 9369d4e..55d7ad4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ docker compose up in this directory. +The app uses [NextAuth.js](https://next-auth.js.org/) to provide support for authentication using [Microsoft Entra ID (formerly Azure AD)](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id). In development, you have to create a `.env.local` file in the root directory and define the following environment variables: + +``` +AZURE_AD_TENANT_ID= +AZURE_AD_CLIENT_ID= +AZURE_AD_CLIENT_SECRET +``` + To start a local development server, use: ```bash @@ -42,3 +50,15 @@ To check for code issues using [ESLint](https://eslint.org/), run: ```bash npm run lint ``` + +## Deployment instructions + +The following environment variables must be defined for the app to function properly: + +``` +NEXTAUTH_URL= +NEXTAUTH_SECRET= +AZURE_AD_TENANT_ID= +AZURE_AD_CLIENT_ID= +AZURE_AD_CLIENT_SECRET +``` diff --git a/package-lock.json b/package-lock.json index 8e0895e..32623d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "next": "14.0.4", + "next-auth": "^4.24.5", "next-intl": "^3.4.2", "react": "^18", "react-dom": "^18" @@ -51,7 +52,6 @@ "version": "7.23.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.7.tgz", "integrity": "sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -510,6 +510,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", @@ -1251,6 +1259,14 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2887,6 +2903,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", + "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", + "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", @@ -3040,7 +3064,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -3194,6 +3217,33 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.5.tgz", + "integrity": "sha512-3RafV3XbfIKk6rF6GlLE4/KxjTcuMCifqrmD+98ejFq73SRoj2rmzoca8u764977lH/Q7jo6Xu6yM+Re1Mz/Og==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.5.0", + "jose": "^4.11.4", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "next": "^12.2.5 || ^13 || ^14", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18", + "react-dom": "^17.0.2 || ^18" + }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + } + } + }, "node_modules/next-intl": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.4.2.tgz", @@ -3265,6 +3315,11 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3392,6 +3447,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3401,6 +3464,28 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.4.tgz", + "integrity": "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==", + "dependencies": { + "jose": "^4.15.4", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -3708,6 +3793,26 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/preact": { + "version": "10.19.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz", + "integrity": "sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "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", @@ -3732,6 +3837,11 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "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/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3845,8 +3955,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", @@ -4694,6 +4803,14 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -4900,8 +5017,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { "version": "2.3.4", diff --git a/package.json b/package.json index 29f9a52..5663bd5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "next": "14.0.4", + "next-auth": "^4.24.5", "next-intl": "^3.4.2", "react": "^18", "react-dom": "^18" diff --git a/src/app/[locale]/auth/layout.tsx b/src/app/[locale]/auth/layout.tsx new file mode 100644 index 0000000..c53ef59 --- /dev/null +++ b/src/app/[locale]/auth/layout.tsx @@ -0,0 +1,21 @@ +import { NextIntlClientProvider, useMessages } from "next-intl"; + +type LayoutProps = { + children: React.ReactNode; + params: { + locale: string; + }; +}; + +export default function AuthLayout({ + children, + params: { locale }, +}: LayoutProps) { + const messages = useMessages(); + + return ( + + {children} + + ); +} diff --git a/src/app/[locale]/auth/signin/SignInButton.tsx b/src/app/[locale]/auth/signin/SignInButton.tsx new file mode 100644 index 0000000..fcb4e19 --- /dev/null +++ b/src/app/[locale]/auth/signin/SignInButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { signIn } from "next-auth/react"; + +function SignInButton() { + const t = useTranslations("Auth.SignIn"); + + const params = useSearchParams(); + const error = params.get("error"); + return ( +
+ + {error && + (error === "Callback" ? ( +

{t("callbackError")}

+ ) : ( +

{t("otherError")}

+ ))} +
+ ); +} + +export default SignInButton; diff --git a/src/app/[locale]/auth/signin/page.tsx b/src/app/[locale]/auth/signin/page.tsx new file mode 100644 index 0000000..f56604f --- /dev/null +++ b/src/app/[locale]/auth/signin/page.tsx @@ -0,0 +1,9 @@ +import SignInButton from "./SignInButton"; + +export default function SignInPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index c36aad2..1c0d4ef 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -9,15 +9,17 @@ export const metadata: Metadata = { description: "Generated by create next app", }; -export default function RootLayout({ - children, - params: { locale }, -}: { +type LayoutProps = { children: React.ReactNode; params: { locale: string; }; -}) { +}; + +export default function RootLayout({ + children, + params: { locale }, +}: LayoutProps) { return ( {children} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..3c0d9f9 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,94 @@ +import { NextAuthOptions } from "next-auth"; +import NextAuth from "next-auth/next"; +import AzureAD from "next-auth/providers/azure-ad"; +import type { Provider } from "next-auth/providers/index"; +// import prisma from "@/db/prisma"; + +const providers: Provider[] = []; + +const tenantId = process.env.AZURE_AD_TENANT_ID; +if (typeof tenantId !== "string" || !tenantId) { + throw Error( + "Required environment variable `AZURE_AD_TENANT_ID` is not defined", + ); +} + +const clientId = process.env.AZURE_AD_CLIENT_ID; +if (typeof clientId !== "string" || !clientId) { + throw Error( + "Required environment variable `AZURE_AD_CLIENT_ID` is not defined", + ); +} + +const clientSecret = process.env.AZURE_AD_CLIENT_SECRET; +if (typeof clientSecret !== "string" || !clientSecret) { + throw Error( + "Required environment variable `AZURE_AD_CLIENT_SECRET` is not defined", + ); +} + +providers.push( + AzureAD({ + tenantId, + clientId, + clientSecret, + authorization: { + params: {}, + }, + profile(profile) { + return { + id: profile.sub, + azureAdObjectId: profile.oid, + name: profile.name, + email: profile.email, + image: profile.picture ?? null, + }; + }, + }), +); + +export const authOptions: NextAuthOptions = { + providers, + + callbacks: { + async signIn({ user }) { + // waiting for prisma to be added just a prof of concept + // const dbUser = await prisma.user.findUnique({ + // where: { azureAdObjectId: user.azureAdObjectId }, + // }); + // if (dbUser) { + // return true; + // } + // return false; + + return true; + }, + jwt({ token, user, account }) { + if (!account) { + throw new Error("Cannot create JWT, received account object is null"); + } + + if (!account.access_token) { + throw new Error("Access token was not present in account object"); + } + + token.accessToken = account.access_token; + token.azureAdObjectId = user.azureAdObjectId; + + return token; + }, + session({ session, token }) { + session.accessToken = token.accessToken; + session.user.azureAdObjectId = token.azureAdObjectId; + + return session; + }, + }, + pages: { + signIn: "/auth/signin", + }, +}; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/messages/en.json b/src/messages/en.json index ced1954..c3f2774 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2,5 +2,12 @@ "Index": { "getStarted": "Get started by editing", "by": "By" + }, + "Auth": { + "SignIn": { + "buttonLabel": "Sign in", + "callbackError": "Only authorized users are allowed to log into the admin interface of the app", + "otherError": "Ooops, something went wrong :(" + } } } diff --git a/src/messages/ro.json b/src/messages/ro.json index 97bbfe0..3019c0a 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -2,5 +2,12 @@ "Index": { "getStarted": "Începe prin a edita fișierul", "by": "De" + }, + "Auth": { + "SignIn": { + "buttonLabel": "Conectare", + "callbackError": "Doar utilizatorii autorizați au permisiunea de a se conecta la interfața de administrare a aplicației", + "otherError": "Hopa, ceva nu a funcționat corect :(" + } } } diff --git a/src/middleware.ts b/src/middleware.ts index 6ce780a..6063418 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,9 @@ import createMiddleware from "next-intl/middleware"; import { locales } from "./i18n"; +import { withAuth } from "next-auth/middleware"; +import { NextRequest } from "next/server"; -export default createMiddleware({ +const intlMiddleware = createMiddleware({ // A list of all locales that are supported locales, @@ -9,7 +11,33 @@ export default createMiddleware({ defaultLocale: "ro", }); +const authMiddleware = withAuth( + function onSuccess(req) { + return intlMiddleware(req); + }, + { + callbacks: { + authorized: ({ token }) => token != null, + }, + }, +); + +export default function middleware(req: NextRequest) { + // /[locale]/admin/* private url pattern + const excludePattern = "^(/(" + locales.join("|") + "))?/admin/?.*?$"; + const publicPathnameRegex = RegExp(excludePattern, "i"); + const isPublicPage = !publicPathnameRegex.test(req.nextUrl.pathname); + + if (isPublicPage) { + // Apply Next-Intl middleware for public pages + return intlMiddleware(req); + } else { + // Apply Next-Auth middleware for private pages + return (authMiddleware as any)(req); + } +} + export const config = { - // Match only internationalized pathnames - matcher: ["/", "/(en|ro)/:path*"], + // Match only internationalized pathnames and exclude the api and _next + matcher: ["/", "/(en|ro)/:path*", "/((?!api|_next|.*\\..*).*)"], }; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..74bc161 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,23 @@ +import { DefaultSession, Profile, JWT } from "next-auth"; + +declare module "next-auth" { + interface Session { + accessToken?: string; + user: { + azureAdObjectId: string; + } & DefaultSession["user"]; + } + + interface User { + id: number; + azureAdObjectId: string; + } +} + +declare module "next-auth/jwt" { + /** Returned by the `jwt` callback and `getToken`, when using JWT sessions */ + interface JWT { + accessToken: string; + azureAdObjectId: string; + } +}