From b9724b71d71433e63013e5bf641889a4ba3d461b Mon Sep 17 00:00:00 2001 From: kamtschatka Date: Sun, 15 Sep 2024 19:08:53 +0200 Subject: feature: Added support for custom OIDC providers to set up authentication. Fixes #92 (#307) * https://github.com/hoarder-app/hoarder/issues/92 Added support for custom OIDC providers to set up authentication * Added support for custom OIDC providers to set up authentication #92 Showing OAuth errors in the signin page * Added support for custom OIDC providers to set up authentication #92 Added the possibility to log in using an API key in case OAuth is used * Added support for custom OIDC providers to set up authentication #92 improved the code to also promote the first user to admin if OAuth is used * revert extension changes * Simplify admin checks --------- Co-authored-by: MohamedBassem --- apps/web/components/signin/CredentialsForm.tsx | 19 ++++-- apps/web/server/auth.ts | 88 ++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 10 deletions(-) (limited to 'apps') diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx index 07e08fae..65fec6a8 100644 --- a/apps/web/components/signin/CredentialsForm.tsx +++ b/apps/web/components/signin/CredentialsForm.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import { Form, @@ -28,9 +28,18 @@ const signInSchema = z.object({ password: z.string(), }); +const SIGNIN_FAILED = "Incorrect username or password"; +const OAUTH_FAILED = "OAuth login failed: "; + function SignIn() { - const [signinError, setSigninError] = useState(false); + const [signinError, setSigninError] = useState(""); const router = useRouter(); + const searchParams = useSearchParams(); + const oAuthError = searchParams.get("error"); + if (oAuthError && !signinError) { + setSigninError(`${OAUTH_FAILED} ${oAuthError}`); + } + const form = useForm>({ resolver: zodResolver(signInSchema), }); @@ -45,7 +54,7 @@ function SignIn() { password: value.password, }); if (!resp || !resp?.ok) { - setSigninError(true); + setSigninError(SIGNIN_FAILED); return; } router.replace("/"); @@ -53,9 +62,7 @@ function SignIn() { >
{signinError && ( -

- Incorrect username or password -

+

{signinError}

)} { + const [{ count: userCount }] = await db + .select({ count: count() }) + .from(users); + return userCount == 0; +} + +/** + * Returns true if the user is an admin + */ +async function isAdmin(email: string): Promise { + const res = await db.query.users.findFirst({ + columns: { role: true }, + where: eq(users.email, email), + }); + return res?.role == "admin"; +} + const providers: Provider[] = [ CredentialsProvider({ // The name to display on the sign in form (e.g. "Sign in with...") @@ -67,6 +94,35 @@ const providers: Provider[] = [ }), ]; +const oauth = serverConfig.auth.oauth; +if (oauth.wellKnownUrl) { + providers.push({ + id: "custom", + name: oauth.name, + type: "oauth", + wellKnown: oauth.wellKnownUrl, + authorization: { params: { scope: oauth.scope } }, + clientId: oauth.clientId, + clientSecret: oauth.clientSecret, + allowDangerousEmailAccountLinking: oauth.allowDangerousEmailAccountLinking, + idToken: true, + checks: ["pkce", "state"], + async profile(profile: Record) { + const [admin, firstUser] = await Promise.all([ + isAdmin(profile.email), + isFirstUser(), + ]); + return { + id: profile.sub, + name: profile.name, + email: profile.email, + image: profile.picture, + role: admin || firstUser ? "admin" : "user", + }; + }, + }); +} + export const authOptions: NextAuthOptions = { // https://github.com/nextauthjs/next-auth/issues/9493 adapter: DrizzleAdapter(db, { @@ -79,7 +135,31 @@ export const authOptions: NextAuthOptions = { session: { strategy: "jwt", }, + pages: { + signIn: "/signin", + signOut: "/signin", + error: "/signin", + newUser: "/signin", + }, callbacks: { + async signIn({ credentials, profile }) { + if (credentials) { + return true; + } + if (!profile?.email || !profile?.name) { + throw new Error("No profile"); + } + const [{ count: userCount }] = await db + .select({ count: count() }) + .from(users) + .where(and(eq(users.email, profile.email))); + + // If it's a new user and signups are disabled, fail the sign in + if (userCount === 0 && serverConfig.auth.disableSignups) { + throw new Error("Signups are disabled in server config"); + } + return true; + }, async jwt({ token, user }) { if (user) { token.user = { @@ -87,7 +167,7 @@ export const authOptions: NextAuthOptions = { name: user.name, email: user.email, image: user.image, - role: user.role || "user", + role: user.role ?? "user", }; } return token; -- cgit v1.2.3-70-g09d2