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, 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,
});
});