From b9724b71d71433e63013e5bf641889a4ba3d461b Mon Sep 17 00:00:00 2001 From: kamtschatka Date: Sun, 15 Sep 2024 19:08:53 +0200 Subject: [PATCH] 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 --- .../web/components/signin/CredentialsForm.tsx | 19 ++-- apps/web/server/auth.ts | 88 ++++++++++++++++++- docs/docs/03-configuration.md | 22 ++++- packages/shared/config.ts | 15 ++++ 4 files changed, 133 insertions(+), 11 deletions(-) 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; diff --git a/docs/docs/03-configuration.md b/docs/docs/03-configuration.md index 4237e294..e7b13e97 100644 --- a/docs/docs/03-configuration.md +++ b/docs/docs/03-configuration.md @@ -9,10 +9,30 @@ The app is mainly configured by environment variables. All the used environment | NEXTAUTH_SECRET | Yes | Not set | Random string used to sign the JWT tokens. Generate one with `openssl rand -base64 36`. | | MEILI_ADDR | No | Not set | The address of meilisearch. If not set, Search will be disabled. E.g. (`http://meilisearch:7700`) | | MEILI_MASTER_KEY | Only in Prod and if search is enabled | Not set | The master key configured for meilisearch. Not needed in development environment. Generate one with `openssl rand -base64 36` | -| DISABLE_SIGNUPS | No | false | If enabled, no new signups will be allowed and the signup button will be disabled in the UI | | MAX_ASSET_SIZE_MB | No | 4 | Sets the maximum allowed asset size (in MB) to be uploaded | | DISABLE_NEW_RELEASE_CHECK | No | false | If set to true, latest release check will be disabled in the admin panel. | +## Authentication / Signup + +By default, Hoarder uses the database to store users, but it is possible to also use OAuth. +The flags need to be provided to the `web` container. + +:::info +Only OIDC compliant OAuth providers are supported! For information on how to set it up, consult the documentation of your provider. +::: + +| Name | Required | Default | Description | +| ------------------------------------------- | -------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DISABLE_SIGNUPS | No | false | If enabled, no new signups will be allowed and the signup button will be disabled in the UI | +| OAUTH_WELLKNOWN_URL | No | Not set | The "wellknown Url" for openid-configuration as provided by the OAuth provider | +| OAUTH_CLIENT_SECRET | No | Not set | The "Client Secret" as provided by the OAuth provider | +| OAUTH_CLIENT_ID | No | Not set | The "Client ID" as provided by the OAuth provider | +| OAUTH_SCOPE | No | "openid email profile" | "Full list of scopes to request (space delimited)" | +| OAUTH_PROVIDER_NAME | No | "Custom Provider" | The name of your provider. Will be shown on the signup page as "Sign in with " | +| OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING | No | false | Whether existing accounts in hoarder stored in the database should automatically be linked with your OAuth account. DANGEROUS, but can also be helpful! | + +For more information on `OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING`, check the [next-auth.js documentation](https://next-auth.js.org/configuration/providers/oauth#allowdangerousemailaccountlinking-option). + ## Inference Configs (For automatic tagging) Either `OPENAI_API_KEY` or `OLLAMA_BASE_URL` need to be set for automatic tagging to be enabled. Otherwise, automatic tagging will be skipped. diff --git a/packages/shared/config.ts b/packages/shared/config.ts index b2de8677..21cdb1c8 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -10,6 +10,12 @@ const stringBool = (defaultValue: string) => const allEnv = z.object({ API_URL: z.string().url().default("http://localhost:3000"), DISABLE_SIGNUPS: stringBool("false"), + OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: stringBool("false"), + OAUTH_WELLKNOWN_URL: z.string().url().optional(), + OAUTH_CLIENT_SECRET: z.string().optional(), + OAUTH_CLIENT_ID: z.string().optional(), + OAUTH_SCOPE: z.string().default("openid email profile"), + OAUTH_PROVIDER_NAME: z.string().default("Custom Provider"), OPENAI_API_KEY: z.string().optional(), OPENAI_BASE_URL: z.string().url().optional(), OLLAMA_BASE_URL: z.string().url().optional(), @@ -47,6 +53,15 @@ const serverConfigSchema = allEnv.transform((val) => { apiUrl: val.API_URL, auth: { disableSignups: val.DISABLE_SIGNUPS, + oauth: { + allowDangerousEmailAccountLinking: + val.OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING, + wellKnownUrl: val.OAUTH_WELLKNOWN_URL, + clientSecret: val.OAUTH_CLIENT_SECRET, + clientId: val.OAUTH_CLIENT_ID, + scope: val.OAUTH_SCOPE, + name: val.OAUTH_PROVIDER_NAME, + }, }, inference: { jobTimeoutSec: val.INFERENCE_JOB_TIMEOUT_SEC,