From 9a8ea9e1fe1943519a873c29e5ab939ba318130d Mon Sep 17 00:00:00 2001 From: Rikpat <33869814+Rikpat@users.noreply.github.com> Date: Fri, 9 Feb 2024 22:57:00 +0100 Subject: [PATCH] feat: add ldap and oidc support (#1497) Co-authored-by: Thomas Camlong <49837342+ajnart@users.noreply.github.com> Co-authored-by: Tagaishi --- next.config.js | 5 + package.json | 5 +- .../Manage/User/Create/review-input-step.tsx | 2 +- .../Onboarding/step-create-account.tsx | 9 +- src/env.js | 60 ++++ src/middleware.ts | 14 +- src/pages/auth/invite/[inviteId].tsx | 3 +- src/pages/auth/login.tsx | 168 +++++++--- src/server/auth.ts | 108 +------ src/server/db/schema.ts | 8 +- src/utils/auth/adapter.ts | 166 ++++++++++ src/utils/auth/credentials.ts | 56 ++++ src/utils/auth/index.ts | 46 +++ src/utils/auth/ldap.ts | 161 ++++++++++ src/utils/auth/oidc.ts | 51 +++ tests/pages/auth/login.spec.ts | 9 + tsconfig.json | 2 +- yarn.lock | 299 +++++++++++++----- 18 files changed, 923 insertions(+), 249 deletions(-) create mode 100644 src/utils/auth/adapter.ts create mode 100644 src/utils/auth/credentials.ts create mode 100644 src/utils/auth/index.ts create mode 100644 src/utils/auth/ldap.ts create mode 100644 src/utils/auth/oidc.ts diff --git a/next.config.js b/next.config.js index c87a8ebb2..191310ecf 100644 --- a/next.config.js +++ b/next.config.js @@ -6,6 +6,11 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ }); module.exports = withBundleAnalyzer({ + webpack: (config) => { + // for dynamic loading of auth providers + config.experiments = { ...config.experiments, topLevelAwait: true }; + return config; + }, images: { domains: ['cdn.jsdelivr.net'], }, diff --git a/package.json b/package.json index 590c95722..ef83e4af8 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "db:migrate": "dotenv ts-node drizzle/migrate/migrate.ts ./drizzle" }, "dependencies": { - "@auth/drizzle-adapter": "^0.3.2", "@ctrl/deluge": "^4.1.0", "@ctrl/qbittorrent": "^6.0.0", "@ctrl/shared-torrent": "^4.1.1", @@ -92,9 +91,8 @@ "i18next": "^22.5.1", "immer": "^10.0.2", "js-file-download": "^0.4.12", + "ldapjs": "^3.0.5", "mantine-react-table": "^1.3.4", - "moment": "^2.29.4", - "moment-timezone": "^0.5.43", "next": "13.4.12", "next-auth": "^4.23.0", "next-i18next": "^14.0.0", @@ -123,6 +121,7 @@ "@types/better-sqlite3": "^7.6.5", "@types/cookies": "^0.7.7", "@types/dockerode": "^3.3.9", + "@types/ldapjs": "^3.0.2", "@types/node": "18.17.8", "@types/prismjs": "^1.26.0", "@types/react": "^18.2.11", diff --git a/src/components/Manage/User/Create/review-input-step.tsx b/src/components/Manage/User/Create/review-input-step.tsx index eb56d7efe..7d819b19d 100644 --- a/src/components/Manage/User/Create/review-input-step.tsx +++ b/src/components/Manage/User/Create/review-input-step.tsx @@ -111,7 +111,7 @@ export const ReviewInputStep = ({ values, prevStep, nextStep }: ReviewInputStepP password: values.security.password, email: values.account.eMail === '' ? undefined : values.account.eMail, }); - umami.track('Create user', { username: values.account.username}); + umami.track('Create user', { username: values.account.username }); }} loading={isLoading} rightIcon={} diff --git a/src/components/Onboarding/step-create-account.tsx b/src/components/Onboarding/step-create-account.tsx index 01dc4a5f8..f5ada5cb0 100644 --- a/src/components/Onboarding/step-create-account.tsx +++ b/src/components/Onboarding/step-create-account.tsx @@ -57,9 +57,12 @@ export const StepCreateAccount = ({ Create your administrator account - Your administrator account must be secure, that's why we have so many rules surrounding it. -
Try not to make it adminadmin this time... -
Note: these password requirements are not forced, they are just recommendations. + Your administrator account must be secure, that's why we have so many rules + surrounding it. +
+ Try not to make it adminadmin this time... +
+ Note: these password requirements are not forced, they are just recommendations.
diff --git a/src/env.js b/src/env.js index 1787aa5ce..1b7c349ce 100644 --- a/src/env.js +++ b/src/env.js @@ -1,6 +1,14 @@ const { z } = require('zod'); const { createEnv } = require('@t3-oss/env-nextjs'); +const trueStrings = ["1", "t", "T", "TRUE", "true", "True"]; +const falseStrings = ["0", "f", "F", "FALSE", "false", "False"]; + +const zodParsedBoolean = () => z + .enum([...trueStrings, ...falseStrings]) + .default("false") + .transform((value) => trueStrings.includes(value)) + const portSchema = z .string() .regex(/\d*/) @@ -8,6 +16,8 @@ const portSchema = z .optional(); const envSchema = z.enum(['development', 'test', 'production']); +const authProviders = process.env.AUTH_PROVIDER?.replaceAll(' ', '').split(',') || ['credentials']; + const env = createEnv({ /** * Specify your server-side environment variables schema here. This way you can ensure the app @@ -28,6 +38,37 @@ const env = createEnv({ DOCKER_PORT: portSchema, DEMO_MODE: z.string().optional(), HOSTNAME: z.string().optional(), + + // Authentication + AUTH_PROVIDER: z.string().default('credentials').transform(providers => providers.replaceAll(' ', '').split(',')), + // LDAP + ...(authProviders.includes('ldap') + ? { + AUTH_LDAP_URI: z.string().url(), + AUTH_LDAP_BIND_DN: z.string(), + AUTH_LDAP_BIND_PASSWORD: z.string(), + AUTH_LDAP_BASE: z.string(), + AUTH_LDAP_USERNAME_ATTRIBUTE: z.string().default('uid'), + AUTH_LDAP_GROUP_CLASS: z.string().default('groupOfUniqueNames'), + AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: z.string().default('member'), + AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: z.string().default('dn'), + AUTH_LDAP_ADMIN_GROUP: z.string().default('admin'), + AUTH_LDAP_OWNER_GROUP: z.string().default('admin'), + } + : {}), + // OIDC + ...(authProviders.includes('oidc') + ? { + AUTH_OIDC_CLIENT_ID: z.string(), + AUTH_OIDC_CLIENT_SECRET: z.string(), + AUTH_OIDC_URI: z.string().url(), + // Custom Display name, defaults to OIDC + AUTH_OIDC_CLIENT_NAME: z.string().default('OIDC'), + AUTH_OIDC_ADMIN_GROUP: z.string().default('admin'), + AUTH_OIDC_OWNER_GROUP: z.string().default('admin'), + AUTH_OIDC_AUTO_LOGIN: zodParsedBoolean() + } + : {}), }, /** @@ -64,6 +105,25 @@ const env = createEnv({ NEXT_PUBLIC_PORT: process.env.PORT, NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV, HOSTNAME: process.env.HOSTNAME, + AUTH_PROVIDER: process.env.AUTH_PROVIDER, + AUTH_LDAP_URI: process.env.AUTH_LDAP_URI, + AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN, + AUTH_LDAP_BIND_PASSWORD: process.env.AUTH_LDAP_BIND_PASSWORD, + AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE, + AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE, + AUTH_LDAP_GROUP_CLASS: process.env.AUTH_LDAP_GROUP_CLASS, + AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE, + AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE, + AUTH_LDAP_ADMIN_GROUP: process.env.AUTH_LDAP_ADMIN_GROUP, + AUTH_LDAP_OWNER_GROUP: process.env.AUTH_LDAP_OWNER_GROUP, + AUTH_OIDC_CLIENT_ID: process.env.AUTH_OIDC_CLIENT_ID, + AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET, + AUTH_OIDC_URI: process.env.AUTH_OIDC_URI, + AUTH_OIDC_CLIENT_NAME: process.env.AUTH_OIDC_CLIENT_NAME, + AUTH_OIDC_GROUP_CLAIM: process.env.AUTH_OIDC_GROUP_CLAIM, + AUTH_OIDC_ADMIN_GROUP: process.env.AUTH_OIDC_ADMIN_GROUP, + AUTH_OIDC_OWNER_GROUP: process.env.AUTH_OIDC_OWNER_GROUP, + AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN, DEMO_MODE: process.env.DEMO_MODE, }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, diff --git a/src/middleware.ts b/src/middleware.ts index ee139deca..61bb29cd0 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -10,6 +10,7 @@ const skippedUrls = [ '/favicon.ico', '/404', '/pages/_app', + '/auth/login', '/imgs/', ]; @@ -29,12 +30,15 @@ export async function middleware(req: NextRequest) { } // Do not redirect if there are users in the database - if (cachedUserCount > 0) { - return NextResponse.next(); - } + if (cachedUserCount > 0 || !(await shouldRedirectToOnboard())) { + // redirect to login if not logged in + // not working, should work in next-auth 5 + // @see https://github.com/nextauthjs/next-auth/pull/7443 - // Do not redirect if there are users in the database - if (!(await shouldRedirectToOnboard())) { + // const session = await getServerSession(); + // if (!session?.user) { + // return NextResponse.redirect(getUrl(req) + '/auth/login') + // } return NextResponse.next(); } diff --git a/src/pages/auth/invite/[inviteId].tsx b/src/pages/auth/invite/[inviteId].tsx index f41bfeb6a..32d0e3baf 100644 --- a/src/pages/auth/invite/[inviteId].tsx +++ b/src/pages/auth/invite/[inviteId].tsx @@ -125,8 +125,7 @@ export default function AuthInvitePage() { withAsterisk {...form.getInputProps('password')} /> - + diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index b1dabe22c..eb7f0af2e 100644 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -1,13 +1,24 @@ -import { Alert, Button, Card, Flex, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core'; +import { + Alert, + Button, + Card, + Divider, + Flex, + PasswordInput, + Stack, + Text, + TextInput, + Title, +} from '@mantine/core'; import { useForm } from '@mantine/form'; import { IconAlertTriangle } from '@tabler/icons-react'; -import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next'; import { signIn } from 'next-auth/react'; import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Image from 'next/image'; import { useRouter } from 'next/router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { z } from 'zod'; import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle'; import { FloatingBackground } from '~/components/layout/Background/FloatingBackground'; @@ -17,8 +28,13 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { signInSchema } from '~/validations/user'; +const signInSchemaWithProvider = signInSchema.extend({ provider: z.string() }); + export default function LoginPage({ redirectAfterLogin, + providers, + oidcProviderName, + oidcAutoLogin, isDemo, }: InferGetServerSidePropsType) { const { t } = useTranslation('authentication/login'); @@ -27,16 +43,18 @@ export default function LoginPage({ const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); - const form = useForm>({ + const hasCredentialsInput = providers.includes('credentials') || providers.includes('ldap'); + + const form = useForm>({ validateInputOnChange: true, validateInputOnBlur: true, - validate: i18nZodResolver(signInSchema), + validate: i18nZodResolver(signInSchemaWithProvider), }); - const handleSubmit = (values: z.infer) => { + const handleSubmit = (values: z.infer) => { setIsLoading(true); setIsError(false); - signIn('credentials', { + signIn(values.provider, { redirect: false, name: values.name, password: values.password, @@ -51,6 +69,10 @@ export default function LoginPage({ }); }; + useEffect(() => { + if (oidcAutoLogin) signIn('oidc'); + }, [oidcAutoLogin]); + const metaTitle = `${t('metaTitle')} • Homarr`; return ( @@ -58,7 +80,6 @@ export default function LoginPage({ {metaTitle} - @@ -83,51 +104,94 @@ export default function LoginPage({ demodemo )} - - - {t('title')} - + {oidcAutoLogin ? ( + + + Signing in with OIDC provider + + + ) : ( + + + {t('title')} + - - {t('text')} - + + {t('text')} + - {isError && ( - } color="red"> - {t('alert')} - - )} + {isError && ( + } color="red"> + {t('alert')} + + )} + {hasCredentialsInput && ( + + + - - - + - + {providers.includes('credentials') && ( + + )} - + )} + + {redirectAfterLogin && ( + + {t('form.afterLoginRedirection', { url: redirectAfterLogin })} + + )} + + + )} + {hasCredentialsInput && providers.includes('oidc') && ( + + )} + {providers.includes('oidc') && ( + - - {redirectAfterLogin && ( - - {t('form.afterLoginRedirection', { url: redirectAfterLogin })} - - )} - - - + )} + + )}
@@ -136,7 +200,12 @@ export default function LoginPage({ const regexExp = /^\/{1}[A-Za-z\/]*$/; -export const getServerSideProps: GetServerSideProps = async ({ locale, req, res, query }) => { +export const getServerSideProps = async ({ + locale, + req, + res, + query, +}: GetServerSidePropsContext) => { const session = await getServerAuthSession({ req, res }); const zodResult = await z @@ -159,6 +228,9 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res, props: { ...(await getServerSideTranslations(['authentication/login'], locale, req, res)), redirectAfterLogin, + providers: env.AUTH_PROVIDER, + oidcProviderName: env.AUTH_OIDC_CLIENT_NAME || null, + oidcAutoLogin: env.AUTH_OIDC_AUTO_LOGIN || null, isDemo, }, }; diff --git a/src/server/auth.ts b/src/server/auth.ts index 5d79c62f5..46ea2f539 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -1,57 +1,17 @@ -import { DrizzleAdapter } from '@auth/drizzle-adapter'; -import bcrypt from 'bcryptjs'; -import Consola from 'consola'; import Cookies from 'cookies'; import { eq } from 'drizzle-orm'; import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next'; -import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth'; +import { type NextAuthOptions, getServerSession } from 'next-auth'; import { Adapter } from 'next-auth/adapters'; import { decode, encode } from 'next-auth/jwt'; -import Credentials from 'next-auth/providers/credentials'; +import { adapter, onCreateUser, providers } from '~/utils/auth'; import EmptyNextAuthProvider from '~/utils/empty-provider'; import { fromDate, generateSessionToken } from '~/utils/session'; -import { colorSchemeParser, signInSchema } from '~/validations/user'; +import { colorSchemeParser } from '~/validations/user'; import { db } from './db'; import { users } from './db/schema'; -/** - * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` - * object and keep type safety. - * - * @see https://next-auth.js.org/getting-started/typescript#module-augmentation - */ -declare module 'next-auth' { - interface Session extends DefaultSession { - user: DefaultSession['user'] & { - id: string; - isAdmin: boolean; - colorScheme: 'light' | 'dark' | 'environment'; - autoFocusSearch: boolean; - language: string; - // ...other properties - // role: UserRole; - }; - } - - interface User { - isAdmin: boolean; - colorScheme: 'light' | 'dark' | 'environment'; - autoFocusSearch: boolean; - language: string; - // ...other properties - // role: UserRole; - } -} - -declare module 'next-auth/jwt' { - interface JWT { - id: string; - isAdmin: boolean; - } -} - -const adapter = DrizzleAdapter(db); const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days /** @@ -63,6 +23,9 @@ export const constructAuthOptions = ( req: NextApiRequest, res: NextApiResponse ): NextAuthOptions => ({ + events: { + createUser: onCreateUser, + }, callbacks: { async session({ session, user }) { if (session.user) { @@ -133,58 +96,7 @@ export const constructAuthOptions = ( error: '/auth/login', }, adapter: adapter as Adapter, - providers: [ - Credentials({ - name: 'credentials', - credentials: { - name: { - label: 'Username', - type: 'text', - }, - password: { label: 'Password', type: 'password' }, - }, - async authorize(credentials) { - const data = await signInSchema.parseAsync(credentials); - - const user = await db.query.users.findFirst({ - with: { - settings: { - columns: { - colorScheme: true, - language: true, - autoFocusSearch: true, - }, - }, - }, - where: eq(users.name, data.name), - }); - - if (!user || !user.password) { - return null; - } - - Consola.log(`user ${user.name} is trying to log in. checking password...`); - const isValidPassword = await bcrypt.compare(data.password, user.password); - - if (!isValidPassword) { - Consola.log(`password for user ${user.name} was incorrect`); - return null; - } - - Consola.log(`user ${user.name} successfully authorized`); - - return { - id: user.id, - name: user.name, - isAdmin: false, - colorScheme: colorSchemeParser.parse(user.settings?.colorScheme), - language: user.settings?.language ?? 'en', - autoFocusSearch: user.settings?.autoFocusSearch ?? false, - }; - }, - }), - EmptyNextAuthProvider(), - ], + providers: [...providers, EmptyNextAuthProvider()], jwt: { async encode(params) { if (!isCredentialsRequest(req)) { @@ -207,10 +119,12 @@ export const constructAuthOptions = ( }); const isCredentialsRequest = (req: NextApiRequest): boolean => { - const nextAuthQueryParams = req.query.nextauth as ['callback', 'credentials']; + const nextAuthQueryParams = req.query.nextauth as string[]; return ( nextAuthQueryParams.includes('callback') && - nextAuthQueryParams.includes('credentials') && + (nextAuthQueryParams.includes('credentials') || + nextAuthQueryParams.includes('ldap') || + nextAuthQueryParams.includes('oidc')) && req.method === 'POST' ); }; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 470f96a5e..9ee168d05 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -2,7 +2,9 @@ import { InferSelectModel, relations } from 'drizzle-orm'; import { index, int, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { type AdapterAccount } from 'next-auth/adapters'; -export const users = sqliteTable('user', { +// workaround for typescript check in adapter +// preferably add email into credential login and make email non-nullable here +export const _users = { id: text('id').notNull().primaryKey(), name: text('name'), email: text('email'), @@ -12,7 +14,9 @@ export const users = sqliteTable('user', { salt: text('salt'), isAdmin: int('is_admin', { mode: 'boolean' }).notNull().default(false), isOwner: int('is_owner', { mode: 'boolean' }).notNull().default(false), -}); +}; + +export const users = sqliteTable('user', _users); export const accounts = sqliteTable( 'account', diff --git a/src/utils/auth/adapter.ts b/src/utils/auth/adapter.ts new file mode 100644 index 000000000..7fe34f184 --- /dev/null +++ b/src/utils/auth/adapter.ts @@ -0,0 +1,166 @@ +import { randomUUID } from 'crypto'; +import { and, eq } from 'drizzle-orm'; +import { + BaseSQLiteDatabase, + SQLiteTableFn, + sqliteTable as defaultSqliteTableFn, + text, +} from 'drizzle-orm/sqlite-core'; +import { User } from 'next-auth'; +import { Adapter, AdapterAccount } from 'next-auth/adapters'; +import { db } from '~/server/db'; +import { _users, accounts, sessions, userSettings, verificationTokens } from '~/server/db/schema'; + +// Need to modify createTables with custom schema +const createTables = (sqliteTable: SQLiteTableFn) => ({ + users: sqliteTable('user', { + ..._users, + email: text('email').notNull(), // workaround for typescript + }), + accounts, + sessions, + verificationTokens, +}); + +export type DefaultSchema = ReturnType; + +export const onCreateUser = async ({ user }: { user: User }) => { + await db.insert(userSettings).values({ + id: randomUUID(), + userId: user.id, + }); +}; + +// Keep this the same as original file @auth/drizzle-adapter/src/lib/sqlite.ts +// only change changed return type from Adapter to "satisfies Adapter", to tell typescript createUser exists + +export function SQLiteDrizzleAdapter( + client: InstanceType, + tableFn = defaultSqliteTableFn +) { + const { users, accounts, sessions, verificationTokens } = createTables(tableFn); + + return { + createUser(data) { + return client + .insert(users) + .values({ ...data, id: crypto.randomUUID() }) + .returning() + .get(); + }, + getUser(data) { + return client.select().from(users).where(eq(users.id, data)).get() ?? null; + }, + getUserByEmail(data) { + return client.select().from(users).where(eq(users.email, data)).get() ?? null; + }, + createSession(data) { + return client.insert(sessions).values(data).returning().get(); + }, + getSessionAndUser(data) { + return ( + client + .select({ + session: sessions, + user: users, + }) + .from(sessions) + .where(eq(sessions.sessionToken, data)) + .innerJoin(users, eq(users.id, sessions.userId)) + .get() ?? null + ); + }, + updateUser(data) { + if (!data.id) { + throw new Error('No user id.'); + } + + return client.update(users).set(data).where(eq(users.id, data.id)).returning().get(); + }, + updateSession(data) { + return client + .update(sessions) + .set(data) + .where(eq(sessions.sessionToken, data.sessionToken)) + .returning() + .get(); + }, + linkAccount(rawAccount) { + const updatedAccount = client.insert(accounts).values(rawAccount).returning().get(); + + const account: AdapterAccount = { + ...updatedAccount, + type: updatedAccount.type, + access_token: updatedAccount.access_token ?? undefined, + token_type: updatedAccount.token_type ?? undefined, + id_token: updatedAccount.id_token ?? undefined, + refresh_token: updatedAccount.refresh_token ?? undefined, + scope: updatedAccount.scope ?? undefined, + expires_at: updatedAccount.expires_at ?? undefined, + session_state: updatedAccount.session_state ?? undefined, + }; + + return account; + }, + getUserByAccount(account) { + const results = client + .select() + .from(accounts) + .leftJoin(users, eq(users.id, accounts.userId)) + .where( + and( + eq(accounts.provider, account.provider), + eq(accounts.providerAccountId, account.providerAccountId) + ) + ) + .get(); + + return results?.user ?? null; + }, + deleteSession(sessionToken) { + return ( + client.delete(sessions).where(eq(sessions.sessionToken, sessionToken)).returning().get() ?? + null + ); + }, + createVerificationToken(token) { + return client.insert(verificationTokens).values(token).returning().get(); + }, + useVerificationToken(token) { + try { + return ( + client + .delete(verificationTokens) + .where( + and( + eq(verificationTokens.identifier, token.identifier), + eq(verificationTokens.token, token.token) + ) + ) + .returning() + .get() ?? null + ); + } catch (err) { + throw new Error('No verification token found.'); + } + }, + deleteUser(id) { + return client.delete(users).where(eq(users.id, id)).returning().get(); + }, + unlinkAccount(account) { + client + .delete(accounts) + .where( + and( + eq(accounts.providerAccountId, account.providerAccountId), + eq(accounts.provider, account.provider) + ) + ) + .run(); + + return undefined; + }, + } satisfies Adapter; +} + +export default SQLiteDrizzleAdapter(db); diff --git a/src/utils/auth/credentials.ts b/src/utils/auth/credentials.ts new file mode 100644 index 000000000..199c1e239 --- /dev/null +++ b/src/utils/auth/credentials.ts @@ -0,0 +1,56 @@ +import bcrypt from 'bcryptjs'; +import Consola from 'consola'; +import { eq } from 'drizzle-orm'; +import Credentials from 'next-auth/providers/credentials'; +import { colorSchemeParser, signInSchema } from '~/validations/user'; + +import { db } from '../../server/db'; +import { users } from '../../server/db/schema'; + +export default Credentials({ + name: 'credentials', + credentials: { + name: { + label: 'Username', + type: 'text', + }, + password: { label: 'Password', type: 'password' }, + }, + async authorize(credentials) { + const data = await signInSchema.parseAsync(credentials); + + const user = await db.query.users.findFirst({ + with: { + settings: { + columns: { + colorScheme: true, + language: true, + autoFocusSearch: true, + }, + }, + }, + where: eq(users.name, data.name), + }); + + if (!user || !user.password) { + return null; + } + + Consola.log(`user ${user.name} is trying to log in. checking password...`); + const isValidPassword = await bcrypt.compare(data.password, user.password); + + if (!isValidPassword) { + Consola.log(`password for user ${user.name} was incorrect`); + return null; + } + + Consola.log(`user ${user.name} successfully authorized`); + + return { + id: user.id, + name: user.name, + isAdmin: false, + isOwner: false, + }; + }, +}); diff --git a/src/utils/auth/index.ts b/src/utils/auth/index.ts new file mode 100644 index 000000000..73c7ea5db --- /dev/null +++ b/src/utils/auth/index.ts @@ -0,0 +1,46 @@ +import { DefaultSession } from 'next-auth'; +import { CredentialsConfig, OAuthConfig } from 'next-auth/providers'; +import { env } from '~/env'; + +export { default as adapter, onCreateUser } from './adapter'; + +/** + * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` + * object and keep type safety. + * + * @see https://next-auth.js.org/getting-started/typescript#module-augmentation + */ +declare module 'next-auth' { + interface Session extends DefaultSession { + user: DefaultSession['user'] & { + id: string; + isAdmin: boolean; + colorScheme: 'light' | 'dark' | 'environment'; + autoFocusSearch: boolean; + language: string; + // ...other properties + // role: UserRole; + }; + } + + interface User { + isAdmin: boolean; + isOwner?: boolean; + // ...other properties + // role: UserRole; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + id: string; + isAdmin: boolean; + } +} + +export const providers: (CredentialsConfig | OAuthConfig)[] = []; + +if (env.AUTH_PROVIDER?.includes('ldap')) providers.push((await import('./ldap')).default); +if (env.AUTH_PROVIDER?.includes('credentials')) + providers.push((await import('./credentials')).default); +if (env.AUTH_PROVIDER?.includes('oidc')) providers.push((await import('./oidc')).default); diff --git a/src/utils/auth/ldap.ts b/src/utils/auth/ldap.ts new file mode 100644 index 000000000..91d0761b1 --- /dev/null +++ b/src/utils/auth/ldap.ts @@ -0,0 +1,161 @@ +import Consola from 'consola'; +import ldap from 'ldapjs'; +import Credentials from 'next-auth/providers/credentials'; +import { env } from '~/env'; +import { signInSchema } from '~/validations/user'; + +import adapter, { onCreateUser } from './adapter'; + +// Helper types for infering properties of returned search type +type AttributeConstraint = string | readonly string[] | undefined; + +type InferrableSearchOptions< + Attributes extends AttributeConstraint, + ArrayAttributes extends Attributes, +> = Omit & { + attributes?: Attributes; + arrayAttributes?: ArrayAttributes; +}; + +type SearchResultIndex = Attributes extends string + ? Attributes + : Attributes extends readonly string[] + ? Attributes[number] + : string; + +type SearchResult< + Attributes extends AttributeConstraint, + ArrayAttributes extends Attributes = never, +> = { dn: string } & Record< + Exclude, SearchResultIndex>, + string +> & + Record, string[]>; + +const ldapLogin = (username: string, password: string) => + new Promise((resolve, reject) => { + const client = ldap.createClient({ + url: env.AUTH_LDAP_URI, + }); + client.bind(username, password, (error, res) => { + if (error) { + reject('Invalid username or password'); + } else { + resolve(client); + } + }); + }); + +const ldapSearch = async < + Attributes extends AttributeConstraint, + ArrayAttributes extends Attributes = never, +>( + client: ldap.Client, + base: string, + options: InferrableSearchOptions +) => + new Promise[]>((resolve, reject) => { + client.search(base, options as ldap.SearchOptions, (err, res) => { + const results: SearchResult[] = []; + res.on('error', (err) => { + reject('error: ' + err.message); + }); + res.on('searchEntry', (entry) => { + results.push( + entry.pojo.attributes.reduce>( + (obj, attr) => { + // just take first element assuming there's only one (uid, mail), unless in arrayAttributes + obj[attr.type] = options.arrayAttributes?.includes(attr.type) + ? attr.values + : attr.values[0]; + return obj; + }, + { dn: entry.pojo.objectName } + ) as SearchResult + ); + }); + res.on('end', (result) => { + if (result?.status != 0) { + reject(new Error('ldap search status is not 0, search failed')); + } else { + resolve(results); + } + }); + }); + }); + +export default Credentials({ + id: 'ldap', + name: 'LDAP', + credentials: { + name: { label: 'uid', type: 'text' }, + password: { label: 'Password', type: 'password' }, + }, + async authorize(credentials) { + try { + const data = await signInSchema.parseAsync(credentials); + + Consola.log(`user ${data.name} is trying to log in using LDAP. Signing in...`); + const client = await ldapLogin(env.AUTH_LDAP_BIND_DN, env.AUTH_LDAP_BIND_PASSWORD); + + const ldapUser = ( + await ldapSearch(client, env.AUTH_LDAP_BASE, { + filter: `(uid=${data.name})`, + // as const for inference + attributes: ['uid', 'mail'] as const, + }) + )[0]; + + await ldapLogin(ldapUser.dn, data.password).then((client) => client.destroy()); + + const userGroups = ( + await ldapSearch(client, env.AUTH_LDAP_BASE, { + filter: `(&(objectclass=${env.AUTH_LDAP_GROUP_CLASS})(${ + env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE + }=${ldapUser[env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE as 'dn' | 'uid']}))`, + // as const for inference + attributes: 'cn', + }) + ).map((group) => group.cn); + + client.destroy(); + + Consola.log(`user ${data.name} successfully authorized`); + + let user = await adapter.getUserByEmail!(ldapUser.mail); + const isAdmin = userGroups.includes(env.AUTH_LDAP_ADMIN_GROUP); + const isOwner = userGroups.includes(env.AUTH_LDAP_OWNER_GROUP); + + if (!user) { + // CreateUser will create settings in event + user = await adapter.createUser({ + name: ldapUser.uid, + email: ldapUser.mail, + emailVerified: new Date(), // assume ldap email is verified + isAdmin: isAdmin, + isOwner: isOwner, + }); + // For some reason adapter.createUser doesn't call createUser event, needs to be called manually to create usersettings + await onCreateUser({ user }); + } else if (user.isAdmin != isAdmin || user.isOwner != isOwner) { + // Update roles if changed in LDAP + Consola.log(`updating roles of user ${user.name}`); + adapter.updateUser({ + ...user, + isAdmin, + isOwner, + }); + } + + return { + id: user?.id || ldapUser.dn, + name: user?.name || ldapUser.uid, + isAdmin: isAdmin, + isOwner: isOwner, + }; + } catch (error) { + Consola.error(error); + return null; + } + }, +}); diff --git a/src/utils/auth/oidc.ts b/src/utils/auth/oidc.ts new file mode 100644 index 000000000..8d9c3ef76 --- /dev/null +++ b/src/utils/auth/oidc.ts @@ -0,0 +1,51 @@ +import Consola from 'consola'; +import { OAuthConfig } from 'next-auth/providers/oauth'; +import { env } from '~/env'; + +import adapter from './adapter'; + +type Profile = { + sub: string; + name: string; + email: string; + groups: string[]; + preferred_username: string; + email_verified: boolean; +}; + +const provider: OAuthConfig = { + id: 'oidc', + name: env.AUTH_OIDC_CLIENT_NAME, + type: 'oauth', + clientId: env.AUTH_OIDC_CLIENT_ID, + clientSecret: env.AUTH_OIDC_CLIENT_SECRET, + wellKnown: `${env.AUTH_OIDC_URI}/.well-known/openid-configuration`, + authorization: { params: { scope: 'openid email profile groups' } }, + idToken: true, + async profile(profile) { + const user = await adapter.getUserByEmail!(profile.email); + + const isAdmin = profile.groups.includes(env.AUTH_OIDC_ADMIN_GROUP); + const isOwner = profile.groups.includes(env.AUTH_OIDC_OWNER_GROUP); + + // check for role update + if (user && (user.isAdmin != isAdmin || user.isOwner != isOwner)) { + Consola.log(`updating roles of user ${user.name}`); + adapter.updateUser({ + ...user, + isAdmin, + isOwner, + }); + } + + return { + id: profile.sub, + name: profile.preferred_username, + email: profile.email, + isAdmin, + isOwner, + }; + }, +}; + +export default provider; diff --git a/tests/pages/auth/login.spec.ts b/tests/pages/auth/login.spec.ts index d4facabe3..da0dbfce0 100644 --- a/tests/pages/auth/login.spec.ts +++ b/tests/pages/auth/login.spec.ts @@ -38,6 +38,9 @@ describe('login page', () => { redirectAfterLogin: null, isDemo: false, _i18Next: 'hello', + oidcAutoLogin: null, + oidcProviderName: null, + providers: undefined }, }); @@ -75,6 +78,9 @@ describe('login page', () => { redirectAfterLogin: '/manage/users/create', isDemo: false, _i18Next: 'hello', + oidcAutoLogin: null, + oidcProviderName: null, + providers: undefined }, }); @@ -112,6 +118,9 @@ describe('login page', () => { redirectAfterLogin: null, isDemo: false, _i18Next: 'hello', + oidcAutoLogin: null, + oidcProviderName: null, + providers: undefined }, }); diff --git a/tsconfig.json b/tsconfig.json index ddd387b7f..0266d4ed3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es6", + "target": "es2017", "lib": [ "dom", "dom.iterable", diff --git a/yarn.lock b/yarn.lock index 7d1454967..26f58fa16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,34 +22,6 @@ __metadata: languageName: node linkType: hard -"@auth/core@npm:0.18.1": - version: 0.18.1 - resolution: "@auth/core@npm:0.18.1" - dependencies: - "@panva/hkdf": ^1.1.1 - cookie: 0.5.0 - jose: ^5.1.0 - oauth4webapi: ^2.3.0 - preact: 10.11.3 - preact-render-to-string: 5.2.3 - peerDependencies: - nodemailer: ^6.8.0 - peerDependenciesMeta: - nodemailer: - optional: true - checksum: 46ae80e621e03d9206cc9a5e37941df92207e58298f423ec71ae2b8d3492d86f14d5e024ba30c5a905675c451688d212d389b580748f3a176ec0ddcd3872291a - languageName: node - linkType: hard - -"@auth/drizzle-adapter@npm:^0.3.2": - version: 0.3.6 - resolution: "@auth/drizzle-adapter@npm:0.3.6" - dependencies: - "@auth/core": 0.18.1 - checksum: c80abc825ab15645f39ad4fd630ca81caf18880aca32f8df030a072dfb7f5222d1fe4396713041bf24e7252c8478a09be81ac4f921652497319acf30e138f4ec - languageName: node - linkType: hard - "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.22.13": version: 7.22.13 resolution: "@babel/code-frame@npm:7.22.13" @@ -1033,6 +1005,95 @@ __metadata: languageName: node linkType: hard +"@ldapjs/asn1@npm:2.0.0, @ldapjs/asn1@npm:^2.0.0": + version: 2.0.0 + resolution: "@ldapjs/asn1@npm:2.0.0" + checksum: b9957b47b14ef0a24fa5275849b624f8a1a7708c2f37b0b7ff278062527a7c93a885c9a73462c3bba4a9e182bd0766422f08597361cdbf3ecafd7dfb478ab490 + languageName: node + linkType: hard + +"@ldapjs/asn1@npm:^1.2.0": + version: 1.2.0 + resolution: "@ldapjs/asn1@npm:1.2.0" + checksum: 720b65fd825b414f672264c19edf2b67f643bd655ac9dae761394f40e332c68fbe7f442046daf88a00a656ca2cbbfe91c0435fc59c9b7c301770ea0d2606b89a + languageName: node + linkType: hard + +"@ldapjs/attribute@npm:1.0.0, @ldapjs/attribute@npm:^1.0.0": + version: 1.0.0 + resolution: "@ldapjs/attribute@npm:1.0.0" + dependencies: + "@ldapjs/asn1": 2.0.0 + "@ldapjs/protocol": ^1.2.1 + process-warning: ^2.1.0 + checksum: 887665a3067deebbfea7760befc535f94205f87cece0f164f9ddc2f3f5b0daa136a0ede4520fa37aa9d30af025cb23023a155473bbc61916aa39da2ad697c7f0 + languageName: node + linkType: hard + +"@ldapjs/change@npm:^1.0.0": + version: 1.0.0 + resolution: "@ldapjs/change@npm:1.0.0" + dependencies: + "@ldapjs/asn1": 2.0.0 + "@ldapjs/attribute": 1.0.0 + checksum: 5f28d8e904fe47cbaff225d9696d35ee78f1f648e2aedab9aebe67c0b19df4a9b0224bf2ac9a8ab2d1dab00e69eaff9f17be5532af2f32862e27a973228d83eb + languageName: node + linkType: hard + +"@ldapjs/controls@npm:^2.1.0": + version: 2.1.0 + resolution: "@ldapjs/controls@npm:2.1.0" + dependencies: + "@ldapjs/asn1": ^1.2.0 + "@ldapjs/protocol": ^1.2.1 + checksum: b61a69ddf0634ea6bbc1a32691fa19ee92aa2efe17aeae77ea261b5b16cf6102c36ed71ef0ce038ec74fe7751917c0946862fdabe328086b7561b6e6453ef794 + languageName: node + linkType: hard + +"@ldapjs/dn@npm:^1.1.0": + version: 1.1.0 + resolution: "@ldapjs/dn@npm:1.1.0" + dependencies: + "@ldapjs/asn1": 2.0.0 + process-warning: ^2.1.0 + checksum: 716e408c9f8ea1d1f14c512a1ecbc3271d7873da1aee788bfa6548a47290fecefd9ea2039f1f9f9238cba8072ae798c4e4b4da5e457ee24b68e94572665f711f + languageName: node + linkType: hard + +"@ldapjs/filter@npm:^2.1.1": + version: 2.1.1 + resolution: "@ldapjs/filter@npm:2.1.1" + dependencies: + "@ldapjs/asn1": 2.0.0 + "@ldapjs/protocol": ^1.2.1 + process-warning: ^2.1.0 + checksum: e87c698fe7921969a751479b435a58f8202ebbe48420a3705dd47180b33ae39fcbe1451640c58fb94f80b4a96efa91d99ec91e6dc6d7be96b7bc3cc469506ba8 + languageName: node + linkType: hard + +"@ldapjs/messages@npm:^1.3.0": + version: 1.3.0 + resolution: "@ldapjs/messages@npm:1.3.0" + dependencies: + "@ldapjs/asn1": ^2.0.0 + "@ldapjs/attribute": ^1.0.0 + "@ldapjs/change": ^1.0.0 + "@ldapjs/controls": ^2.1.0 + "@ldapjs/dn": ^1.1.0 + "@ldapjs/filter": ^2.1.1 + "@ldapjs/protocol": ^1.2.1 + process-warning: ^2.2.0 + checksum: e7f1994db976456546769d72b2efba18c93e9201c81050b52479575bf72bac42312c6b817e886ac315caf592b00d2f0d3407fcc4eea58ff65c8bd18211e5b458 + languageName: node + linkType: hard + +"@ldapjs/protocol@npm:^1.2.1": + version: 1.2.1 + resolution: "@ldapjs/protocol@npm:1.2.1" + checksum: 3e26f3fc642897ae1448a5a172839ab368fe72e05b9eaf36e16fe6dd4c3c93ce298ab4e3907b3eda9c3911e018d617777b79071dfbb3b813add56b046aea48dc + languageName: node + linkType: hard + "@mantine/core@npm:^6.0.0": version: 6.0.21 resolution: "@mantine/core@npm:6.0.21" @@ -1504,7 +1565,7 @@ __metadata: languageName: node linkType: hard -"@panva/hkdf@npm:^1.0.2, @panva/hkdf@npm:^1.1.1": +"@panva/hkdf@npm:^1.0.2": version: 1.1.1 resolution: "@panva/hkdf@npm:1.1.1" checksum: f0dd12903751d8792420353f809ed3c7de860cf506399759fff5f59f7acfef8a77e2b64012898cee7e5b047708fa0bd91dff5ef55a502bf8ea11aad9842160da @@ -3286,6 +3347,15 @@ __metadata: languageName: node linkType: hard +"@types/ldapjs@npm:^3.0.2": + version: 3.0.2 + resolution: "@types/ldapjs@npm:3.0.2" + dependencies: + "@types/node": "*" + checksum: 0839acb3c46aa231577266c46700b44cfeb5cc77cfb854be6dac25bf1346cd0b5c83e3671fd6a78769c7702a1eb610d5f08b68e2583bb5c13d214eb0558c3d36 + languageName: node + linkType: hard + "@types/mime@npm:*": version: 3.0.4 resolution: "@types/mime@npm:3.0.4" @@ -3919,6 +3989,13 @@ __metadata: languageName: node linkType: hard +"abstract-logging@npm:^2.0.1": + version: 2.0.1 + resolution: "abstract-logging@npm:2.0.1" + checksum: 6967d15e5abbafd17f56eaf30ba8278c99333586fa4f7935fd80e93cfdc006c37fcc819c5d63ee373a12e6cb2d0417f7c3c6b9e42b957a25af9937d26749415e + languageName: node + linkType: hard + "accepts@npm:^1.3.7": version: 1.3.8 resolution: "accepts@npm:1.3.8" @@ -4207,6 +4284,13 @@ __metadata: languageName: node linkType: hard +"assert-plus@npm:^1.0.0": + version: 1.0.0 + resolution: "assert-plus@npm:1.0.0" + checksum: 19b4340cb8f0e6a981c07225eacac0e9d52c2644c080198765d63398f0075f83bbc0c8e95474d54224e297555ad0d631c1dcd058adb1ddc2437b41a6b424ac64 + languageName: node + linkType: hard + "assertion-error@npm:^1.1.0": version: 1.1.0 resolution: "assertion-error@npm:1.1.0" @@ -4309,6 +4393,15 @@ __metadata: languageName: node linkType: hard +"backoff@npm:^2.5.0": + version: 2.5.0 + resolution: "backoff@npm:2.5.0" + dependencies: + precond: 0.2 + checksum: ccdcf2a26acd9379d0d4f09e3fb3b7ee34dee94f07ab74d1e38b38f89a3675d9f3cbebb142d9c61c655f4c9eb63f1d6ec28cebeb3dc9215efd8fe7cef92725b9 + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -4904,13 +4997,6 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.5.0, cookie@npm:^0.5.0": - version: 0.5.0 - resolution: "cookie@npm:0.5.0" - checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180 - languageName: node - linkType: hard - "cookie@npm:^0.4.0": version: 0.4.2 resolution: "cookie@npm:0.4.2" @@ -4918,6 +5004,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^0.5.0": + version: 0.5.0 + resolution: "cookie@npm:0.5.0" + checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180 + languageName: node + linkType: hard + "cookie@npm:~0.6.0": version: 0.6.0 resolution: "cookie@npm:0.6.0" @@ -4978,6 +5071,13 @@ __metadata: languageName: node linkType: hard +"core-util-is@npm:1.0.2": + version: 1.0.2 + resolution: "core-util-is@npm:1.0.2" + checksum: 7a4c925b497a2c91421e25bf76d6d8190f0b2359a9200dbeed136e63b2931d6294d3b1893eda378883ed363cd950f44a12a401384c609839ea616befb7927dab + languageName: node + linkType: hard + "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -6481,6 +6581,13 @@ __metadata: languageName: node linkType: hard +"extsprintf@npm:^1.2.0": + version: 1.4.1 + resolution: "extsprintf@npm:1.4.1" + checksum: a2f29b241914a8d2bad64363de684821b6b1609d06ae68d5b539e4de6b28659715b5bea94a7265201603713b7027d35399d10b0548f09071c5513e65e8323d33 + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -7276,7 +7383,6 @@ __metadata: version: 0.0.0-use.local resolution: "homarr@workspace:." dependencies: - "@auth/drizzle-adapter": ^0.3.2 "@ctrl/deluge": ^4.1.0 "@ctrl/qbittorrent": ^6.0.0 "@ctrl/shared-torrent": ^4.1.1 @@ -7327,6 +7433,7 @@ __metadata: "@types/better-sqlite3": ^7.6.5 "@types/cookies": ^0.7.7 "@types/dockerode": ^3.3.9 + "@types/ldapjs": ^3.0.2 "@types/node": 18.17.8 "@types/prismjs": ^1.26.0 "@types/react": ^18.2.11 @@ -7370,9 +7477,8 @@ __metadata: i18next: ^22.5.1 immer: ^10.0.2 js-file-download: ^0.4.12 + ldapjs: ^3.0.5 mantine-react-table: ^1.3.4 - moment: ^2.29.4 - moment-timezone: ^0.5.43 next: 13.4.12 next-auth: ^4.23.0 next-i18next: ^14.0.0 @@ -8188,13 +8294,6 @@ __metadata: languageName: node linkType: hard -"jose@npm:^5.1.0": - version: 5.1.1 - resolution: "jose@npm:5.1.1" - checksum: 3a18d85dd1ed0e7746c67cba65a95ee972f20b363ceb99a9d75b870beb34942089cfca6249c4a50a79bc854c5a052f1be39e814c42b0f00f9358e902ce706e8d - languageName: node - linkType: hard - "js-file-download@npm:^0.4.12": version: 0.4.12 resolution: "js-file-download@npm:0.4.12" @@ -8398,6 +8497,28 @@ __metadata: languageName: node linkType: hard +"ldapjs@npm:^3.0.5": + version: 3.0.7 + resolution: "ldapjs@npm:3.0.7" + dependencies: + "@ldapjs/asn1": ^2.0.0 + "@ldapjs/attribute": ^1.0.0 + "@ldapjs/change": ^1.0.0 + "@ldapjs/controls": ^2.1.0 + "@ldapjs/dn": ^1.1.0 + "@ldapjs/filter": ^2.1.1 + "@ldapjs/messages": ^1.3.0 + "@ldapjs/protocol": ^1.2.1 + abstract-logging: ^2.0.1 + assert-plus: ^1.0.0 + backoff: ^2.5.0 + once: ^1.4.0 + vasync: ^2.2.1 + verror: ^1.10.1 + checksum: 4c0c4aeb5a0e22d0b1cba3779663472d8ebe6bc0fed5e56d6e29ac15b7f9e567e673c8764d0e51ca52eab48eef2024561a3553d6c804b11a260a893c18bd8df7 + languageName: node + linkType: hard + "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -8960,22 +9081,6 @@ __metadata: languageName: node linkType: hard -"moment-timezone@npm:^0.5.43": - version: 0.5.43 - resolution: "moment-timezone@npm:0.5.43" - dependencies: - moment: ^2.29.4 - checksum: 8075c897ed8a044f992ef26fe8cdbcad80caf974251db424cae157473cca03be2830de8c74d99341b76edae59f148c9d9d19c1c1d9363259085688ec1cf508d0 - languageName: node - linkType: hard - -"moment@npm:^2.29.4": - version: 2.29.4 - resolution: "moment@npm:2.29.4" - checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e - languageName: node - linkType: hard - "mpd-parser@npm:^1.0.1, mpd-parser@npm:^1.2.2": version: 1.2.2 resolution: "mpd-parser@npm:1.2.2" @@ -9341,13 +9446,6 @@ __metadata: languageName: node linkType: hard -"oauth4webapi@npm:^2.3.0": - version: 2.4.0 - resolution: "oauth4webapi@npm:2.4.0" - checksum: 9e6d5be3966013aa9dd61781032a6bd07a63166a9819f2fc0d622d33b23221ea39ae25334a4bde9eba4623e576972d367b196e3b5d3facff75002125c510b672 - languageName: node - linkType: hard - "oauth@npm:^0.9.15": version: 0.9.15 resolution: "oauth@npm:0.9.15" @@ -9801,17 +9899,6 @@ __metadata: languageName: node linkType: hard -"preact-render-to-string@npm:5.2.3": - version: 5.2.3 - resolution: "preact-render-to-string@npm:5.2.3" - dependencies: - pretty-format: ^3.8.0 - peerDependencies: - preact: ">=10" - checksum: 6e46288d8956adde35b9fe3a21aecd9dea29751b40f0f155dea62f3896f27cb8614d457b32f48d33909d2da81135afcca6c55077520feacd7d15164d1371fb44 - languageName: node - linkType: hard - "preact-render-to-string@npm:^5.1.19": version: 5.2.6 resolution: "preact-render-to-string@npm:5.2.6" @@ -9823,13 +9910,6 @@ __metadata: languageName: node linkType: hard -"preact@npm:10.11.3": - version: 10.11.3 - resolution: "preact@npm:10.11.3" - checksum: 9387115aa0581e8226309e6456e9856f17dfc0e3d3e63f774de80f3d462a882ba7c60914c05942cb51d51e23e120dcfe904b8d392d46f29ad15802941fe7a367 - languageName: node - linkType: hard - "preact@npm:^10.6.3": version: 10.19.2 resolution: "preact@npm:10.19.2" @@ -9859,6 +9939,13 @@ __metadata: languageName: node linkType: hard +"precond@npm:0.2": + version: 0.2.3 + resolution: "precond@npm:0.2.3" + checksum: c613e7d68af3e0b43a294a994bf067cc2bc44b03fd17bc4fb133e30617a4f5b49414b08e9b392d52d7c6822d8a71f66a7fe93a8a1e7d02240177202cff3f63ef + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -9941,6 +10028,13 @@ __metadata: languageName: node linkType: hard +"process-warning@npm:^2.1.0, process-warning@npm:^2.2.0": + version: 2.3.2 + resolution: "process-warning@npm:2.3.2" + checksum: cbeddc85d3963eccd6578b1eea5ba981383d1ec688d6e4ba5bf0ca6662d094c024b44dfcb1c530662c7694b68fe09fd95fa0269a1309090d793008f4553e7784 + languageName: node + linkType: hard + "process@npm:^0.11.10": version: 0.11.10 resolution: "process@npm:0.11.10" @@ -12520,6 +12614,37 @@ __metadata: languageName: node linkType: hard +"vasync@npm:^2.2.1": + version: 2.2.1 + resolution: "vasync@npm:2.2.1" + dependencies: + verror: 1.10.0 + checksum: dca14090436f1b30d4887737af47bc8333795a6d45e520e583ca2c4476d841bf68606cbc79071cfd980e3e42e630736d66a598b9100a505663442ae2e7c2f92f + languageName: node + linkType: hard + +"verror@npm:1.10.0": + version: 1.10.0 + resolution: "verror@npm:1.10.0" + dependencies: + assert-plus: ^1.0.0 + core-util-is: 1.0.2 + extsprintf: ^1.2.0 + checksum: c431df0bedf2088b227a4e051e0ff4ca54df2c114096b0c01e1cbaadb021c30a04d7dd5b41ab277bcd51246ca135bf931d4c4c796ecae7a4fef6d744ecef36ea + languageName: node + linkType: hard + +"verror@npm:^1.10.1": + version: 1.10.1 + resolution: "verror@npm:1.10.1" + dependencies: + assert-plus: ^1.0.0 + core-util-is: 1.0.2 + extsprintf: ^1.2.0 + checksum: 690a8d6ad5a4001672290e9719e3107c86269bc45fe19f844758eecf502e59f8aa9631b19b839f6d3dea562334884d22d1eb95ae7c863032075a9212c889e116 + languageName: node + linkType: hard + "video.js@npm:^7 || ^8, video.js@npm:^8.0.3": version: 8.6.1 resolution: "video.js@npm:8.6.1"