aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkamtschatka <simon.schatka@gmx.at>2024-09-15 19:08:53 +0200
committerGitHub <noreply@github.com>2024-09-15 18:08:53 +0100
commitb9724b71d71433e63013e5bf641889a4ba3d461b (patch)
tree22966b9c378bf898d7ab812fcabbba2298a65989
parent80749d5327942c12b513124c43e3577fdd8c0541 (diff)
downloadkarakeep-b9724b71d71433e63013e5bf641889a4ba3d461b.tar.zst
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 <me@mbassem.com>
-rw-r--r--apps/web/components/signin/CredentialsForm.tsx19
-rw-r--r--apps/web/server/auth.ts88
-rw-r--r--docs/docs/03-configuration.md22
-rw-r--r--packages/shared/config.ts15
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<z.infer<typeof signInSchema>>({
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() {
>
<div className="flex w-full flex-col space-y-2">
{signinError && (
- <p className="w-full text-center text-destructive">
- Incorrect username or password
- </p>
+ <p className="w-full text-center text-destructive">{signinError}</p>
)}
<FormField
control={form.control}
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<boolean> {
+ 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<boolean> {
+ 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<string, string>) {
+ 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 <name>" |
+| 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,