feat: add organisation sso portal (#1946)

Allow organisations to manage an SSO OIDC compliant portal. This method
is intended to streamline the onboarding process and paves the way to
allow organisations to manage their members in a more strict way.
This commit is contained in:
David Nguyen
2025-09-09 17:14:07 +10:00
committed by GitHub
parent 374f2c45b4
commit 9ac7b94d9a
56 changed files with 2922 additions and 200 deletions

View File

@ -7,6 +7,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
import type { AuthAppType } from '../server';
import type { SessionValidationResult } from '../server/lib/session/session';
import type { PartialAccount } from '../server/lib/utils/get-accounts';
import type { ActiveSession } from '../server/lib/utils/get-session';
import { handleSignInRedirect } from '../server/lib/utils/redirect';
import type {
@ -96,6 +97,25 @@ export class AuthClient {
}
}
public account = {
getMany: async () => {
const response = await this.client['accounts'].$get();
await this.handleError(response);
const result = await response.json();
return superjson.deserialize<{ accounts: PartialAccount[] }>(result);
},
delete: async (accountId: string) => {
const response = await this.client['account'][':accountId'].$delete({
param: { accountId },
});
await this.handleError(response);
},
};
public emailPassword = {
signIn: async (data: Omit<TEmailPasswordSignin, 'csrfToken'> & { csrfToken?: string }) => {
let csrfToken = data.csrfToken;
@ -214,6 +234,22 @@ export class AuthClient {
window.location.href = data.redirectUrl;
}
},
org: {
signIn: async ({ orgUrl }: { orgUrl: string }) => {
const response = await this.client['oauth'].authorize.oidc.org[':orgUrl'].$post({
param: { orgUrl },
});
await this.handleError(response);
const data = await response.json();
// Redirect to external OIDC provider URL.
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
},
};
}

View File

@ -7,6 +7,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { setCsrfCookie } from './lib/session/session-cookies';
import { accountRoute } from './routes/account';
import { callbackRoute } from './routes/callback';
import { emailPasswordRoute } from './routes/email-password';
import { oauthRoute } from './routes/oauth';
@ -43,6 +44,7 @@ export const auth = new Hono<HonoAuthContext>()
})
.route('/', sessionRoute)
.route('/', signOutRoute)
.route('/', accountRoute)
.route('/callback', callbackRoute)
.route('/oauth', oauthRoute)
.route('/email-password', emailPasswordRoute)

View File

@ -0,0 +1,37 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import type { Context } from 'hono';
import { ORGANISATION_USER_ACCOUNT_TYPE } from '@documenso/lib/constants/organisations';
import { prisma } from '@documenso/prisma';
import { getSession } from './get-session';
export const deleteAccountProvider = async (c: Context, accountId: string): Promise<void> => {
const { user } = await getSession(c);
const requestMeta = c.get('requestMetadata');
await prisma.$transaction(async (tx) => {
const deletedAccountProvider = await tx.account.delete({
where: {
id: accountId,
userId: user.id,
},
select: {
type: true,
},
});
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type:
deletedAccountProvider.type === ORGANISATION_USER_ACCOUNT_TYPE
? UserSecurityAuditLogType.ORGANISATION_SSO_UNLINK
: UserSecurityAuditLogType.ACCOUNT_SSO_UNLINK,
},
});
});
};

View File

@ -0,0 +1,32 @@
import type { Context } from 'hono';
import { prisma } from '@documenso/prisma';
import { getSession } from './get-session';
export type PartialAccount = {
id: string;
userId: number;
type: string;
provider: string;
providerAccountId: string;
createdAt: Date;
};
export const getAccounts = async (c: Context | Request): Promise<PartialAccount[]> => {
const { user } = await getSession(c);
return await prisma.account.findMany({
where: {
userId: user.id,
},
select: {
id: true,
userId: true,
type: true,
provider: true,
providerAccountId: true,
createdAt: true,
},
});
};

View File

@ -20,70 +20,10 @@ type HandleOAuthCallbackUrlOptions = {
export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOptions) => {
const { c, clientOptions } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
requiredScopes: clientOptions.scope,
});
const oAuthClient = new OAuth2Client(
clientOptions.clientId,
clientOptions.clientSecret,
clientOptions.redirectUrl,
);
const requestMeta = c.get('requestMetadata');
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid or missing state',
});
}
// eslint-disable-next-line prefer-const
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
if (redirectState !== storedState || !redirectPath) {
redirectPath = '/';
}
const tokens = await oAuthClient.validateAuthorizationCode(
token_endpoint,
code,
storedCodeVerifier,
);
const accessToken = tokens.accessToken();
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
const idToken = tokens.idToken();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
const email = claims.email;
const name = claims.name;
const sub = claims.sub;
if (typeof email !== 'string' || typeof name !== 'string' || typeof sub !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid claims',
});
}
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
});
}
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken, redirectPath } =
await validateOauth({ c, clientOptions });
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
@ -199,3 +139,92 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(redirectPath, 302);
};
export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
const { c, clientOptions } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
requiredScopes: clientOptions.scope,
});
const oAuthClient = new OAuth2Client(
clientOptions.clientId,
clientOptions.clientSecret,
clientOptions.redirectUrl,
);
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid or missing state',
});
}
// eslint-disable-next-line prefer-const
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
if (redirectState !== storedState || !redirectPath) {
redirectPath = '/';
}
const tokens = await oAuthClient.validateAuthorizationCode(
token_endpoint,
code,
storedCodeVerifier,
);
const accessToken = tokens.accessToken();
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
const idToken = tokens.idToken();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
const email = claims.email;
const name = claims.name;
const sub = claims.sub;
if (typeof email !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing email',
});
}
if (typeof name !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing name',
});
}
if (typeof sub !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Missing sub claim',
});
}
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
});
}
return {
email,
name,
sub,
accessToken,
accessTokenExpiresAt,
idToken,
redirectPath,
};
};

View File

@ -0,0 +1,99 @@
import type { Context } from 'hono';
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { AppError } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
import { prisma } from '@documenso/prisma';
import { AuthenticationErrorCode } from '../errors/error-codes';
import { onAuthorize } from './authorizer';
import { validateOauth } from './handle-oauth-callback-url';
import { getOrganisationAuthenticationPortalOptions } from './organisation-portal';
type HandleOAuthOrganisationCallbackUrlOptions = {
c: Context;
orgUrl: string;
};
export const handleOAuthOrganisationCallbackUrl = async (
options: HandleOAuthOrganisationCallbackUrlOptions,
) => {
const { c, orgUrl } = options;
const { organisation, clientOptions } = await getOrganisationAuthenticationPortalOptions({
type: 'url',
organisationUrl: orgUrl,
});
const { email, name, sub, accessToken, accessTokenExpiresAt, idToken } = await validateOauth({
c,
clientOptions: {
...clientOptions,
bypassEmailVerification: true, // Bypass for organisation OIDC because we manually verify the email.
},
});
const allowedDomains = organisation.organisationAuthenticationPortal.allowedDomains;
if (allowedDomains.length > 0 && !allowedDomains.some((domain) => email.endsWith(`@${domain}`))) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Email domain not allowed',
});
}
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
where: {
provider: clientOptions.id,
providerAccountId: sub,
},
include: {
user: true,
},
});
// Directly log in user if account already exists.
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(`/o/${orgUrl}`, 302);
}
let userToLink = await prisma.user.findFirst({
where: {
email,
},
});
// Handle new user.
if (!userToLink) {
userToLink = await prisma.user.create({
data: {
email: email,
name: name,
emailVerified: null, // Do not verify email.
},
});
await onCreateUserHook(userToLink).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
});
}
await sendOrganisationAccountLinkConfirmationEmail({
type: userToLink.emailVerified ? 'link' : 'create',
userId: userToLink.id,
organisationId: organisation.id,
organisationName: organisation.name,
oauthConfig: {
accessToken,
idToken,
providerAccountId: sub,
expiresAt: Math.floor(accessTokenExpiresAt.getTime() / 1000),
},
});
return c.redirect(`${formatOrganisationLoginUrl(orgUrl)}?action=verification-required`, 302);
};

View File

@ -0,0 +1,94 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatOrganisationCallbackUrl } from '@documenso/lib/utils/organisation-authentication-portal';
import { prisma } from '@documenso/prisma';
type GetOrganisationAuthenticationPortalOptions =
| {
type: 'url';
organisationUrl: string;
}
| {
type: 'id';
organisationId: string;
};
export const getOrganisationAuthenticationPortalOptions = async (
options: GetOrganisationAuthenticationPortalOptions,
) => {
const organisation = await prisma.organisation.findFirst({
where:
options.type === 'url'
? {
url: options.organisationUrl,
}
: {
id: options.organisationId,
},
include: {
organisationClaim: true,
organisationAuthenticationPortal: true,
groups: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Billing is not enabled',
});
}
if (
!organisation.organisationClaim.flags.authenticationPortal ||
!organisation.organisationAuthenticationPortal.enabled
) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Authentication portal is not enabled for this organisation',
});
}
const {
clientId,
clientSecret: encryptedClientSecret,
wellKnownUrl,
} = organisation.organisationAuthenticationPortal;
if (!clientId || !encryptedClientSecret || !wellKnownUrl) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Authentication portal is not configured for this organisation',
});
}
if (!DOCUMENSO_ENCRYPTION_KEY) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Encryption key is not set',
});
}
const clientSecret = Buffer.from(
symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_KEY, data: encryptedClientSecret }),
).toString('utf-8');
return {
organisation,
clientId,
clientSecret,
wellKnownUrl,
clientOptions: {
id: organisation.id,
scope: ['openid', 'email', 'profile'],
clientId,
clientSecret,
redirectUrl: formatOrganisationCallbackUrl(organisation.url),
wellKnownUrl,
},
};
};

View File

@ -0,0 +1,25 @@
import { Hono } from 'hono';
import superjson from 'superjson';
import { deleteAccountProvider } from '../lib/utils/delete-account-provider';
import { getAccounts } from '../lib/utils/get-accounts';
export const accountRoute = new Hono()
/**
* Get all linked accounts.
*/
.get('/accounts', async (c) => {
const accounts = await getAccounts(c);
return c.json(superjson.serialize({ accounts }));
})
/**
* Delete an account linking method.
*/
.delete('/account/:accountId', async (c) => {
const accountId = c.req.param('accountId');
await deleteAccountProvider(c, accountId);
return c.json({ success: true });
});

View File

@ -1,7 +1,10 @@
import { Hono } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
import type { HonoAuthContext } from '../types/context';
/**
@ -14,6 +17,31 @@ export const callbackRoute = new Hono<HonoAuthContext>()
*/
.get('/oidc', async (c) => handleOAuthCallbackUrl({ c, clientOptions: OidcAuthOptions }))
/**
* Organisation OIDC callback verification.
*/
.get('/oidc/org/:orgUrl', async (c) => {
const orgUrl = c.req.param('orgUrl');
try {
return await handleOAuthOrganisationCallbackUrl({
c,
orgUrl,
});
} catch (err) {
console.error(err);
if (err instanceof Error) {
throw new AppError(err.name, {
message: err.message,
statusCode: 500,
});
}
throw err;
}
})
/**
* Google callback verification.
*/

View File

@ -16,7 +16,7 @@ import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-only/user/get-most-recent-verification-token-by-user-id';
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
@ -105,7 +105,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
}
if (!user.emailVerified) {
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
const mostRecentToken = await getMostRecentEmailVerificationToken({
userId: user.id,
});

View File

@ -4,6 +4,7 @@ import { z } from 'zod';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
import type { HonoAuthContext } from '../types/context';
const ZOAuthAuthorizeSchema = z.object({
@ -34,4 +35,20 @@ export const oauthRoute = new Hono<HonoAuthContext>()
clientOptions: OidcAuthOptions,
redirectPath,
});
})
/**
* Organisation OIDC authorize endpoint.
*/
.post('/authorize/oidc/org/:orgUrl', async (c) => {
const orgUrl = c.req.param('orgUrl');
const { clientOptions } = await getOrganisationAuthenticationPortalOptions({
type: 'url',
organisationUrl: orgUrl,
});
return await handleOAuthAuthorizeUrl({
c,
clientOptions,
});
});

View File

@ -0,0 +1,163 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { getOrganisationAuthenticationPortalOptions } from '@documenso/auth/server/lib/utils/organisation-portal';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import {
ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
export interface LinkOrganisationAccountOptions {
token: string;
requestMeta: RequestMetadata;
}
export const linkOrganisationAccount = async ({
token,
requestMeta,
}: LinkOrganisationAccountOptions) => {
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
// Delete the token since it contains unnecessary sensitive data.
const verificationToken = await prisma.verificationToken.delete({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
include: {
user: {
select: {
id: true,
emailVerified: true,
accounts: {
select: {
provider: true,
providerAccountId: true,
},
},
},
},
},
});
if (!verificationToken) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
if (verificationToken.completed) {
throw new AppError('ALREADY_USED');
}
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
const tokenMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(
verificationToken.metadata,
);
if (!tokenMetadata.success) {
console.error('Invalid token metadata', tokenMetadata.error);
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Verification token not found, used or expired',
});
}
const user = verificationToken.user;
const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({
type: 'id',
organisationId: tokenMetadata.data.organisationId,
});
const organisationMember = await prisma.organisationMember.findFirst({
where: {
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
},
});
const oauthConfig = tokenMetadata.data.oauthConfig;
const userAlreadyLinked = user.accounts.find(
(account) =>
account.provider === clientOptions.id &&
account.providerAccountId === oauthConfig.providerAccountId,
);
if (organisationMember && userAlreadyLinked) {
return;
}
await prisma.$transaction(
async (tx) => {
// Link the user if not linked yet.
if (!userAlreadyLinked) {
await tx.account.create({
data: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: clientOptions.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
id_token: oauthConfig.idToken,
userId: user.id,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in using that method since we cannot confirm the password
// was set by the user.
if (!user.emailVerified) {
await tx.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
}
// Only add the user to the organisation if they are not already a member.
if (!organisationMember) {
await addUserToOrganisation({
userId: user.id,
organisationId: tokenMetadata.data.organisationId,
organisationGroups: organisation.groups,
organisationMemberRole:
organisation.organisationAuthenticationPortal.defaultOrganisationRole,
});
}
},
{ timeout: 30_000 },
);
};

View File

@ -0,0 +1,119 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import crypto from 'crypto';
import { DateTime } from 'luxon';
import { mailer } from '@documenso/email/mailer';
import { OrganisationAccountLinkConfirmationTemplate } from '@documenso/email/templates/organisation-account-link-confirmation';
import { getI18nInstance } from '@documenso/lib/client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '@documenso/lib/constants/email';
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEmailContext } from '@documenso/lib/server-only/email/get-email-context';
import type { TOrganisationAccountLinkMetadata } from '@documenso/lib/types/organisation';
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
import { prisma } from '@documenso/prisma';
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
organisationName: string;
};
export const sendOrganisationAccountLinkConfirmationEmail = async ({
type,
userId,
organisationId,
organisationName,
oauthConfig,
}: SendOrganisationAccountLinkConfirmationEmailProps) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
include: {
verificationTokens: {
where: {
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const [previousVerificationToken] = user.verificationTokens;
// If we've sent a token in the last 5 minutes, don't send another one
if (
previousVerificationToken?.createdAt &&
DateTime.fromJSDate(previousVerificationToken.createdAt).diffNow('minutes').minutes > -5
) {
return;
}
const token = crypto.randomBytes(20).toString('hex');
const createdToken = await prisma.verificationToken.create({
data: {
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
token,
expires: DateTime.now().plus({ minutes: 30 }).toJSDate(),
metadata: {
type,
userId,
organisationId,
oauthConfig,
} satisfies TOrganisationAccountLinkMetadata,
userId,
},
});
const { emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId,
},
meta: null,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/organisation/sso/confirmation/${createdToken.token}`;
const confirmationTemplate = createElement(OrganisationAccountLinkConfirmationTemplate, {
type,
assetBaseUrl,
confirmationLink,
organisationName,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage }),
renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
return mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: DOCUMENSO_INTERNAL_EMAIL,
subject:
type === 'create'
? i18n._(msg`Account creation request`)
: i18n._(msg`Account linking request`),
html,
text,
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

View File

@ -0,0 +1,145 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Img,
Preview,
Section,
Text,
} from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
type OrganisationAccountLinkConfirmationTemplateProps = {
type: 'create' | 'link';
confirmationLink: string;
organisationName: string;
assetBaseUrl: string;
};
export const OrganisationAccountLinkConfirmationTemplate = ({
type = 'link',
confirmationLink = '<CONFIRMATION_LINK>',
organisationName = '<ORGANISATION_NAME>',
assetBaseUrl = 'http://localhost:3002',
}: OrganisationAccountLinkConfirmationTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText =
type === 'create'
? msg`A request has been made to create an account for you`
: msg`A request has been made to link your Documenso account`;
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
) : (
<TemplateImage
assetBaseUrl={assetBaseUrl}
className="mb-4 h-6 p-2"
staticAsset="logo.png"
/>
)}
<Section>
<TemplateImage
className="mx-auto h-12 w-12"
assetBaseUrl={assetBaseUrl}
staticAsset="building-2.png"
/>
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
{type === 'create' ? (
<Trans>Account creation request</Trans>
) : (
<Trans>Link your Documenso account</Trans>
)}
</Text>
<Text className="text-center text-base">
{type === 'create' ? (
<Trans>
<span className="font-bold">{organisationName}</span> has requested to create an
account on your behalf.
</Trans>
) : (
<Trans>
<span className="font-bold">{organisationName}</span> has requested to link your
current Documenso account to their organisation.
</Trans>
)}
</Text>
{/* Placeholder text if we want to have the warning in the email as well. */}
{/* <Section className="mt-6">
<Text className="my-0 text-sm">
<Trans>
By accepting this request, you will be granting{' '}
<strong>{organisationName}</strong> full access to:
</Trans>
</Text>
<ul className="mb-0 mt-2">
<li className="text-sm">
<Trans>Your account, and everything associated with it</Trans>
</li>
<li className="mt-1 text-sm">
<Trans>Something something something</Trans>
</li>
<li className="mt-1 text-sm">
<Trans>Something something something</Trans>
</li>
</ul>
<Text className="mt-2 text-sm">
<Trans>
You can unlink your account at any time in your security settings on Documenso{' '}
<Link href={`${assetBaseUrl}/settings/security/linked-accounts`}>here.</Link>
</Trans>
</Text>
</Section> */}
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={confirmationLink}
>
<Trans>Review request</Trans>
</Button>
</Section>
</Section>
<Text className="text-center text-xs text-slate-500">
<Trans>Link expires in 30 minutes.</Trans>
</Text>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default OrganisationAccountLinkConfirmationTemplate;

View File

@ -23,6 +23,9 @@ export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
ACCOUNT_SSO_LINK: 'Linked account to SSO',
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
ORGANISATION_SSO_LINK: 'Linked account to organisation',
ORGANISATION_SSO_UNLINK: 'Unlinked account from organisation',
ACCOUNT_PROFILE_UPDATE: 'Profile updated',
AUTH_2FA_DISABLE: '2FA Disabled',
AUTH_2FA_ENABLE: '2FA Enabled',

View File

@ -16,3 +16,5 @@ export const EMAIL_VERIFICATION_STATE = {
EXPIRED: 'EXPIRED',
ALREADY_VERIFIED: 'ALREADY_VERIFIED',
} as const;
export const USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER = 'confirmation-email';

View File

@ -126,3 +126,7 @@ export const PROTECTED_ORGANISATION_URLS = [
export const isOrganisationUrlProtected = (url: string) => {
return PROTECTED_ORGANISATION_URLS.some((protectedUrl) => url.startsWith(`/${protectedUrl}`));
};
export const ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER = 'organisation-account-link';
export const ORGANISATION_USER_ACCOUNT_TYPE = 'org-oidc';

View File

@ -8,7 +8,10 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { env } from '../../utils/env';
import {
DOCUMENSO_INTERNAL_EMAIL,
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
} from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendConfirmationEmailProps {
@ -16,15 +19,15 @@ export interface SendConfirmationEmailProps {
}
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
const NEXT_PRIVATE_SMTP_FROM_NAME = env('NEXT_PRIVATE_SMTP_FROM_NAME');
const NEXT_PRIVATE_SMTP_FROM_ADDRESS = env('NEXT_PRIVATE_SMTP_FROM_ADDRESS');
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
verificationTokens: {
where: {
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
@ -41,8 +44,6 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAddress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,
@ -61,10 +62,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
address: user.email,
name: user.name || '',
},
from: {
name: senderName,
address: senderAddress,
},
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(msg`Please confirm your email`),
html,
text,

View File

@ -1,3 +1,4 @@
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@ -23,11 +24,7 @@ export const acceptOrganisationInvitation = async ({
include: {
organisation: {
include: {
groups: {
include: {
teamGroups: true,
},
},
groups: true,
},
},
},
@ -45,6 +42,9 @@ export const acceptOrganisationInvitation = async ({
where: {
email: organisationMemberInvite.email,
},
select: {
id: true,
},
});
if (!user) {
@ -55,10 +55,49 @@ export const acceptOrganisationInvitation = async ({
const { organisation } = organisationMemberInvite;
const organisationGroupToUse = organisation.groups.find(
const isUserPartOfOrganisation = await prisma.organisationMember.findFirst({
where: {
userId: user.id,
organisationId: organisation.id,
},
});
if (isUserPartOfOrganisation) {
return;
}
await addUserToOrganisation({
userId: user.id,
organisationId: organisation.id,
organisationGroups: organisation.groups,
organisationMemberRole: organisationMemberInvite.organisationRole,
});
await prisma.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
});
};
export const addUserToOrganisation = async ({
userId,
organisationId,
organisationGroups,
organisationMemberRole,
}: {
userId: number;
organisationId: string;
organisationGroups: OrganisationGroup[];
organisationMemberRole: OrganisationMemberRole;
}) => {
const organisationGroupToUse = organisationGroups.find(
(group) =>
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
group.organisationRole === organisationMemberInvite.organisationRole,
group.organisationRole === organisationMemberRole,
);
if (!organisationGroupToUse) {
@ -72,8 +111,8 @@ export const acceptOrganisationInvitation = async ({
await tx.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId: user.id,
organisationId: organisation.id,
userId,
organisationId,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
@ -83,20 +122,11 @@ export const acceptOrganisationInvitation = async ({
},
});
await tx.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
});
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId: organisation.id,
memberUserId: user.id,
organisationId,
memberUserId: userId,
},
});
},

View File

@ -75,6 +75,16 @@ export const createOrganisation = async ({
},
});
const organisationAuthenticationPortal = await tx.organisationAuthenticationPortal.create({
data: {
id: generateDatabaseId('org_sso'),
enabled: false,
clientId: '',
clientSecret: '',
wellKnownUrl: '',
},
});
const orgIdAndUrl = prefixedId('org');
const organisation = await tx.organisation
@ -87,6 +97,7 @@ export const createOrganisation = async ({
ownerUserId: userId,
organisationGlobalSettingsId: organisationSetting.id,
organisationClaimId: organisationClaim.id,
organisationAuthenticationPortalId: organisationAuthenticationPortal.id,
groups: {
create: ORGANISATION_INTERNAL_GROUPS.map((group) => ({
...group,

View File

@ -1,41 +0,0 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
const IDENTIFIER = 'confirmation-email';
export const generateConfirmationToken = async ({ email }: { email: string }) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
where: {
email: email,
},
});
if (!user) {
throw new Error('User not found');
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {
connect: {
id: user.id,
},
},
},
});
if (!createdToken) {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
};

View File

@ -0,0 +1,21 @@
import { prisma } from '@documenso/prisma';
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
export type getMostRecentEmailVerificationTokenOptions = {
userId: number;
};
export const getMostRecentEmailVerificationToken = async ({
userId,
}: getMostRecentEmailVerificationTokenOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -1,18 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetMostRecentVerificationTokenByUserIdOptions = {
userId: number;
};
export const getMostRecentVerificationTokenByUserId = async ({
userId,
}: GetMostRecentVerificationTokenByUserIdOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -3,11 +3,10 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
const IDENTIFIER = 'confirmation-email';
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
type SendConfirmationTokenOptions = { email: string; force?: boolean };
@ -31,7 +30,7 @@ export const sendConfirmationToken = async ({
throw new Error('Email verified');
}
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
const mostRecentToken = await getMostRecentEmailVerificationToken({ userId: user.id });
// If we've sent a token in the last 5 minutes, don't send another one
if (
@ -44,7 +43,7 @@ export const sendConfirmationToken = async ({
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {

View File

@ -2,7 +2,10 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { EMAIL_VERIFICATION_STATE } from '../../constants/email';
import {
EMAIL_VERIFICATION_STATE,
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
} from '../../constants/email';
import { jobsClient } from '../../jobs/client';
export type VerifyEmailProps = {
@ -22,6 +25,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
},
where: {
token,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
});

View File

@ -1,4 +1,4 @@
import type { z } from 'zod';
import { z } from 'zod';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import { OrganisationSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@ -43,3 +43,19 @@ export const ZOrganisationLiteSchema = OrganisationSchema.pick({
* A version of the organisation response schema when returning multiple organisations at once from a single API endpoint.
*/
export const ZOrganisationManySchema = ZOrganisationLiteSchema;
export const ZOrganisationAccountLinkMetadataSchema = z.object({
type: z.enum(['link', 'create']),
userId: z.number(),
organisationId: z.string(),
oauthConfig: z.object({
providerAccountId: z.string(),
accessToken: z.string(),
expiresAt: z.number(),
idToken: z.string(),
}),
});
export type TOrganisationAccountLinkMetadata = z.infer<
typeof ZOrganisationAccountLinkMetadataSchema
>;

View File

@ -28,6 +28,8 @@ export const ZClaimFlagsSchema = z.object({
embedSigningWhiteLabel: z.boolean().optional(),
cfr21: z.boolean().optional(),
authenticationPortal: z.boolean().optional(),
});
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
@ -76,6 +78,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'cfr21',
label: '21 CFR',
},
authenticationPortal: {
key: 'authenticationPortal',
label: 'Authentication portal',
},
};
export enum INTERNAL_CLAIM_ID {
@ -157,6 +163,7 @@ export const internalClaims: InternalClaims = {
embedSigning: true,
embedSigningWhiteLabel: true,
cfr21: true,
authenticationPortal: true,
},
},
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {

View File

@ -16,6 +16,7 @@ type DatabaseIdPrefix =
| 'org_email'
| 'org_claim'
| 'org_group'
| 'org_sso'
| 'org_setting'
| 'member'
| 'member_invite'

View File

@ -0,0 +1,13 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const formatOrganisationLoginUrl = (organisationUrl: string) => {
return NEXT_PUBLIC_WEBAPP_URL() + formatOrganisationLoginPath(organisationUrl);
};
export const formatOrganisationLoginPath = (organisationUrl: string) => {
return `/o/${organisationUrl}/signin`;
};
export const formatOrganisationCallbackUrl = (organisationUrl: string) => {
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/oidc/org/${organisationUrl}`;
};

View File

@ -0,0 +1,75 @@
/*
Warnings:
- A unique constraint covering the columns `[organisationAuthenticationPortalId]` on the table `Organisation` will be added. If there are existing duplicate values, this will fail.
- Added the required column `organisationAuthenticationPortalId` to the `Organisation` table without a default value. This is not possible if the table is not empty.
*/
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ACCOUNT_SSO_UNLINK';
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_LINK';
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_UNLINK';
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- [CUSTOM_CHANGE] This is supposed to be NOT NULL but we reapply it at the end.
ALTER TABLE "Organisation" ADD COLUMN "organisationAuthenticationPortalId" TEXT;
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "metadata" JSONB;
-- CreateTable
CREATE TABLE "OrganisationAuthenticationPortal" (
"id" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"clientId" TEXT NOT NULL DEFAULT '',
"clientSecret" TEXT NOT NULL DEFAULT '',
"wellKnownUrl" TEXT NOT NULL DEFAULT '',
"defaultOrganisationRole" "OrganisationMemberRole" NOT NULL DEFAULT 'MEMBER',
"autoProvisionUsers" BOOLEAN NOT NULL DEFAULT true,
"allowedDomains" TEXT[] DEFAULT ARRAY[]::TEXT[],
"organisationId" TEXT, -- [CUSTOM_CHANGE] This is a temporary column for migration purposes.
CONSTRAINT "OrganisationAuthenticationPortal_pkey" PRIMARY KEY ("id")
);
-- [CUSTOM_CHANGE] Create default OrganisationAuthenticationPortal for all organisations
INSERT INTO "OrganisationAuthenticationPortal" ("id", "enabled", "clientId", "clientSecret", "wellKnownUrl", "defaultOrganisationRole", "autoProvisionUsers", "allowedDomains", "organisationId")
SELECT
generate_prefix_id('org_sso'),
false,
'',
'',
'',
'MEMBER',
true,
ARRAY[]::TEXT[],
o."id"
FROM "Organisation" o
WHERE o."organisationAuthenticationPortalId" IS NULL;
-- [CUSTOM_CHANGE] Update organisations with their corresponding organisationAuthenticationPortalId
UPDATE "Organisation" o
SET "organisationAuthenticationPortalId" = oap."id"
FROM "OrganisationAuthenticationPortal" oap
WHERE oap."organisationId" = o."id" AND o."organisationAuthenticationPortalId" IS NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Organisation_organisationAuthenticationPortalId_key" ON "Organisation"("organisationAuthenticationPortalId");
-- AddForeignKey
ALTER TABLE "Organisation" ADD CONSTRAINT "Organisation_organisationAuthenticationPortalId_fkey" FOREIGN KEY ("organisationAuthenticationPortalId") REFERENCES "OrganisationAuthenticationPortal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- [CUSTOM_CHANGE] Reapply NOT NULL constraint.
ALTER TABLE "Organisation" ALTER COLUMN "organisationAuthenticationPortalId" SET NOT NULL;
-- [CUSTOM_CHANGE] Drop temporary column.
ALTER TABLE "OrganisationAuthenticationPortal" DROP COLUMN "organisationId";

View File

@ -90,6 +90,9 @@ model TeamProfile {
enum UserSecurityAuditLogType {
ACCOUNT_PROFILE_UPDATE
ACCOUNT_SSO_LINK
ACCOUNT_SSO_UNLINK
ORGANISATION_SSO_LINK
ORGANISATION_SSO_UNLINK
AUTH_2FA_DISABLE
AUTH_2FA_ENABLE
PASSKEY_CREATED
@ -157,6 +160,7 @@ model VerificationToken {
completed Boolean @default(false)
expires DateTime
createdAt DateTime @default(now())
metadata Json?
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
@ -277,13 +281,15 @@ model OrganisationClaim {
}
model Account {
id String @id @default(cuid())
id String @id @default(cuid())
// When this record was created, unrelated to anything passed back by the provider.
createdAt DateTime @default(now())
userId Int
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
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?
@ -291,7 +297,7 @@ model Account {
ext_expires_in Int?
token_type String?
scope String?
id_token String? @db.Text
id_token String? @db.Text
session_state String?
password String?
@ -632,6 +638,9 @@ model Organisation {
organisationGlobalSettingsId String @unique
organisationGlobalSettings OrganisationGlobalSettings @relation(fields: [organisationGlobalSettingsId], references: [id])
organisationAuthenticationPortalId String @unique
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
}
model OrganisationMember {
@ -1026,3 +1035,18 @@ model OrganisationEmail {
organisationGlobalSettings OrganisationGlobalSettings[]
teamGlobalSettings TeamGlobalSettings[]
}
model OrganisationAuthenticationPortal {
id String @id
organisation Organisation?
enabled Boolean @default(false)
clientId String @default("")
clientSecret String @default("")
wellKnownUrl String @default("")
defaultOrganisationRole OrganisationMemberRole @default(MEMBER)
autoProvisionUsers Boolean @default(true)
allowedDomains String[] @default([])
}

View File

@ -1,10 +1,8 @@
import type { OrganisationMemberRole, OrganisationType } from '@prisma/client';
import { OrganisationMemberInviteStatus, type User } from '@prisma/client';
import { nanoid } from 'nanoid';
import { OrganisationGroupType, type User } from '@prisma/client';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
import { prefixedId } from '@documenso/lib/universal/id';
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
import { prisma } from '..';
import { seedTestEmail } from './users';
@ -27,6 +25,13 @@ export const seedOrganisationMembers = async ({
const createdMembers: User[] = [];
const organisationGroups = await prisma.organisationGroup.findMany({
where: {
organisationId,
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
});
for (const member of members) {
const email = member.email ?? seedTestEmail();
@ -53,33 +58,15 @@ export const seedOrganisationMembers = async ({
email: newUser.email,
organisationRole: member.organisationRole,
});
await addUserToOrganisation({
userId: newUser.id,
organisationId,
organisationGroups,
organisationMemberRole: member.organisationRole,
});
}
await prisma.organisationMemberInvite.createMany({
data: membersToInvite.map((invite) => ({
id: prefixedId('member_invite'),
email: invite.email,
organisationId,
organisationRole: invite.organisationRole,
token: nanoid(32),
})),
});
const invites = await prisma.organisationMemberInvite.findMany({
where: {
organisationId,
status: OrganisationMemberInviteStatus.PENDING,
},
});
await Promise.all(
invites.map(async (invite) => {
await acceptOrganisationInvitation({
token: invite.token,
});
}),
);
return createdMembers;
};

View File

@ -2,7 +2,7 @@ import { z } from 'zod';
import { ZEmailDomainSchema } from '@documenso/lib/types/email-domain';
const domainRegex =
export const domainRegex =
/^(?!https?:\/\/)(?!www\.)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
export const ZDomainSchema = z

View File

@ -0,0 +1,25 @@
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZDeclineLinkOrganisationAccountRequestSchema,
ZDeclineLinkOrganisationAccountResponseSchema,
} from './decline-link-organisation-account.types';
/**
* Unauthenicated procedure, do not copy paste.
*/
export const declineLinkOrganisationAccountRoute = procedure
.input(ZDeclineLinkOrganisationAccountRequestSchema)
.output(ZDeclineLinkOrganisationAccountResponseSchema)
.mutation(async ({ input }) => {
const { token } = input;
await prisma.verificationToken.delete({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
});
});

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZDeclineLinkOrganisationAccountRequestSchema = z.object({
token: z.string(),
});
export const ZDeclineLinkOrganisationAccountResponseSchema = z.void();
export type TDeclineLinkOrganisationAccountRequest = z.infer<
typeof ZDeclineLinkOrganisationAccountRequestSchema
>;

View File

@ -0,0 +1,84 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationAuthenticationPortalRequestSchema,
ZGetOrganisationAuthenticationPortalResponseSchema,
} from './get-organisation-authentication-portal.types';
export const getOrganisationAuthenticationPortalRoute = authenticatedProcedure
.input(ZGetOrganisationAuthenticationPortalRequestSchema)
.output(ZGetOrganisationAuthenticationPortalResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId } = input;
ctx.logger.info({
input: {
organisationId,
},
});
return await getOrganisationAuthenticationPortal({
userId: ctx.user.id,
organisationId,
});
});
type GetOrganisationAuthenticationPortalOptions = {
userId: number;
organisationId: string;
};
export const getOrganisationAuthenticationPortal = async ({
userId,
organisationId,
}: GetOrganisationAuthenticationPortalOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationClaim: true,
organisationAuthenticationPortal: {
select: {
defaultOrganisationRole: true,
enabled: true,
clientId: true,
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
clientSecret: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (!organisation.organisationClaim.flags.authenticationPortal) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Authentication portal not found',
});
}
const portal = organisation.organisationAuthenticationPortal;
return {
defaultOrganisationRole: portal.defaultOrganisationRole,
enabled: portal.enabled,
clientId: portal.clientId,
wellKnownUrl: portal.wellKnownUrl,
autoProvisionUsers: portal.autoProvisionUsers,
allowedDomains: portal.allowedDomains,
clientSecretProvided: Boolean(portal.clientSecret),
};
};

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
import { OrganisationAuthenticationPortalSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationAuthenticationPortalSchema';
export const ZGetOrganisationAuthenticationPortalRequestSchema = z.object({
organisationId: z.string(),
});
export const ZGetOrganisationAuthenticationPortalResponseSchema =
OrganisationAuthenticationPortalSchema.pick({
defaultOrganisationRole: true,
enabled: true,
clientId: true,
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
}).extend({
/**
* Whether we have the client secret in the database.
*
* Do not expose the actual client secret.
*/
clientSecretProvided: z.boolean(),
});
export type TGetOrganisationAuthenticationPortalResponse = z.infer<
typeof ZGetOrganisationAuthenticationPortalResponseSchema
>;

View File

@ -0,0 +1,22 @@
import { linkOrganisationAccount } from '@documenso/ee/server-only/lib/link-organisation-account';
import { procedure } from '../trpc';
import {
ZLinkOrganisationAccountRequestSchema,
ZLinkOrganisationAccountResponseSchema,
} from './link-organisation-account.types';
/**
* Unauthenicated procedure, do not copy paste.
*/
export const linkOrganisationAccountRoute = procedure
.input(ZLinkOrganisationAccountRequestSchema)
.output(ZLinkOrganisationAccountResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token } = input;
await linkOrganisationAccount({
token,
requestMeta: ctx.metadata.requestMetadata,
});
});

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const ZLinkOrganisationAccountRequestSchema = z.object({
token: z.string(),
});
export const ZLinkOrganisationAccountResponseSchema = z.void();
export type TLinkOrganisationAccountRequest = z.infer<typeof ZLinkOrganisationAccountRequestSchema>;

View File

@ -2,15 +2,19 @@ import { router } from '../trpc';
import { createOrganisationEmailRoute } from './create-organisation-email';
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
import { createSubscriptionRoute } from './create-subscription';
import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account';
import { deleteOrganisationEmailRoute } from './delete-organisation-email';
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
import { findOrganisationEmailDomainsRoute } from './find-organisation-email-domain';
import { findOrganisationEmailsRoute } from './find-organisation-emails';
import { getInvoicesRoute } from './get-invoices';
import { getOrganisationAuthenticationPortalRoute } from './get-organisation-authentication-portal';
import { getOrganisationEmailDomainRoute } from './get-organisation-email-domain';
import { getPlansRoute } from './get-plans';
import { getSubscriptionRoute } from './get-subscription';
import { linkOrganisationAccountRoute } from './link-organisation-account';
import { manageSubscriptionRoute } from './manage-subscription';
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
import { updateOrganisationEmailRoute } from './update-organisation-email';
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
@ -29,6 +33,12 @@ export const enterpriseRouter = router({
delete: deleteOrganisationEmailDomainRoute,
verify: verifyOrganisationEmailDomainRoute,
},
authenticationPortal: {
get: getOrganisationAuthenticationPortalRoute,
update: updateOrganisationAuthenticationPortalRoute,
linkAccount: linkOrganisationAccountRoute,
declineLinkAccount: declineLinkOrganisationAccountRoute,
},
},
billing: {
plans: {

View File

@ -0,0 +1,109 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationAuthenticationPortalRequestSchema,
ZUpdateOrganisationAuthenticationPortalResponseSchema,
} from './update-organisation-authentication-portal.types';
export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedure
.input(ZUpdateOrganisationAuthenticationPortalRequestSchema)
.output(ZUpdateOrganisationAuthenticationPortalResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, data } = input;
const { user } = ctx;
ctx.logger.info({
input: {
organisationId,
},
});
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationAuthenticationPortal: true,
organisationClaim: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
if (!organisation.organisationClaim.flags.authenticationPortal) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Authentication portal is not allowed for this organisation',
});
}
const {
defaultOrganisationRole,
enabled,
clientId,
clientSecret,
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
} = data;
if (
enabled &&
(!wellKnownUrl ||
!clientId ||
(!clientSecret && !organisation.organisationAuthenticationPortal.clientSecret))
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message:
'Client ID, client secret, and well known URL are required when authentication portal is enabled',
});
}
// Allow empty string to be passed in to remove the client secret from the database.
let encryptedClientSecret: string | undefined = clientSecret;
// Encrypt the secret if it is provided.
if (clientSecret) {
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
encryptedClientSecret = symmetricEncrypt({
key: encryptionKey,
data: clientSecret,
});
}
await prisma.organisationAuthenticationPortal.update({
where: {
id: organisation.organisationAuthenticationPortal.id,
},
data: {
defaultOrganisationRole,
enabled,
clientId,
clientSecret: encryptedClientSecret,
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
},
});
});

View File

@ -0,0 +1,24 @@
import { z } from 'zod';
import OrganisationMemberRoleSchema from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { domainRegex } from './create-organisation-email-domain.types';
export const ZUpdateOrganisationAuthenticationPortalRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
defaultOrganisationRole: OrganisationMemberRoleSchema,
enabled: z.boolean(),
clientId: z.string(),
clientSecret: z.string().optional(),
wellKnownUrl: z.union([z.string().url(), z.literal('')]),
autoProvisionUsers: z.boolean(),
allowedDomains: z.array(z.string().regex(domainRegex)),
}),
});
export const ZUpdateOrganisationAuthenticationPortalResponseSchema = z.void();
export type TUpdateOrganisationAuthenticationPortalRequest = z.infer<
typeof ZUpdateOrganisationAuthenticationPortalRequestSchema
>;

View File

@ -1,4 +1,7 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import {
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@ -37,9 +40,18 @@ export const deleteOrganisationRoute = authenticatedProcedure
});
}
await prisma.organisation.delete({
where: {
id: organisation.id,
},
await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({
where: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: organisation.id,
},
});
await tx.organisation.delete({
where: {
id: organisation.id,
},
});
});
});