feat: add oidc support

This commit is contained in:
Matt Kilgore
2024-04-13 20:46:08 -04:00
parent 80c758fb62
commit e0440fd8a2
12 changed files with 163 additions and 9 deletions

View File

@ -12,5 +12,9 @@ declare namespace NodeJS {
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string; NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string; NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
NEXT_PRIVATE_OIDC_WELL_KNOWN: string;
NEXT_PRIVATE_OIDC_CLIENT_ID: string;
NEXT_PRIVATE_OIDC_CLIENT_SECRET: string;
} }
} }

View File

@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env'; import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignInForm } from '~/components/forms/signin'; import { SignInForm } from '~/components/forms/signin';
@ -37,10 +37,13 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
Welcome back, we are lucky to have you. Welcome back, we are lucky to have you.
</p> </p>
<hr className="-mx-6 my-4" /> <hr className="-mx-6 my-4" />
<SignInForm initialEmail={email || undefined} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} /> <SignInForm
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
/>
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && ( {NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm"> <p className="text-muted-foreground mt-6 text-center text-sm">

View File

@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env'; import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { SignUpFormV2 } from '~/components/forms/v2/signup'; import { SignUpFormV2 } from '~/components/forms/v2/signup';
@ -37,6 +37,7 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16" className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
initialEmail={email || undefined} initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED}
/> />
); );
} }

View File

@ -10,6 +10,7 @@ import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/br
import { KeyRoundIcon } from 'lucide-react'; import { KeyRoundIcon } from 'lucide-react';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc'; import { FcGoogle } from 'react-icons/fc';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
@ -68,9 +69,15 @@ export type SignInFormProps = {
className?: string; className?: string;
initialEmail?: string; initialEmail?: string;
isGoogleSSOEnabled?: boolean; isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
}; };
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => { export const SignInForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isOIDCSSOEnabled,
}: SignInFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { getFlag } = useFeatureFlags(); const { getFlag } = useFeatureFlags();
@ -256,6 +263,19 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
} }
}; };
const onSignInWithOIDCClick = async () => {
try {
await signIn('oidc', { callbackUrl: LOGIN_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you In. Please try again later.',
variant: 'destructive',
});
}
};
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@ -316,7 +336,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
{isSubmitting ? 'Signing in...' : 'Sign In'} {isSubmitting ? 'Signing in...' : 'Sign In'}
</Button> </Button>
{(isGoogleSSOEnabled || isPasskeyEnabled) && ( {(isGoogleSSOEnabled || isPasskeyEnabled || isOIDCSSOEnabled) && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase"> <div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" /> <div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or continue with</span> <span className="text-muted-foreground bg-transparent">Or continue with</span>
@ -338,6 +358,20 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
</Button> </Button>
)} )}
{isOIDCSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
OIDC
</Button>
)}
{isPasskeyEnabled && ( {isPasskeyEnabled && (
<Button <Button
type="button" type="button"

View File

@ -52,9 +52,15 @@ export type SignUpFormProps = {
className?: string; className?: string;
initialEmail?: string; initialEmail?: string;
isGoogleSSOEnabled?: boolean; isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
}; };
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => { export const SignUpForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isOIDCSSOEnabled,
}: SignUpFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const analytics = useAnalytics(); const analytics = useAnalytics();
const router = useRouter(); const router = useRouter();
@ -121,6 +127,19 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
} }
}; };
const onSignUpWithOIDCClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@ -221,6 +240,28 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
</Button> </Button>
</> </>
)} )}
{isOIDCSSOEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with OIDC
</Button>
</>
)}
</form> </form>
</Form> </Form>
); );

View File

@ -10,6 +10,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc'; import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod'; import { z } from 'zod';
@ -73,12 +74,14 @@ export type SignUpFormV2Props = {
className?: string; className?: string;
initialEmail?: string; initialEmail?: string;
isGoogleSSOEnabled?: boolean; isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
}; };
export const SignUpFormV2 = ({ export const SignUpFormV2 = ({
className, className,
initialEmail, initialEmail,
isGoogleSSOEnabled, isGoogleSSOEnabled,
isOIDCSSOEnabled,
}: SignUpFormV2Props) => { }: SignUpFormV2Props) => {
const { toast } = useToast(); const { toast } = useToast();
const analytics = useAnalytics(); const analytics = useAnalytics();
@ -179,6 +182,19 @@ export const SignUpFormV2 = ({
} }
}; };
const onSignUpWithOIDCClick = async () => {
try {
await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return ( return (
<div className={cn('flex justify-center gap-x-12', className)}> <div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex"> <div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
@ -255,7 +271,7 @@ export const SignUpFormV2 = ({
<fieldset <fieldset
className={cn( className={cn(
'flex h-[550px] w-full flex-col gap-y-4', 'flex h-[550px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[650px]', (isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
)} )}
disabled={isSubmitting} disabled={isSubmitting}
> >
@ -323,14 +339,18 @@ export const SignUpFormV2 = ({
)} )}
/> />
{isGoogleSSOEnabled && ( {(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<> <>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase"> <div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" /> <div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span> <span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" /> <div className="bg-border h-px flex-1" />
</div> </div>
</>
)}
{isGoogleSSOEnabled && (
<>
<Button <Button
type="button" type="button"
size="lg" size="lg"
@ -345,6 +365,22 @@ export const SignUpFormV2 = ({
</> </>
)} )}
{isOIDCSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
Sign Up with OIDC
</Button>
</>
)}
<p className="text-muted-foreground mt-4 text-sm"> <p className="text-muted-foreground mt-4 text-sm">
Already have an account?{' '} Already have an account?{' '}
<Link href="/signin" className="text-documenso-700 duration-200 hover:opacity-70"> <Link href="/signin" className="text-documenso-700 duration-200 hover:opacity-70">

View File

@ -5,12 +5,19 @@ export const SALT_ROUNDS = 12;
export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = { export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
[IdentityProvider.DOCUMENSO]: 'Documenso', [IdentityProvider.DOCUMENSO]: 'Documenso',
[IdentityProvider.GOOGLE]: 'Google', [IdentityProvider.GOOGLE]: 'Google',
[IdentityProvider.OIDC]: 'OIDC',
}; };
export const IS_GOOGLE_SSO_ENABLED = Boolean( export const IS_GOOGLE_SSO_ENABLED = Boolean(
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET, process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
); );
export const IS_OIDC_SSO_ENABLED = Boolean(
process.env.NEXT_PRIVATE_OIDC_WELL_KNOWN &&
process.env.NEXT_PRIVATE_OIDC_CLIENT_ID &&
process.env.NEXT_PRIVATE_OIDC_CLIENT_SECRET,
);
export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = { export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
[UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO', [UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated', [UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',

View File

@ -136,6 +136,25 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}; };
}, },
}), }),
{
id: 'oidc',
name: 'OIDC',
wellKnown: process.env.NEXT_PRIVATE_OIDC_WELL_KNOWN,
clientId: process.env.NEXT_PRIVATE_OIDC_CLIENT_ID,
clientSecret: process.env.NEXT_PRIVATE_OIDC_CLIENT_SECRET,
authorization: { params: { scope: 'openid email profile' } },
idToken: true,
checks: ['pkce', 'state'],
type: 'oauth',
allowDangerousEmailAccountLinking: true,
profile(profile) {
return {
id: Number(profile.sub),
email: profile.email,
name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(),
};
},
},
CredentialsProvider({ CredentialsProvider({
id: 'webauthn', id: 'webauthn',
name: 'Keypass', name: 'Keypass',

View File

@ -0,0 +1 @@
ALTER TYPE "IdentityProvider" ADD VALUE IF NOT EXISTS 'OIDC';

View File

@ -11,6 +11,7 @@ datasource db {
enum IdentityProvider { enum IdentityProvider {
DOCUMENSO DOCUMENSO
GOOGLE GOOGLE
OIDC
} }
enum Role { enum Role {

View File

@ -6,6 +6,10 @@ declare namespace NodeJS {
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string; NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string; NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
NEXT_PRIVATE_OIDC_WELL_KNOWN?: string;
NEXT_PRIVATE_OIDC_CLIENT_ID?: string;
NEXT_PRIVATE_OIDC_CLIENT_SECRET?: string;
NEXT_PRIVATE_DATABASE_URL: string; NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PRIVATE_ENCRYPTION_KEY: string; NEXT_PRIVATE_ENCRYPTION_KEY: string;
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string; NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string;

View File

@ -70,6 +70,9 @@
"NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS", "NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID", "NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET", "NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PRIVATE_OIDC_WELL_KNOWN",
"NEXT_PRIVATE_OIDC_CLIENT_ID",
"NEXT_PRIVATE_OIDC_CLIENT_SECRET",
"NEXT_PUBLIC_UPLOAD_TRANSPORT", "NEXT_PUBLIC_UPLOAD_TRANSPORT",
"NEXT_PRIVATE_UPLOAD_ENDPOINT", "NEXT_PRIVATE_UPLOAD_ENDPOINT",
"NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE", "NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE",