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/server/auth.ts | 88 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 4 deletions(-) (limited to 'apps/web/server') diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts index 2ab44d5a..483d1522 100644 --- a/apps/web/server/auth.ts +++ b/apps/web/server/auth.ts @@ -1,5 +1,6 @@ import type { Adapter } from "next-auth/adapters"; import { DrizzleAdapter } from "@auth/drizzle-adapter"; +import { and, count, eq } from "drizzle-orm"; import NextAuth, { DefaultSession, getServerSession, @@ -15,13 +16,16 @@ import { users, verificationTokens, } from "@hoarder/db/schema"; +import serverConfig from "@hoarder/shared/config"; import { validatePassword } from "@hoarder/trpc/auth"; +type UserRole = "admin" | "user"; + declare module "next-auth/jwt" { export interface JWT { user: { id: string; - role: "admin" | "user"; + role: UserRole; } & DefaultSession["user"]; } } @@ -33,15 +37,38 @@ declare module "next-auth" { export interface Session { user: { id: string; - role: "admin" | "user"; + role: UserRole; } & DefaultSession["user"]; } export interface DefaultUser { - role: "admin" | "user" | null; + role: UserRole | null; } } +/** + * Returns true if the user table is empty, which indicates that this user is going to be + * the first one. This can be racy if multiple users are created at the same time, but + * that should be fine. + */ +async function isFirstUser(): Promise { + 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