mirror of
				https://github.com/ajnart/homarr.git
				synced 2025-10-31 18:46:23 +01:00 
			
		
		
		
	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:
		| @@ -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'], | ||||
|   }, | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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" />} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
							
								
								
									
										60
									
								
								src/env.js
									
									
									
									
									
								
							
							
						
						
									
										60
									
								
								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, | ||||
|   | ||||
| @@ -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(); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -125,8 +125,7 @@ export default function AuthInvitePage() { | ||||
|                 withAsterisk | ||||
|                 {...form.getInputProps('password')} | ||||
|               /> | ||||
|               <Card | ||||
|               > | ||||
|               <Card> | ||||
|                 <PasswordRequirements value={form.values.password} /> | ||||
|               </Card> | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|     }, | ||||
|   }; | ||||
|   | ||||
| @@ -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' | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										166
									
								
								src/utils/auth/adapter.ts
									
									
									
									
									
										Normal 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); | ||||
							
								
								
									
										56
									
								
								src/utils/auth/credentials.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/utils/auth/credentials.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										46
									
								
								src/utils/auth/index.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										161
									
								
								src/utils/auth/ldap.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										51
									
								
								src/utils/auth/oidc.ts
									
									
									
									
									
										Normal 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; | ||||
| @@ -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 | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "es6", | ||||
|     "target": "es2017", | ||||
|     "lib": [ | ||||
|       "dom", | ||||
|       "dom.iterable", | ||||
|   | ||||
							
								
								
									
										299
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										299
									
								
								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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user