Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Azure AD Authentication with Next.js and Prisma #445

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/init-cmd.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ echo "Applying migrations to the database..."
npx prisma migrate deploy
echo "Migrations applied successfully!"

while sleep 1000; do :; done
while sleep 1000; do :; done
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions prisma/migrations/20230402192938_/migration.sql
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ CREATE TABLE "Account" (
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"expires_in" INTEGER,
"ext_expires_in" INTEGER,
"token_type" TEXT,
Comment on lines +62 to 64
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these really neccessary?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these really neccessary?

I can try without but seems necessary according to documentation next auth.

"scope" TEXT,
"id_token" TEXT,
Expand Down
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ model Account {
access_token String? @db.Text
expires_at Int?
refresh_expires_in Int?
ext_expires_in Int?
expires_in Int?
token_type String?
scope String?
id_token String? @db.Text
Expand Down
52 changes: 49 additions & 3 deletions src/components/auth/loginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type NextAuthError = {

interface IProps {
hasOauth: boolean;
hasAzureAD: boolean;
oauthExlusiveLogin: boolean;
}

Expand All @@ -34,6 +35,19 @@ const OauthLoginButton = ({ oAuthHandler, loading }) => (
</button>
);

const AzureAdLoginButton = ({ AzureAdAuthHandler, loading }) => (
<button
type="button"
onClick={(e) => AzureAdAuthHandler(e)}
className={cn(
"btn btn-block btn-primary cursor-pointer rounded-full font-semibold tracking-wide shadow-lg",
)}
>
{loading.azuread ? <span className="loading loading-spinner"></span> : null}
Sign in with Azure AD
</button>
);

const CredentialsLogin = ({ loading }) => (
<button
type="submit"
Expand All @@ -53,6 +67,7 @@ const CredentialsLoginForm = ({
submitHandler,
loading,
hasOauth,
hasAzureAD,
}) => {
return (
<>
Expand Down Expand Up @@ -103,14 +118,20 @@ const CredentialsLoginForm = ({
<div className="divider divider-error">OR</div>
</div>
) : null}
{hasAzureAD ? (
<div className="flex flex-col w-full">
<div className="divider divider-error">OR</div>
</div>
) : null}
</>
);
};

const LoginForm: React.FC<IProps> = ({ hasOauth, oauthExlusiveLogin }) => {
const LoginForm: React.FC<IProps> = ({ hasOauth, hasAzureAD, oauthExlusiveLogin}) => {
const router = useRouter();
const { error: oauthError } = router.query;
const [loading, setLoading] = useState({ credentials: false, oauth: false });
const { error: azureadError } = router.query;
const [loading, setLoading] = useState({ credentials: false, oauth: false, azuread: false });
const [formData, setFormData] = useState<FormData>({
email: "",
password: "",
Expand Down Expand Up @@ -166,16 +187,35 @@ const LoginForm: React.FC<IProps> = ({ hasOauth, oauthExlusiveLogin }) => {
});
};

const AzureAdAuthHandler = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
setLoading((prev) => ({ ...prev, azuread: true }));

await signIn("azure-ad")
.then(async () => {
if (!azureadError) {
return await router.push("/network");
}
toast.error(`Error occured: ${azureadError}` as string, { duration: 10000 });
setLoading((prev) => ({ ...prev, azuread: false }));
})
.catch((_error: NextAuthError) => {
// Handle any errors that might occur during the signIn process
toast.error(`Error occured: ${azureadError}` as string);
setLoading((prev) => ({ ...prev, azuread: false }));
});
};
return (
<div className="z-10 flex justify-center self-center">
<div className="w-100 mx-auto rounded-2xl border p-12">
{!oauthExlusiveLogin || !hasOauth ? (
{(!oauthExlusiveLogin || !hasOauth) && (!oauthExlusiveLogin || !hasAzureAD) ? (
<CredentialsLoginForm
formData={formData}
handleChange={handleChange}
submitHandler={submitHandler}
loading={loading}
hasOauth={hasOauth}
hasAzureAD={hasAzureAD}
/>
) : null}

Expand All @@ -184,6 +224,12 @@ const LoginForm: React.FC<IProps> = ({ hasOauth, oauthExlusiveLogin }) => {
<OauthLoginButton oAuthHandler={oAuthHandler} loading={loading} />
</div>
) : null}

{hasAzureAD ? (
<div className="">
<AzureAdLoginButton AzureAdAuthHandler={AzureAdAuthHandler} loading={loading} />
</div>
) : null}
<div className="pt-5 text-center text-xs text-gray-400">
<span>Copyright © {new Date().getFullYear()} Kodea Solutions</span>
</div>
Expand Down
13 changes: 7 additions & 6 deletions src/pages/auth/login/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { type GetServerSideProps, type GetServerSidePropsContext } from "next";
import type { GetServerSideProps, GetServerSidePropsContext } from "next";
import Head from "next/head";
import { type Session } from "next-auth";
import type { Session } from "next-auth";
import { getSession } from "next-auth/react";
import { ReactElement } from "react";
import type { ReactElement } from "react";
import { globalSiteTitle } from "~/utils/global";
import { LayoutPublic } from "~/components/layouts/layout";
import LoginForm from "~/components/auth/loginForm";
import { WelcomeMessage } from "~/components/auth/welcomeMessage";

const Login = ({ hasOauth, oauthExlusiveLogin }) => {
const Login = ({ hasOauth, hasAzureAD, oauthExlusiveLogin}) => {
const title = `${globalSiteTitle} - Sign In`;

return (
Expand All @@ -24,7 +24,7 @@ const Login = ({ hasOauth, oauthExlusiveLogin }) => {
<div className="flex flex-grow items-center m-5 sm:m-0">
<div className="mx-auto flex">
<WelcomeMessage />
<LoginForm hasOauth={hasOauth} oauthExlusiveLogin={oauthExlusiveLogin} />
<LoginForm hasOauth={hasOauth} hasAzureAD={hasAzureAD} oauthExlusiveLogin={oauthExlusiveLogin}/>
</div>
</div>
</main>
Expand All @@ -40,11 +40,12 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
context: GetServerSidePropsContext,
) => {
const hasOauth = !!(process.env.OAUTH_ID && process.env.OAUTH_SECRET);
const hasAzureAD = !!(process.env.AZURE_AD_CLIENT_ID && process.env.AZURE_AD_CLIENT_SECRET && process.env.AZURE_AD_TENANT_ID);
const oauthExlusiveLogin = process.env.OAUTH_EXCLUSIVE_LOGIN === "true";

const session = await getSession(context);
if (!session || !session.user) {
return { props: { hasOauth, oauthExlusiveLogin } };
return { props: { hasOauth, hasAzureAD, oauthExlusiveLogin } };
}

if (session.user) {
Expand Down
83 changes: 78 additions & 5 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { type GetServerSidePropsContext } from "next";
import type { GetServerSidePropsContext } from "next";
import { getServerSession, type NextAuthOptions, type DefaultSession } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import AzureADProvider from "next-auth/providers/azure-ad";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "~/server/db";
import { compare } from "bcryptjs";
import { type User as IUser } from "@prisma/client";
import type { User as IUser } from "@prisma/client";
import { isRunningInDocker } from "~/utils/docker";

/**
Expand Down Expand Up @@ -106,7 +107,23 @@ export const authOptions: NextAuthOptions = {
role: "USER",
});
},
},
},
AzureADProvider({
allowDangerousEmailAccountLinking:
Boolean(process.env.ALLOW_DANGEROUS_EMAIL_LINKING) || true,
clientId: process.env.AZURE_AD_CLIENT_ID,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
tenantId: process.env.AZURE_AD_TENANT_ID,
profile(profile) {
return Promise.resolve({
id: profile.sub || profile.id.toString(), // Handle ID based on provider
name: profile.name || profile.login || profile.username,
email: profile.email,
image: profile.picture || profile.avatar_url || profile.image_url || profile.image,
role: "USER",
});
},
}),
CredentialsProvider({
// The name to display on the sign in form (e.g. "Sign in with...")
name: "Credentials",
Expand Down Expand Up @@ -167,13 +184,13 @@ export const authOptions: NextAuthOptions = {
],
session: {
strategy: "jwt",
maxAge: parseInt(process.env.NEXTAUTH_SESSION_MAX_AGE, 10) || 30 * 24 * 60 * 60, // 30 Days
maxAge: Number.parseInt(process.env.NEXTAUTH_SESSION_MAX_AGE, 10) || 30 * 24 * 60 * 60, // 30 Days
},
callbacks: {
/**
* @see https://next-auth.js.org/configuration/callbacks#sign-in-callback
*/
async signIn({ user, account }) {
async signIn({ user, account}) {
if (account.provider === "credentials") {
// Check if the user already exists
const existingUser = await prisma.user.findUnique({
Expand Down Expand Up @@ -203,6 +220,62 @@ export const authOptions: NextAuthOptions = {
},
});

if (existingUser) {
// User exists, update last login or other fields as necessary
await prisma.user.update({
where: {
id: existingUser.id,
},
data: {
lastLogin: new Date().toISOString(),
},
});
} else {
// User does not exist, create new user
const userCount = await prisma.user.count();
const defaultUserGroup = await prisma.userGroup.findFirst({
where: {
isDefault: true,
},
});

await prisma.user.create({
data: {
name: user.name,
email: user.email,
lastLogin: new Date().toISOString(),
role: userCount === 0 ? "ADMIN" : "USER",
image: user.image,
userGroupId: defaultUserGroup?.id,
options: {
create: {
localControllerUrl: isRunningInDocker()
? "http:https://zerotier:9993"
: "http:https://127.0.0.1:9993",
},
},
},
select: {
id: true,
name: true,
email: true,
role: true,
},
});
}
return true;
}
if (account.provider === "azure-ad") {
// Check if the user already exists
const existingUser = await prisma.user.findUnique({
where: {
email: user.email,
},
include: {
accounts: true
}
});

if (existingUser) {
// User exists, update last login or other fields as necessary
await prisma.user.update({
Expand Down