feat: signin with microsoft (#1998)

This commit is contained in:
Ephraim Duncan
2025-10-22 01:05:11 +00:00
committed by GitHub
parent 49b792503f
commit 4a3859ec60
13 changed files with 181 additions and 10 deletions

View File

@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail # https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
# Find documentation on setting up Microsoft OAuth here:
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_WELL_KNOWN="" NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID="" NEXT_PRIVATE_OIDC_CLIENT_ID=""

View File

@ -27,3 +27,33 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
``` ```
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile. Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
## Microsoft OAuth (Azure AD)
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
### Create and configure a new Azure AD application
1. Go to the [Azure Portal](https://portal.azure.com/)
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
3. In the left sidebar, click **App registrations**
4. Click **New registration**
5. Enter a name for your application (e.g., "Documenso")
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
8. Click **Register**
### Configure the application
1. After registration, you'll be taken to the app's overview page
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
3. In the left sidebar, click **Certificates & secrets**
4. Under **Client secrets**, click **New client secret**
5. Add a description and select an expiration period
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
7. In the Documenso environment variables, set the following:
```
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
```

View File

@ -70,6 +70,7 @@ export type SignInFormProps = {
className?: string; className?: string;
initialEmail?: string; initialEmail?: string;
isGoogleSSOEnabled?: boolean; isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean; isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string; oidcProviderLabel?: string;
returnTo?: string; returnTo?: string;
@ -79,6 +80,7 @@ export const SignInForm = ({
className, className,
initialEmail, initialEmail,
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
oidcProviderLabel, oidcProviderLabel,
returnTo, returnTo,
@ -95,6 +97,8 @@ export const SignInForm = ({
'totp' | 'backup' 'totp' | 'backup'
>('totp'); >('totp');
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const redirectPath = useMemo(() => { const redirectPath = useMemo(() => {
@ -271,6 +275,22 @@ export const SignInForm = ({
} }
}; };
const onSignInWithMicrosoftClick = async () => {
try {
await authClient.microsoft.signIn({
redirectPath,
});
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onSignInWithOIDCClick = async () => { const onSignInWithOIDCClick = async () => {
try { try {
await authClient.oidc.signIn({ await authClient.oidc.signIn({
@ -363,7 +383,7 @@ export const SignInForm = ({
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>} {isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button> </Button>
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && ( {hasSocialAuthEnabled && (
<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"> <span className="text-muted-foreground bg-transparent">
@ -387,6 +407,20 @@ export const SignInForm = ({
</Button> </Button>
)} )}
{isMicrosoftSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithMicrosoftClick}
>
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
Microsoft
</Button>
)}
{isOIDCSSOEnabled && ( {isOIDCSSOEnabled && (
<Button <Button
type="button" type="button"

View File

@ -66,6 +66,7 @@ export type SignUpFormProps = {
className?: string; className?: string;
initialEmail?: string; initialEmail?: string;
isGoogleSSOEnabled?: boolean; isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean; isOIDCSSOEnabled?: boolean;
}; };
@ -73,6 +74,7 @@ export const SignUpForm = ({
className, className,
initialEmail, initialEmail,
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
}: SignUpFormProps) => { }: SignUpFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
@ -84,6 +86,8 @@ export const SignUpForm = ({
const utmSrc = searchParams.get('utm_source') ?? null; const utmSrc = searchParams.get('utm_source') ?? null;
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const form = useForm<TSignUpFormSchema>({ const form = useForm<TSignUpFormSchema>({
values: { values: {
name: '', name: '',
@ -148,6 +152,20 @@ export const SignUpForm = ({
} }
}; };
const onSignUpWithMicrosoftClick = async () => {
try {
await authClient.microsoft.signIn();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onSignUpWithOIDCClick = async () => { const onSignUpWithOIDCClick = async () => {
try { try {
await authClient.oidc.signIn(); await authClient.oidc.signIn();
@ -227,7 +245,7 @@ export const SignUpForm = ({
<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 || isOIDCSSOEnabled) && 'h-[650px]', hasSocialAuthEnabled && 'h-[650px]',
)} )}
disabled={isSubmitting} disabled={isSubmitting}
> >
@ -302,7 +320,7 @@ export const SignUpForm = ({
)} )}
/> />
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && ( {hasSocialAuthEnabled && (
<> <>
<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" />
@ -330,6 +348,26 @@ export const SignUpForm = ({
</> </>
)} )}
{isMicrosoftSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
</>
)}
{isOIDCSSOEnabled && ( {isOIDCSSOEnabled && (
<> <>
<Button <Button

View File

@ -4,6 +4,7 @@ import { Link, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { import {
IS_GOOGLE_SSO_ENABLED, IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED, IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL, OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth'; } from '@documenso/lib/constants/auth';
@ -23,6 +24,7 @@ export async function loader({ request }: Route.LoaderArgs) {
// SSR env variables. // SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED; const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED; const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL; const oidcProviderLabel = OIDC_PROVIDER_LABEL;
@ -32,13 +34,15 @@ export async function loader({ request }: Route.LoaderArgs) {
return { return {
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
oidcProviderLabel, oidcProviderLabel,
}; };
} }
export default function SignIn({ loaderData }: Route.ComponentProps) { export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData; const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData;
return ( return (
<div className="w-screen max-w-lg px-4"> <div className="w-screen max-w-lg px-4">
@ -54,6 +58,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
<SignInForm <SignInForm
isGoogleSSOEnabled={isGoogleSSOEnabled} isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled} isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel} oidcProviderLabel={oidcProviderLabel}
/> />

View File

@ -1,6 +1,10 @@
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth'; import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
} from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { SignUpForm } from '~/components/forms/signup'; import { SignUpForm } from '~/components/forms/signup';
@ -17,6 +21,7 @@ export function loader() {
// SSR env variables. // SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED; const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED; const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
@ -25,17 +30,19 @@ export function loader() {
return { return {
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
}; };
} }
export default function SignUp({ loaderData }: Route.ComponentProps) { export default function SignUp({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled } = loaderData; const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
return ( return (
<SignUpForm <SignUpForm
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"
isGoogleSSOEnabled={isGoogleSSOEnabled} isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled} isOIDCSSOEnabled={isOIDCSSOEnabled}
/> />
); );

View File

@ -0,0 +1 @@
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#F1511B" d="M121.666 121.666H0V0h121.666z"/><path fill="#80CC28" d="M256 121.666H134.335V0H256z"/><path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z"/><path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z"/></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -222,6 +222,22 @@ export class AuthClient {
}, },
}; };
public microsoft = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.microsoft.$post({
json: { redirectPath },
});
await this.handleError(response);
const data = await response.json();
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
};
public oidc = { public oidc = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => { signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } }); const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });

View File

@ -26,6 +26,16 @@ export const GoogleAuthOptions: OAuthClientOptions = {
bypassEmailVerification: false, bypassEmailVerification: false,
}; };
export const MicrosoftAuthOptions: OAuthClientOptions = {
id: 'microsoft',
scope: ['openid', 'email', 'profile'],
clientId: env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/microsoft`,
wellKnownUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
bypassEmailVerification: false,
};
export const OidcAuthOptions: OAuthClientOptions = { export const OidcAuthOptions: OAuthClientOptions = {
id: 'oidc', id: 'oidc',
scope: ['openid', 'email', 'profile'], scope: ['openid', 'email', 'profile'],

View File

@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { GoogleAuthOptions, OidcAuthOptions } from '../config'; import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url'; import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url'; import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
import type { HonoAuthContext } from '../types/context'; import type { HonoAuthContext } from '../types/context';
@ -45,4 +45,11 @@ export const callbackRoute = new Hono<HonoAuthContext>()
/** /**
* Google callback verification. * Google callback verification.
*/ */
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions })); .get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }))
/**
* Microsoft callback verification.
*/
.get('/microsoft', async (c) =>
handleOAuthCallbackUrl({ c, clientOptions: MicrosoftAuthOptions }),
);

View File

@ -2,7 +2,7 @@ import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import { GoogleAuthOptions, OidcAuthOptions } from '../config'; import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url'; import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal'; import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
import type { HonoAuthContext } from '../types/context'; import type { HonoAuthContext } from '../types/context';
@ -24,6 +24,20 @@ export const oauthRoute = new Hono<HonoAuthContext>()
redirectPath, redirectPath,
}); });
}) })
/**
* Microsoft authorize endpoint.
*/
.post('/authorize/microsoft', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
return handleOAuthAuthorizeUrl({
c,
clientOptions: MicrosoftAuthOptions,
redirectPath,
});
})
/** /**
* OIDC authorize endpoint. * OIDC authorize endpoint.
*/ */

View File

@ -6,6 +6,7 @@ export const SALT_ROUNDS = 12;
export const IDENTITY_PROVIDER_NAME: Record<string, string> = { export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso', DOCUMENSO: 'Documenso',
GOOGLE: 'Google', GOOGLE: 'Google',
MICROSOFT: 'Microsoft',
OIDC: 'OIDC', OIDC: 'OIDC',
}; };
@ -13,6 +14,10 @@ export const IS_GOOGLE_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'), env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'),
); );
export const IS_MICROSOFT_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') && env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET'),
);
export const IS_OIDC_SSO_ENABLED = Boolean( export const IS_OIDC_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') && env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
env('NEXT_PRIVATE_OIDC_CLIENT_ID') && env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&

View File

@ -1,9 +1,9 @@
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react'; import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { Trans } from '@lingui/react/macro';
import { DocumentSignatureType } from '@documenso/lib/constants/document'; import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isBase64Image } from '@documenso/lib/constants/signatures'; import { isBase64Image } from '@documenso/lib/constants/signatures';