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

Added support for custom OIDC providers to set up authentication #92 #307

Merged
merged 10 commits into from
Sep 15, 2024
19 changes: 13 additions & 6 deletions apps/web/components/signin/CredentialsForm.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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),
});
Expand All @@ -45,17 +54,15 @@ function SignIn() {
password: value.password,
});
if (!resp || !resp?.ok) {
setSigninError(true);
setSigninError(SIGNIN_FAILED);
return;
}
router.replace("/");
})}
>
<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}
Expand Down
88 changes: 84 additions & 4 deletions apps/web/server/auth.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"];
}
}
Expand All @@ -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...")
Expand All @@ -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, {
Expand All @@ -79,15 +135,39 @@ 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 = {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
role: user.role || "user",
role: user.role ?? "user",
};
}
return token;
Expand Down
22 changes: 21 additions & 1 deletion docs/docs/03-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions packages/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
Loading