feat: add ldap and oidc support (#1497)

Co-authored-by: Thomas Camlong <49837342+ajnart@users.noreply.github.com>
Co-authored-by: Tagaishi <Tagaishi@hotmail.ch>
This commit is contained in:
Rikpat
2024-02-09 22:57:00 +01:00
committed by GitHub
parent b1ae5f700e
commit 9a8ea9e1fe
18 changed files with 923 additions and 249 deletions

View File

@@ -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'],
},

View File

@@ -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",

View File

@@ -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={<IconCheck size="1rem" />}

View File

@@ -57,9 +57,12 @@ export const StepCreateAccount = ({
Create your administrator account
</Title>
<Text>
Your administrator account <b>must be secure</b>, that's why we have so many rules surrounding it.
<br/>Try not to make it adminadmin this time...
<br/>Note: these password requirements <b>are not forced</b>, they are just recommendations.
Your administrator account <b>must be secure</b>, that's why we have so many rules
surrounding it.
<br />
Try not to make it adminadmin this time...
<br />
Note: these password requirements <b>are not forced</b>, they are just recommendations.
</Text>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -125,8 +125,7 @@ export default function AuthInvitePage() {
withAsterisk
{...form.getInputProps('password')}
/>
<Card
>
<Card>
<PasswordRequirements value={form.values.password} />
</Card>

View File

@@ -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<typeof getServerSideProps>) {
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<z.infer<typeof signInSchema>>({
const hasCredentialsInput = providers.includes('credentials') || providers.includes('ldap');
const form = useForm<z.infer<typeof signInSchemaWithProvider>>({
validateInputOnChange: true,
validateInputOnBlur: true,
validate: i18nZodResolver(signInSchema),
validate: i18nZodResolver(signInSchemaWithProvider),
});
const handleSubmit = (values: z.infer<typeof signInSchema>) => {
const handleSubmit = (values: z.infer<typeof signInSchemaWithProvider>) => {
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({
<Head>
<title>{metaTitle}</title>
</Head>
<Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center">
<FloatingBackground />
<ThemeSchemeToggle pos="absolute" top={20} right={20} />
@@ -83,51 +104,94 @@ export default function LoginPage({
<b>demodemo</b>
</Alert>
)}
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
<Title style={{ whiteSpace: 'nowrap' }} align="center" weight={900}>
{t('title')}
</Title>
{oidcAutoLogin ? (
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
<Text size="lg" align="center" m="md">
Signing in with OIDC provider
</Text>
</Card>
) : (
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
<Title style={{ whiteSpace: 'nowrap' }} align="center" weight={900}>
{t('title')}
</Title>
<Text color="dimmed" size="sm" align="center" mt={5} mb="md">
{t('text')}
</Text>
<Text color="dimmed" size="sm" align="center" mt={5} mb="md">
{t('text')}
</Text>
{isError && (
<Alert icon={<IconAlertTriangle size="1rem" />} color="red">
{t('alert')}
</Alert>
)}
{isError && (
<Alert icon={<IconAlertTriangle size="1rem" />} color="red">
{t('alert')}
</Alert>
)}
{hasCredentialsInput && (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
variant="filled"
label={t('form.fields.username.label')}
autoComplete="homarr-username"
withAsterisk
{...form.getInputProps('name')}
/>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
variant="filled"
label={t('form.fields.username.label')}
autoComplete="homarr-username"
withAsterisk
{...form.getInputProps('name')}
/>
<PasswordInput
variant="filled"
label={t('form.fields.password.label')}
autoComplete="homarr-password"
withAsterisk
{...form.getInputProps('password')}
/>
<PasswordInput
variant="filled"
label={t('form.fields.password.label')}
autoComplete="homarr-password"
withAsterisk
{...form.getInputProps('password')}
/>
{providers.includes('credentials') && (
<Button
mt="xs"
variant="light"
fullWidth
type="submit"
disabled={isLoading && form.values.provider != 'credentials'}
loading={isLoading && form.values.provider == 'credentials'}
name="credentials"
onClick={() => form.setFieldValue('provider', 'credentials')}
>
{t('form.buttons.submit')}
</Button>
)}
<Button mt="xs" variant="light" fullWidth type="submit" loading={isLoading}>
{t('form.buttons.submit')}
{providers.includes('ldap') && (
<Button
mt="xs"
variant="light"
fullWidth
type="submit"
disabled={isLoading && form.values.provider != 'ldap'}
loading={isLoading && form.values.provider == 'ldap'}
name="ldap"
onClick={() => form.setFieldValue('provider', 'ldap')}
>
{t('form.buttons.submit')} - LDAP
</Button>
)}
{redirectAfterLogin && (
<Text color="dimmed" align="center" size="xs">
{t('form.afterLoginRedirection', { url: redirectAfterLogin })}
</Text>
)}
</Stack>
</form>
)}
{hasCredentialsInput && providers.includes('oidc') && (
<Divider label="OIDC" labelPosition="center" mt="xl" mb="md" />
)}
{providers.includes('oidc') && (
<Button mt="xs" variant="light" fullWidth onClick={() => signIn('oidc')}>
{t('form.buttons.submit')} - {oidcProviderName}
</Button>
{redirectAfterLogin && (
<Text color="dimmed" align="center" size="xs">
{t('form.afterLoginRedirection', { url: redirectAfterLogin })}
</Text>
)}
</Stack>
</form>
</Card>
)}
</Card>
)}
</Stack>
</Flex>
</>
@@ -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,
},
};

View File

@@ -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'
);
};

View File

@@ -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',

166
src/utils/auth/adapter.ts Normal file
View File

@@ -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<typeof createTables>;
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<typeof BaseSQLiteDatabase>,
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);

View File

@@ -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,
};
},
});

46
src/utils/auth/index.ts Normal file
View File

@@ -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<any>)[] = [];
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);

161
src/utils/auth/ldap.ts Normal file
View File

@@ -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<ldap.SearchOptions, 'attributes'> & {
attributes?: Attributes;
arrayAttributes?: ArrayAttributes;
};
type SearchResultIndex<Attributes extends AttributeConstraint> = 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<Attributes>, SearchResultIndex<ArrayAttributes>>,
string
> &
Record<SearchResultIndex<ArrayAttributes>, string[]>;
const ldapLogin = (username: string, password: string) =>
new Promise<ldap.Client>((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<Attributes, ArrayAttributes>
) =>
new Promise<SearchResult<Attributes, ArrayAttributes>[]>((resolve, reject) => {
client.search(base, options as ldap.SearchOptions, (err, res) => {
const results: SearchResult<Attributes, ArrayAttributes>[] = [];
res.on('error', (err) => {
reject('error: ' + err.message);
});
res.on('searchEntry', (entry) => {
results.push(
entry.pojo.attributes.reduce<Record<string, string | string[]>>(
(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<Attributes, ArrayAttributes>
);
});
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;
}
},
});

51
src/utils/auth/oidc.ts Normal file
View File

@@ -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<Profile> = {
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;

View File

@@ -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
},
});

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es6",
"target": "es2017",
"lib": [
"dom",
"dom.iterable",

299
yarn.lock
View File

@@ -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"