From e0440fd8a2133b9e4591fd3ac40b73b98fb3a9c2 Mon Sep 17 00:00:00 2001 From: Matt Kilgore Date: Sat, 13 Apr 2024 20:46:08 -0400 Subject: [PATCH 1/4] feat: add oidc support --- apps/web/process-env.d.ts | 4 ++ .../src/app/(unauthenticated)/signin/page.tsx | 9 ++-- .../src/app/(unauthenticated)/signup/page.tsx | 3 +- apps/web/src/components/forms/signin.tsx | 38 +++++++++++++++- apps/web/src/components/forms/signup.tsx | 43 ++++++++++++++++++- apps/web/src/components/forms/v2/signup.tsx | 40 ++++++++++++++++- packages/lib/constants/auth.ts | 7 +++ packages/lib/next-auth/auth-options.ts | 19 ++++++++ .../migration.sql | 1 + packages/prisma/schema.prisma | 1 + packages/tsconfig/process-env.d.ts | 4 ++ turbo.json | 3 ++ 12 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 packages/prisma/migrations/20240413202001_add_oidc_auth/migration.sql diff --git a/apps/web/process-env.d.ts b/apps/web/process-env.d.ts index 0c00cb4c1..63a341060 100644 --- a/apps/web/process-env.d.ts +++ b/apps/web/process-env.d.ts @@ -12,5 +12,9 @@ declare namespace NodeJS { NEXT_PRIVATE_GOOGLE_CLIENT_ID: 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; } } diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index 21136f2e6..a0599ac1a 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; 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 { SignInForm } from '~/components/forms/signin'; @@ -37,10 +37,13 @@ export default function SignInPage({ searchParams }: SignInPageProps) {

Welcome back, we are lucky to have you.

-
- + {NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx index c7284fac6..2373af770 100644 --- a/apps/web/src/app/(unauthenticated)/signup/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx @@ -3,7 +3,7 @@ import { redirect } from 'next/navigation'; 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 { 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" initialEmail={email || undefined} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} + isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED} /> ); } diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 8d4dd7cd0..6b1e25539 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -10,6 +10,7 @@ import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/br import { KeyRoundIcon } from 'lucide-react'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; +import { FaIdCardClip } from 'react-icons/fa6'; import { FcGoogle } from 'react-icons/fc'; import { match } from 'ts-pattern'; import { z } from 'zod'; @@ -68,9 +69,15 @@ export type SignInFormProps = { className?: string; initialEmail?: string; isGoogleSSOEnabled?: boolean; + isOIDCSSOEnabled?: boolean; }; -export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => { +export const SignInForm = ({ + className, + initialEmail, + isGoogleSSOEnabled, + isOIDCSSOEnabled, +}: SignInFormProps) => { const { toast } = useToast(); 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 (

- {(isGoogleSSOEnabled || isPasskeyEnabled) && ( + {(isGoogleSSOEnabled || isPasskeyEnabled || isOIDCSSOEnabled) && (
Or continue with @@ -338,6 +358,20 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign )} + {isOIDCSSOEnabled && ( + + )} + {isPasskeyEnabled && ( + + )} ); diff --git a/apps/web/src/components/forms/v2/signup.tsx b/apps/web/src/components/forms/v2/signup.tsx index b3b502993..4c177ddef 100644 --- a/apps/web/src/components/forms/v2/signup.tsx +++ b/apps/web/src/components/forms/v2/signup.tsx @@ -10,6 +10,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; +import { FaIdCardClip } from 'react-icons/fa6'; import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; @@ -73,12 +74,14 @@ export type SignUpFormV2Props = { className?: string; initialEmail?: string; isGoogleSSOEnabled?: boolean; + isOIDCSSOEnabled?: boolean; }; export const SignUpFormV2 = ({ className, initialEmail, isGoogleSSOEnabled, + isOIDCSSOEnabled, }: SignUpFormV2Props) => { const { toast } = useToast(); 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 (
@@ -255,7 +271,7 @@ export const SignUpFormV2 = ({
@@ -323,14 +339,18 @@ export const SignUpFormV2 = ({ )} /> - {isGoogleSSOEnabled && ( + {(isGoogleSSOEnabled || isOIDCSSOEnabled) && ( <>
Or
+ + )} + {isGoogleSSOEnabled && ( + <> + + )} +

Already have an account?{' '} diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 137ebe640..4df19b407 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -5,12 +5,19 @@ export const SALT_ROUNDS = 12; export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = { [IdentityProvider.DOCUMENSO]: 'Documenso', [IdentityProvider.GOOGLE]: 'Google', + [IdentityProvider.OIDC]: 'OIDC', }; export const IS_GOOGLE_SSO_ENABLED = Boolean( 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 } = { [UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO', [UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated', diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 6805eedbe..e05fae573 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -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({ id: 'webauthn', name: 'Keypass', diff --git a/packages/prisma/migrations/20240413202001_add_oidc_auth/migration.sql b/packages/prisma/migrations/20240413202001_add_oidc_auth/migration.sql new file mode 100644 index 000000000..929ae8d97 --- /dev/null +++ b/packages/prisma/migrations/20240413202001_add_oidc_auth/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "IdentityProvider" ADD VALUE IF NOT EXISTS 'OIDC'; \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 35d429779..c707ff08b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -11,6 +11,7 @@ datasource db { enum IdentityProvider { DOCUMENSO GOOGLE + OIDC } enum Role { diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 0e05004a4..6af9c44c2 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -6,6 +6,10 @@ declare namespace NodeJS { NEXT_PRIVATE_GOOGLE_CLIENT_ID?: 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_ENCRYPTION_KEY: string; NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string; diff --git a/turbo.json b/turbo.json index fa89193eb..6accaee57 100644 --- a/turbo.json +++ b/turbo.json @@ -70,6 +70,9 @@ "NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS", "NEXT_PRIVATE_GOOGLE_CLIENT_ID", "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_PRIVATE_UPLOAD_ENDPOINT", "NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE", From bd4a1c4c098d3130c3cc0a7642083ba06bd73d05 Mon Sep 17 00:00:00 2001 From: Matt Kilgore Date: Sat, 13 Apr 2024 21:06:24 -0400 Subject: [PATCH 2/4] fix: update .env.example --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index bc052aead..30f374d67 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF" NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" +NEXT_PRIVATE_OIDC_WELL_KNOWN="" +NEXT_PRIVATE_OIDC_CLIENT_ID="" +NEXT_PRIVATE_OIDC_CLIENT_SECRET="" + # [[URLS]] NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" NEXT_PUBLIC_MARKETING_URL="http://localhost:3001" From 788c6269a28398d698a64dd82a69c05ac3db34ee Mon Sep 17 00:00:00 2001 From: Matt Kilgore Date: Sat, 13 Apr 2024 21:16:39 -0400 Subject: [PATCH 3/4] fix: signup page oidc function --- apps/web/src/components/forms/signup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 7cb037c1e..b5a9ffa5b 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -129,7 +129,7 @@ export const SignUpForm = ({ const onSignUpWithOIDCClick = async () => { try { - await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH }); + await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH }); } catch (err) { toast({ title: 'An unknown error occurred', From 70eeb1a7468c60807a655c4ed2203827ecbd0048 Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 30 May 2024 22:15:45 +1000 Subject: [PATCH 4/4] chore: improve oidc provider support Adds fields to the Account model to support various pieces of data returned by OIDC providers such as AzureAD and GitLab. Additionally passes through the email verification status and handles retrieving the email for providers such as AzureAD who use a different claim instead. --- packages/lib/next-auth/auth-options.ts | 13 +++++++++---- .../migration.sql | 3 +++ packages/prisma/schema.prisma | 4 ++++ 3 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 packages/prisma/migrations/20240530120101_add_missing_fields_to_account_model_for_oidc/migration.sql diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index e05fae573..107548e9b 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -139,19 +139,24 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { { id: 'oidc', name: 'OIDC', + type: 'oauth', + 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', + + idToken: true, allowDangerousEmailAccountLinking: true, + profile(profile) { return { - id: Number(profile.sub), - email: profile.email, + id: profile.sub, + email: profile.email || profile.preferred_username, name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(), + emailVerified: profile.email_verified ? new Date().toISOString() : null, }; }, }, diff --git a/packages/prisma/migrations/20240530120101_add_missing_fields_to_account_model_for_oidc/migration.sql b/packages/prisma/migrations/20240530120101_add_missing_fields_to_account_model_for_oidc/migration.sql new file mode 100644 index 000000000..6d7bc841a --- /dev/null +++ b/packages/prisma/migrations/20240530120101_add_missing_fields_to_account_model_for_oidc/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "created_at" INTEGER, +ADD COLUMN "ext_expires_in" INTEGER; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f9902ab35..908bb10c1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -233,6 +233,10 @@ model Account { refresh_token String? @db.Text access_token String? @db.Text expires_at Int? + // Some providers return created_at so we need to make it optional + created_at Int? + // Stops next-auth from crashing when dealing with AzureAD + ext_expires_in Int? token_type String? scope String? id_token String? @db.Text