Skip to content

Commit

Permalink
feat: add oidc support (#1103)
Browse files Browse the repository at this point in the history
## Description

This PR adds generic OIDC as an authentication provider. This allows
personal users and companies potentially to define whatever IdP they
want as long as it supports the OIDC well known format. (Azure, Zitadel,
Authentik, KeyCloak, Google, etc. all support it)

## Related Issue

Fixes #1090 

## Changes Made

- Adds OIDC buttons to the signin and registration pages
- Adds appropriate environment variables
- Adds migration to add OIDC to the `IdentityProvider` Enum

## Testing Performed

#### Zitadel
- Created application in Zitadel as an web app, with Client auth
- Enabled `User Info inside ID Token` in Token settings
- Copied client id and client secret to the new .ENV variables
- Copied the well-known URL from the URLs section to .ENV
- Created new account with OIDC provider button
- Verified email manually
- Signed into account with OIDC provider
- Logged out
- Signed into accounting again with OIDC provider

#### Authentik
- Created application in Authentik
- Copied client id and client secret to the new .ENV variables
- Copied the well-known URL from the URLs section to .ENV
- Created new account with OIDC provider button
- Verified email manually
- Signed into account with OIDC provider
- Logged out
- Signed into accounting again with OIDC provider

#### Azure AD
- Created application in Azure AD using OAuth2
- Copied client id and client secret to the new .ENV variables
- Copied the well-known URL from the URLs section to .ENV
- Created new account with OIDC provider button
- Verified email manually
- Signed into account with OIDC provider
- Logged out
- Signed into accounting again with OIDC provider
  • Loading branch information
Mythie committed May 31, 2024
2 parents 6bd2f68 + 70eeb1a commit c346a3f
Show file tree
Hide file tree
Showing 14 changed files with 179 additions and 9 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions apps/web/process-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
9 changes: 6 additions & 3 deletions apps/web/src/app/(unauthenticated)/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -37,10 +37,13 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
<p className="text-muted-foreground mt-2 text-sm">
Welcome back, we are lucky to have you.
</p>

<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' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/(unauthenticated)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}
/>
);
}
38 changes: 36 additions & 2 deletions apps/web/src/components/forms/signin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,9 +70,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();

Expand Down Expand Up @@ -257,6 +264,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 (
<Form {...form}>
<form
Expand Down Expand Up @@ -317,7 +337,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>

{(isGoogleSSOEnabled || isPasskeyEnabled) && (
{(isGoogleSSOEnabled || isPasskeyEnabled || 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 continue with</span>
Expand All @@ -339,6 +359,20 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
</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 && (
<Button
type="button"
Expand Down
43 changes: 42 additions & 1 deletion apps/web/src/components/forms/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,15 @@ export type SignUpFormProps = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
};

export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
export const SignUpForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isOIDCSSOEnabled,
}: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
Expand Down Expand Up @@ -121,6 +127,19 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
}
};

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 (
<Form {...form}>
<form
Expand Down Expand Up @@ -221,6 +240,28 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
</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>
);
Expand Down
40 changes: 38 additions & 2 deletions apps/web/src/components/forms/v2/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 (
<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">
Expand Down Expand Up @@ -255,7 +271,7 @@ export const SignUpFormV2 = ({
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
isGoogleSSOEnabled && 'h-[650px]',
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
)}
disabled={isSubmitting}
>
Expand Down Expand Up @@ -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="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
</>
)}

{isGoogleSSOEnabled && (
<>
<Button
type="button"
size="lg"
Expand All @@ -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">
Already have an account?{' '}
<Link href="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
Expand Down
7 changes: 7 additions & 0 deletions packages/lib/constants/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 24 additions & 0 deletions packages/lib/next-auth/auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,30 @@ 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' } },
checks: ['pkce', 'state'],

idToken: true,
allowDangerousEmailAccountLinking: true,

profile(profile) {
return {
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,
};
},
},
CredentialsProvider({
id: 'webauthn',
name: 'Keypass',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TYPE "IdentityProvider" ADD VALUE IF NOT EXISTS 'OIDC';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "created_at" INTEGER,
ADD COLUMN "ext_expires_in" INTEGER;
5 changes: 5 additions & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ datasource db {
enum IdentityProvider {
DOCUMENSO
GOOGLE
OIDC
}

enum Role {
Expand Down Expand Up @@ -232,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
Expand Down
4 changes: 4 additions & 0 deletions packages/tsconfig/process-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit c346a3f

Please sign in to comment.