Skip to content

Commit

Permalink
feature: Added support for custom OIDC providers to set up authentica…
Browse files Browse the repository at this point in the history
…tion. Fixes #92 (#307)

* #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 <[email protected]>
  • Loading branch information
kamtschatka and MohamedBassem authored Sep 15, 2024
1 parent 80749d5 commit b9724b7
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 11 deletions.
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

0 comments on commit b9724b7

Please sign in to comment.