diff --git a/packages/frontend-2/components/settings/workspaces/security/Sso.vue b/packages/frontend-2/components/settings/workspaces/security/Sso.vue index 4eba8ebd8..a34bc48f1 100644 --- a/packages/frontend-2/components/settings/workspaces/security/Sso.vue +++ b/packages/frontend-2/components/settings/workspaces/security/Sso.vue @@ -51,7 +51,9 @@ :rules="[isRequired, isUrl, isStringOfLength({ minLength: 5 })]" />
- Save + + Save +
@@ -63,6 +65,8 @@ import { useForm } from 'vee-validate' import { isRequired, isStringOfLength, isUrl } from '~~/lib/common/helpers/validation' import { graphql } from '~~/lib/common/generated/gql' import type { SettingsWorkspacesSecuritySso_WorkspaceFragment } from '~~/lib/common/generated/gql/graphql' +import { usePostAuthRedirect } from '~/lib/auth/composables/postAuthRedirect' +import { useLoginOrRegisterUtils } from '~/lib/auth/composables/auth' graphql(` fragment SettingsWorkspacesSecuritySso_Workspace on Workspace { @@ -83,6 +87,8 @@ const props = defineProps<{ }>() const apiOrigin = useApiOrigin() +const { challenge } = useLoginOrRegisterUtils() +const postAuthRedirect = usePostAuthRedirect() const { handleSubmit } = useForm() const providerName = ref('') @@ -96,10 +102,13 @@ const onSubmit = handleSubmit(() => { `providerName=${providerName.value}`, `clientId=${clientId.value}`, `clientSecret=${clientSecret.value}`, - `issuerUrl=${issuerUrl.value}` + `issuerUrl=${issuerUrl.value}`, + `challenge=${challenge.value}` ] const route = `${baseUrl}?${params.join('&')}` + postAuthRedirect.set(`/workspaces/${props.workspace.slug}?settings=server/general`) + navigateTo(route, { external: true }) diff --git a/packages/frontend-2/lib/auth/composables/auth.ts b/packages/frontend-2/lib/auth/composables/auth.ts index f6aeac7a6..91ac778e2 100644 --- a/packages/frontend-2/lib/auth/composables/auth.ts +++ b/packages/frontend-2/lib/auth/composables/auth.ts @@ -382,6 +382,24 @@ export const useAuthManager = ( goHome({ query: { access_code: accessCode } }) } + /** + * Initiate SSO flow. Will create a user if one does not already exist. + */ + const signInOrSignUpWithSso = (params: { + challenge: string + workspaceSlug: string + }) => { + postAuthRedirect.set(`/workspaces/${params.workspaceSlug}`) + + const authUrl = new URL( + `/api/v1/workspaces/${params.workspaceSlug}/sso/auth`, + apiOrigin + ) + authUrl.searchParams.set('challenge', params.challenge) + + navigateTo(authUrl.toString(), { external: true }) + } + /** * Log out */ @@ -425,6 +443,7 @@ export const useAuthManager = ( authToken, loginWithEmail, signUpWithEmail, + signInOrSignUpWithSso, logout, watchAuthQueryString, inviteToken diff --git a/packages/frontend-2/pages/workspaces/[slug]/sso/index.vue b/packages/frontend-2/pages/workspaces/[slug]/sso/index.vue new file mode 100644 index 000000000..0b7847e90 --- /dev/null +++ b/packages/frontend-2/pages/workspaces/[slug]/sso/index.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/server/modules/shared/helpers/dbHelper.ts b/packages/server/modules/shared/helpers/dbHelper.ts index a519ead05..5fa9e8659 100644 --- a/packages/server/modules/shared/helpers/dbHelper.ts +++ b/packages/server/modules/shared/helpers/dbHelper.ts @@ -101,7 +101,7 @@ export const numberOfFreeConnections = (knex: Knex) => { } export const withTransaction = async ( - callback: Promise, + callback: Promise | T, trx: Knex.Transaction ): Promise => { try { diff --git a/packages/server/modules/workspaces/clients/oidcProvider.ts b/packages/server/modules/workspaces/clients/oidcProvider.ts index 652ebf124..b9e144e37 100644 --- a/packages/server/modules/workspaces/clients/oidcProvider.ts +++ b/packages/server/modules/workspaces/clients/oidcProvider.ts @@ -1,14 +1,21 @@ /* eslint-disable camelcase */ import { BaseError } from '@/modules/shared/errors' -import { OIDCProvider, OIDCProviderAttributes } from '@/modules/workspaces/domain/sso' +import { + OidcProvider, + OidcProviderAttributes +} from '@/modules/workspaces/domain/sso/types' import { generators, Issuer, type Client } from 'openid-client' +/** + * Generate the url used to direct users to the SSO provider for authorization. + * (i.e. the sign in form page for the given SSO provider) + */ export const getProviderAuthorizationUrl = async ({ provider, redirectUrl, codeVerifier }: { - provider: OIDCProvider + provider: OidcProvider redirectUrl: URL codeVerifier: string }): Promise => { @@ -28,7 +35,7 @@ export const initializeIssuerAndClient = async ({ provider, redirectUrl }: { - provider: OIDCProvider + provider: OidcProvider redirectUrl?: URL }): Promise<{ issuer: Issuer; client: Client }> => { const issuer = await Issuer.discover(provider.issuerUrl) @@ -44,8 +51,8 @@ export const initializeIssuerAndClient = async ({ export const getOIDCProviderAttributes = async ({ provider }: { - provider: OIDCProvider -}): Promise => { + provider: OidcProvider +}): Promise => { try { const { issuer, client } = await initializeIssuerAndClient({ provider }) return { diff --git a/packages/server/modules/workspaces/domain/logic.ts b/packages/server/modules/workspaces/domain/logic.ts index 10f6abe91..0d1032aff 100644 --- a/packages/server/modules/workspaces/domain/logic.ts +++ b/packages/server/modules/workspaces/domain/logic.ts @@ -7,7 +7,7 @@ import { WorkspaceDefaultProjectRole, WorkspaceDomain } from '@/modules/workspacesCore/domain/types' -import { Roles } from '@speckle/shared' +import { Roles, WorkspaceRoles } from '@speckle/shared' export const userEmailsCompliantWithWorkspaceDomains = ({ userEmails, @@ -59,3 +59,8 @@ export const parseDefaultProjectRole = ( return role } + +export const isWorkspaceRole = (role: string): role is WorkspaceRoles => { + const validRoles: string[] = Object.values(Roles.Workspace) + return validRoles.includes(role) +} diff --git a/packages/server/modules/workspaces/domain/sso.ts b/packages/server/modules/workspaces/domain/sso.ts deleted file mode 100644 index 92c7fcec5..000000000 --- a/packages/server/modules/workspaces/domain/sso.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { z } from 'zod' - -export const oidcProvider = z.object({ - providerName: z.string().min(1), - clientId: z.string().min(5), - clientSecret: z.string().min(1), - issuerUrl: z.string().min(1).url() -}) - -export type OIDCProvider = z.infer - -type ProviderBaseRecord = { - id: string - createdAt: Date - updatedAt: Date -} - -export type OIDCProviderRecord = { - providerType: 'oidc' - provider: OIDCProvider -} & ProviderBaseRecord - -// since storage is encrypted and provider data should be stored as a json string, -// this record type could be extended to be a union for other provider types too, like SAML -export type ProviderRecord = OIDCProviderRecord - -export type StoreProviderRecord = (args: { - providerRecord: ProviderRecord -}) => Promise - -export type WorkspaceSsoProvider = { - workspaceId: string - providerId: string -} & ProviderRecord - -export type GetWorkspaceSsoProvider = (args: { - workspaceId: string -}) => Promise - -export type UserSsoSession = { - userId: string - providerId: string - createdAt: Date - lifespan: number -} - -export type StoreUserSsoSession = (args: { - userSsoSession: UserSsoSession -}) => Promise - -export const oidcProviderValidationRequest = z.object({ - token: z.string(), - provider: oidcProvider -}) -export type OIDCProviderValidationRequest = z.infer< - typeof oidcProviderValidationRequest -> - -export type OIDCProviderAttributes = { - issuer: { - claimsSupported: string[] - grantTypesSupported: string[] - responseTypesSupported: string[] - } - client: { - grantTypes: string[] - } -} - -export type GetOIDCProviderAttributes = (args: { - provider: OIDCProvider -}) => Promise - -export type StoreOIDCProviderValidationRequest = ( - args: OIDCProviderValidationRequest -) => Promise - -export type GetOIDCProviderData = (args: { - validationToken: string -}) => Promise - -export type AssociateSsoProviderWithWorkspace = (args: { - workspaceId: string - providerId: string -}) => Promise diff --git a/packages/server/modules/workspaces/domain/sso/logic.ts b/packages/server/modules/workspaces/domain/sso/logic.ts new file mode 100644 index 000000000..cd06b6ff3 --- /dev/null +++ b/packages/server/modules/workspaces/domain/sso/logic.ts @@ -0,0 +1,9 @@ +/** + * Get the default expiration time for an SSO session based on the current time. + * TODO: Is 7 days a good default session length? + */ +export const getDefaultSsoSessionExpirationDate = (): Date => { + const now = new Date() + now.setDate(now.getDate() + 7) + return now +} diff --git a/packages/server/modules/workspaces/domain/sso/models.ts b/packages/server/modules/workspaces/domain/sso/models.ts new file mode 100644 index 000000000..43e12e6f4 --- /dev/null +++ b/packages/server/modules/workspaces/domain/sso/models.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const oidcProvider = z.object({ + providerName: z.string().min(1), + clientId: z.string().min(5), + clientSecret: z.string().min(1), + issuerUrl: z.string().min(1).url() +}) diff --git a/packages/server/modules/workspaces/domain/sso/operations.ts b/packages/server/modules/workspaces/domain/sso/operations.ts new file mode 100644 index 000000000..c3415e5ea --- /dev/null +++ b/packages/server/modules/workspaces/domain/sso/operations.ts @@ -0,0 +1,43 @@ +import type { + ProviderRecord, + WorkspaceSsoProvider, + UserSsoSessionRecord, + OidcProvider, + OidcProviderAttributes, + OidcProviderValidationRequest +} from '@/modules/workspaces/domain/sso/types' + +// Workspace SSO provider management + +export type AssociateSsoProviderWithWorkspace = (args: { + workspaceId: string + providerId: string +}) => Promise + +export type GetWorkspaceSsoProvider = (args: { + workspaceId: string +}) => Promise + +export type StoreProviderRecord = (args: { + providerRecord: ProviderRecord +}) => Promise + +// User session management + +export type UpsertUserSsoSession = (args: { + userSsoSession: UserSsoSessionRecord +}) => Promise + +// OIDC validation flow + +export type GetOidcProviderAttributes = (args: { + provider: OidcProvider +}) => Promise + +export type StoreOidcProviderValidationRequest = ( + args: OidcProviderValidationRequest +) => Promise + +export type GetOidcProviderData = (args: { + validationToken: string +}) => Promise diff --git a/packages/server/modules/workspaces/domain/sso/types.ts b/packages/server/modules/workspaces/domain/sso/types.ts new file mode 100644 index 000000000..ea2909442 --- /dev/null +++ b/packages/server/modules/workspaces/domain/sso/types.ts @@ -0,0 +1,49 @@ +import { oidcProvider } from '@/modules/workspaces/domain/sso/models' +import type { infer as Infer } from 'zod' + +type ProviderBaseRecord = { + id: string + createdAt: Date + updatedAt: Date +} + +export type OidcProvider = Infer + +export type OidcProviderRecord = { + providerType: 'oidc' + provider: OidcProvider +} & ProviderBaseRecord + +// since storage is encrypted and provider data should be stored as a json string, +// this record type could be extended to be a union for other provider types too, like SAML +export type ProviderRecord = OidcProviderRecord + +export type WorkspaceSsoProvider = { + workspaceId: string + // Equals id in `ProviderRecord` (used for join) + providerId: string +} & ProviderRecord + +export type UserSsoSessionRecord = { + userId: string + providerId: string + createdAt: Date + validUntil: Date +} + +export type OidcProviderValidationRequest = { + token: string + provider: OidcProvider +} + +/** shim */ +export type OidcProviderAttributes = { + issuer: { + claimsSupported: string[] + grantTypesSupported: string[] + responseTypesSupported: string[] + } + client: { + grantTypes: string[] + } +} diff --git a/packages/server/modules/workspaces/errors/sso.ts b/packages/server/modules/workspaces/errors/sso.ts new file mode 100644 index 000000000..0e491f75e --- /dev/null +++ b/packages/server/modules/workspaces/errors/sso.ts @@ -0,0 +1,66 @@ +import { BaseError } from '@/modules/shared/errors/base' + +export class SsoVerificationCodeMissingError extends BaseError { + static defaultMessage = 'Cannot find verification token. Restart authentication flow.' + static code = 'SSO_VERIFICATION_CODE_MISSING_ERROR' +} + +export class SsoProviderTypeNotSupportedError extends BaseError { + static defaultMessage = 'SSO provider type not supported.' + static code = 'SSO_PROVIDER_TYPE_NOT_SUPPORTED' + static statusCode = 500 +} + +export class SsoProviderExistsError extends BaseError { + static defaultMessage = + 'SSO provider already configured for workspace. Delete it to reconfigure.' + static code = 'SSO_PROVIDER_EXISTS_ERROR' +} + +export class SsoProviderMissingError extends BaseError { + static defaultMessage = 'No SSO provider registered for the given workspace.' + static code = 'SSO_PROVIDER_MISSING_ERROR' +} + +export class SsoProviderProfileMissingError extends BaseError { + static defaultMessage = 'Failed to get user profile from SSO provider.' + static code = 'SSO_PROVIDER_PROFILE_MISSING_ERROR' +} + +export class SsoProviderProfileInvalidError extends BaseError { + static defaultMessage = 'SSO provider user profile is invalid.' + static code = 'SSO_PROVIDER_PROFILE_INVALID_ERROR' +} + +export class SsoGenericAuthenticationError extends BaseError { + static defaultMessage = 'Unhandled failure signing in with SSO.' + static code = 'SSO_GENERIC_AUTHENTICATION_ERROR' +} + +export class SsoGenericProviderValidationError extends BaseError { + static defaultMessage = 'Unhandled failure configuring SSo for the given workspace.' + static code = 'SSO_GENERIC_PROVIDER_VALIDATION_ERROR' +} + +export class SsoUserEmailUnverifiedError extends BaseError { + static defaultMessage = 'Cannot sign in with SSO using unverified email.' + static code = 'SSO_USER_EMAIL_UNVERIFIED_ERROR' +} + +export class SsoUserClaimedError extends BaseError { + static defaultMessage = + 'OIDC provider user already associated with another Speckle account.' + static code = 'SSO_USER_ALREADY_CLAIMED_ERROR' +} + +export class SsoUserInviteRequiredError extends BaseError { + static defaultMessage = 'Cannot sign up with SSO without a valid workspace invite.' + static code = 'SSO_USER_INVITE_REQUIRED_ERROR' + static statusCode = 400 +} + +export class OidcProviderMissingGrantTypeError extends BaseError { + static defaultMessage = 'OIDC issuer does not support authorization_code grant type' + static code = 'SSO_OIDC_PROVIDER_MISSING_GRANT_TYPE' + static statusCode = 400 +} diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 2dc5eff7d..8b6dc2e12 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -52,7 +52,6 @@ import { WorkspacesNotAuthorizedError, WorkspacesNotYetImplementedError } from '@/modules/workspaces/errors/workspace' -import { isWorkspaceRole } from '@/modules/workspaces/helpers/roles' import { deleteWorkspaceFactory as repoDeleteWorkspaceFactory, deleteWorkspaceRoleFactory as repoDeleteWorkspaceRoleFactory, @@ -131,7 +130,10 @@ import { import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' import { renderEmail } from '@/modules/emails/services/emailRendering' import { sendEmail } from '@/modules/emails/services/sending' -import { parseDefaultProjectRole } from '@/modules/workspaces/domain/logic' +import { + isWorkspaceRole, + parseDefaultProjectRole +} from '@/modules/workspaces/domain/logic' import { saveActivityFactory } from '@/modules/activitystream/repositories' import { addOrUpdateStreamCollaboratorFactory, diff --git a/packages/server/modules/workspaces/helpers/roles.ts b/packages/server/modules/workspaces/helpers/roles.ts index 6fa9c142f..7e8d5843c 100644 --- a/packages/server/modules/workspaces/helpers/roles.ts +++ b/packages/server/modules/workspaces/helpers/roles.ts @@ -26,7 +26,3 @@ export const mapGqlWorkspaceRoleToMainRole = ( return Roles.Workspace.Guest } } - -export const isWorkspaceRole = (role: string): role is WorkspaceRoles => { - return (Object.values(Roles.Workspace) as string[]).includes(role) -} diff --git a/packages/server/modules/workspaces/helpers/sso.ts b/packages/server/modules/workspaces/helpers/sso.ts new file mode 100644 index 000000000..ca60f9bb1 --- /dev/null +++ b/packages/server/modules/workspaces/helpers/sso.ts @@ -0,0 +1,68 @@ +import { getEncryptionKeyPair } from '@/modules/automate/services/encryption' +import { getFrontendOrigin, getServerOrigin } from '@/modules/shared/helpers/envHelper' +import { buildDecryptor, buildEncryptor } from '@/modules/shared/utils/libsodium' +import { SsoVerificationCodeMissingError } from '@/modules/workspaces/errors/sso' +import { Request } from 'express' + +/** + * Generate Speckle URL to redirect users to after they complete authorization + * with the given SSO provider. + */ +export const buildAuthRedirectUrl = ( + workspaceSlug: string, + isValidationFlow: boolean +): URL => { + const urlFragments = [`/api/v1/workspaces/${workspaceSlug}/sso/oidc/callback`] + + if (isValidationFlow) { + urlFragments.push('?validate=true') + } + + return new URL(urlFragments.join(''), getServerOrigin()) +} + +/** + * Generate Speckle URL to redirect users to after successfully completing the + * SSO authorization flow. + * @remarks Append params to this URL to preserve information about errors + */ +export const buildFinalizeUrl = (workspaceSlug: string): URL => { + return new URL(`workspaces/${workspaceSlug}/sso`, getFrontendOrigin()) +} + +/** + * Generate Speckle URL to redirect users to after an error occurs during SSO. + */ +export const buildErrorUrl = (err: unknown, workspaceSlug: string) => { + const errorRedirectUrl = buildFinalizeUrl(workspaceSlug) + const errorMessage = err instanceof Error ? err.message : `Unknown error: ${err}` + errorRedirectUrl.searchParams.set('error', errorMessage) + return errorRedirectUrl.toString() +} + +export const getEncryptor = () => async (data: string) => { + const encryptionKeyPair = await getEncryptionKeyPair() + const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) + const encryptedData = await encryptor.encrypt(data) + + encryptor.dispose() + + return encryptedData +} + +export const getDecryptor = () => async (data: string) => { + const encryptionKeyPair = await getEncryptionKeyPair() + const decryptor = await buildDecryptor(encryptionKeyPair) + const decryptedData = await decryptor.decrypt(data) + + decryptor.dispose() + + return decryptedData +} + +export const parseCodeVerifier = async (req: Request): Promise => { + const encryptedCodeVerifier = req.session.codeVerifier + if (!encryptedCodeVerifier) throw new SsoVerificationCodeMissingError() + const codeVerifier = await getDecryptor()(encryptedCodeVerifier) + return codeVerifier +} diff --git a/packages/server/modules/workspaces/index.ts b/packages/server/modules/workspaces/index.ts index de561b7ce..38f456bf0 100644 --- a/packages/server/modules/workspaces/index.ts +++ b/packages/server/modules/workspaces/index.ts @@ -8,7 +8,7 @@ import { workspaceScopes } from '@/modules/workspaces/scopes' import { registerOrUpdateRole } from '@/modules/shared/repositories/roles' import { initializeEventListenersFactory } from '@/modules/workspaces/events/eventListener' import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense' -import ssoRouter from '@/modules/workspaces/rest/sso' +import { getSsoRouter } from '@/modules/workspaces/rest/sso' const { FF_WORKSPACES_MODULE_ENABLED, FF_WORKSPACES_SSO_ENABLED } = getFeatureFlags() @@ -37,7 +37,7 @@ const workspacesModule: SpeckleModule = { ) moduleLogger.info('⚒️ Init workspaces module') - if (FF_WORKSPACES_SSO_ENABLED) app.use(ssoRouter) + if (FF_WORKSPACES_SSO_ENABLED) app.use(getSsoRouter()) if (isInitial) { // register the SSO endpoints diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index f9b06dc32..0aa46b30c 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -1,30 +1,33 @@ +import { oidcProvider } from '@/modules/workspaces/domain/sso/models' import { - oidcProvider, - GetOIDCProviderData, - StoreOIDCProviderValidationRequest, - StoreProviderRecord, - ProviderRecord, AssociateSsoProviderWithWorkspace, - StoreUserSsoSession, - UserSsoSession, - GetWorkspaceSsoProvider -} from '@/modules/workspaces/domain/sso' + GetOidcProviderData, + GetWorkspaceSsoProvider, + StoreOidcProviderValidationRequest, + StoreProviderRecord, + UpsertUserSsoSession +} from '@/modules/workspaces/domain/sso/operations' +import { + ProviderRecord, + UserSsoSessionRecord +} from '@/modules/workspaces/domain/sso/types' +import { SsoProviderTypeNotSupportedError } from '@/modules/workspaces/errors/sso' import Redis from 'ioredis' import { Knex } from 'knex' import { omit } from 'lodash' type Crypt = (input: string) => Promise -type StoredSsoProvider = Omit & { +type SsoProviderRecord = Omit & { encryptedProviderData: string } -type WorkspaceSsoProvider = { workspaceId: string; providerId: string } +type WorkspaceSsoProviderRecord = { workspaceId: string; providerId: string } const tables = { - ssoProviders: (db: Knex) => db('sso_providers'), - userSsoSessions: (db: Knex) => db('user_sso_sessions'), + ssoProviders: (db: Knex) => db('sso_providers'), + userSsoSessions: (db: Knex) => db('user_sso_sessions'), workspaceSsoProviders: (db: Knex) => - db('workspace_sso_providers') + db('workspace_sso_providers') } export const storeOIDCProviderValidationRequestFactory = @@ -34,20 +37,19 @@ export const storeOIDCProviderValidationRequestFactory = }: { redis: Redis encrypt: Crypt - }): StoreOIDCProviderValidationRequest => + }): StoreOidcProviderValidationRequest => async ({ provider, token }) => { const providerData = await encrypt(JSON.stringify(provider)) await redis.set(token, providerData) } -export const getOIDCProviderFactory = - ({ redis, decrypt }: { redis: Redis; decrypt: Crypt }): GetOIDCProviderData => +export const getOIDCProviderValidationRequestFactory = + ({ redis, decrypt }: { redis: Redis; decrypt: Crypt }): GetOidcProviderData => async ({ validationToken }: { validationToken: string }) => { const encryptedProviderData = await redis.get(validationToken) if (!encryptedProviderData) return null - const provider = oidcProvider.parse( - JSON.parse(await decrypt(encryptedProviderData)) - ) + const providerDataString = await decrypt(encryptedProviderData) + const provider = oidcProvider.parse(JSON.parse(providerDataString)) return provider } @@ -56,9 +58,9 @@ export const getWorkspaceSsoProviderFactory = async ({ workspaceId }) => { const maybeProvider = await tables .workspaceSsoProviders(db) - .select('*') + .select('*') .where({ workspaceId }) - .join('sso_providers', 'id', 'providerId') + .join('sso_providers', 'id', 'providerId') .first() if (!maybeProvider) return null @@ -72,12 +74,11 @@ export const getWorkspaceSsoProviderFactory = provider: oidcProvider.parse(providerData) } default: - // this is an internal error - throw new Error('Provider type not supported') + throw new SsoProviderTypeNotSupportedError() } } -export const storeProviderRecordFactory = +export const storeSsoProviderRecordFactory = ({ db, encrypt }: { db: Knex; encrypt: Crypt }): StoreProviderRecord => async ({ providerRecord }) => { const encryptedProviderData = await encrypt(JSON.stringify(providerRecord.provider)) @@ -91,9 +92,12 @@ export const associateSsoProviderWithWorkspaceFactory = await tables.workspaceSsoProviders(db).insert({ providerId, workspaceId }) } -// this should be an upsert, if the session exists, we just update the createdAt and lifespan -export const storeUserSsoSessionFactory = - ({ db }: { db: Knex }): StoreUserSsoSession => +export const upsertUserSsoSessionFactory = + ({ db }: { db: Knex }): UpsertUserSsoSession => async ({ userSsoSession }) => { - await tables.userSsoSessions(db).insert(userSsoSession) + await tables + .userSsoSessions(db) + .insert(userSsoSession) + .onConflict(['userId', 'providerId']) + .merge(['createdAt', 'validUntil']) } diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index c0bb11b3d..a6b67a268 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -1,282 +1,615 @@ +/* eslint-disable camelcase */ + import { db } from '@/db/knex' import { validateRequest } from 'zod-express' -import { Router } from 'express' +import { Request, RequestHandler, Router } from 'express' import { z } from 'zod' import { + createWorkspaceUserFromSsoProfileFactory, + linkUserWithSsoProviderFactory, saveSsoProviderRegistrationFactory, - startOIDCSsoProviderValidationFactory + startOidcSsoProviderValidationFactory } from '@/modules/workspaces/services/sso' import { getOIDCProviderAttributes, getProviderAuthorizationUrl, initializeIssuerAndClient } from '@/modules/workspaces/clients/oidcProvider' -import { getFrontendOrigin, getServerOrigin } from '@/modules/shared/helpers/envHelper' +import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' import { storeOIDCProviderValidationRequestFactory, - getOIDCProviderFactory, + getOIDCProviderValidationRequestFactory, associateSsoProviderWithWorkspaceFactory, - storeProviderRecordFactory, - storeUserSsoSessionFactory, + storeSsoProviderRecordFactory, + upsertUserSsoSessionFactory, getWorkspaceSsoProviderFactory } from '@/modules/workspaces/repositories/sso' -import { buildDecryptor, buildEncryptor } from '@/modules/shared/utils/libsodium' -import { getEncryptionKeyPair } from '@/modules/automate/services/encryption' import { getGenericRedis } from '@/modules/core' -import { generators } from 'openid-client' -import { noop } from 'lodash' -import { OIDCProvider, oidcProvider } from '@/modules/workspaces/domain/sso' -import { getWorkspaceBySlugFactory } from '@/modules/workspaces/repositories/workspaces' -import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { generators, UserinfoResponse } from 'openid-client' +import { oidcProvider } from '@/modules/workspaces/domain/sso/models' +import { + OidcProvider, + WorkspaceSsoProvider +} from '@/modules/workspaces/domain/sso/types' +import { + getWorkspaceBySlugFactory, + upsertWorkspaceRoleFactory +} from '@/modules/workspaces/repositories/workspaces' +import { + WorkspaceNotFoundError, + WorkspacesNotAuthorizedError +} from '@/modules/workspaces/errors/workspace' import { authorizeResolver } from '@/modules/shared' import { Roles } from '@speckle/shared' -import { createUserEmailFactory } from '@/modules/core/repositories/userEmails' +import { + createUserEmailFactory, + ensureNoPrimaryEmailForUserFactory, + findEmailFactory, + findEmailsByUserIdFactory, + updateUserEmailFactory +} from '@/modules/core/repositories/userEmails' +import { withTransaction } from '@/modules/shared/helpers/dbHelper' +import { + countAdminUsersFactory, + getUserFactory, + legacyGetUserFactory, + storeUserAclFactory, + storeUserFactory, + UserWithOptionalRole +} from '@/modules/core/repositories/users' import { finalizeAuthMiddlewareFactory, + moveAuthParamsToSessionMiddlewareFactory, sessionMiddlewareFactory } from '@/modules/auth/middleware' +import { + deleteInviteFactory, + deleteServerOnlyInvitesFactory, + findInviteFactory, + updateAllInviteTargetsFactory +} from '@/modules/serverinvites/repositories/serverInvites' +import { createUserFactory } from '@/modules/core/services/users/management' +import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' +import { UsersEmitter } from '@/modules/core/events/usersEmitter' +import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing' +import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request' +import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories' +import { sendEmail } from '@/modules/emails/services/sending' +import { renderEmail } from '@/modules/emails/services/emailRendering' import { createAuthorizationCodeFactory } from '@/modules/auth/repositories/apps' -import { legacyGetUserFactory } from '@/modules/core/repositories/users' - -const router = Router() +import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic' +import { GetWorkspaceBySlug } from '@/modules/workspaces/domain/operations' +import { + GetWorkspaceSsoProvider, + UpsertUserSsoSession +} from '@/modules/workspaces/domain/sso/operations' +import { GetUser } from '@/modules/core/domain/users/operations' +import { FindEmail } from '@/modules/core/domain/userEmails/operations' +import { AuthorizeResolver } from '@/modules/shared/domain/operations' +import { authorizeResolverFactory } from '@/modules/shared/services/auth' +import { getRolesFactory } from '@/modules/shared/repositories/roles' +import { + getUserAclRoleFactory, + getUserServerRoleFactory +} from '@/modules/shared/repositories/acl' +import { getStreamFactory } from '@/modules/core/repositories/streams' +import { + buildAuthRedirectUrl, + buildErrorUrl, + buildFinalizeUrl, + getDecryptor, + getEncryptor, + parseCodeVerifier +} from '@/modules/workspaces/helpers/sso' +import { + SsoGenericAuthenticationError, + SsoGenericProviderValidationError, + SsoProviderMissingError, + SsoProviderProfileMissingError, + SsoUserClaimedError, + SsoUserEmailUnverifiedError, + SsoVerificationCodeMissingError +} from '@/modules/workspaces/errors/sso' +const moveAuthParamsToSessionMiddleware = moveAuthParamsToSessionMiddlewareFactory() const sessionMiddleware = sessionMiddlewareFactory() const finalizeAuthMiddleware = finalizeAuthMiddlewareFactory({ createAuthorizationCode: createAuthorizationCodeFactory({ db }), getUser: legacyGetUserFactory({ db }) }) -const buildAuthRedirectUrl = (workspaceSlug: string): URL => - new URL( - `/api/v1/workspaces/${workspaceSlug}/sso/oidc/callback?validate=true`, - getServerOrigin() +export const getSsoRouter = (): Router => { + const router = Router() + + router.get( + '/api/v1/workspaces/:workspaceSlug/sso', + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }) + }), + handleGetLimitedWorkspaceRequestFactory({ + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }), + getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ + db, + decrypt: getDecryptor() + }) + }) ) -const buildFinalizeUrl = (workspaceSlug: string): URL => - new URL(`workspaces/${workspaceSlug}/?settings=server/general`, getFrontendOrigin()) + router.get( + '/api/v1/workspaces/:workspaceSlug/sso/auth', + sessionMiddleware, + moveAuthParamsToSessionMiddleware, + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }) + }), + handleSsoAuthRequestFactory({ + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }), + getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ + db, + decrypt: getDecryptor() + }) + }) + ) -const ssoVerificationStatusKey = 'ssoVerificationStatus' + router.get( + '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', + sessionMiddleware, + moveAuthParamsToSessionMiddleware, + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }), + query: oidcProvider + }), + handleSsoValidationRequestFactory({ + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }), + startOidcSsoProviderValidation: startOidcSsoProviderValidationFactory({ + getOidcProviderAttributes: getOIDCProviderAttributes, + storeOidcProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ + redis: getGenericRedis(), + encrypt: getEncryptor() + }), + generateCodeVerifier: generators.codeVerifier + }) + }) + ) -const buildErrorUrl = ({ - err, - url, - searchParams -}: { - err: unknown - url: URL - searchParams?: Record -}): URL => { - const settingsSearch = url.searchParams.get('settings') - url.searchParams.forEach((key) => { - url.searchParams.delete(key) - }) - if (settingsSearch) url.searchParams.set('settings', settingsSearch) - url.searchParams.set(ssoVerificationStatusKey, 'failed') - const errorMessage = err instanceof Error ? err.message : `Unknown error ${err}` - url.searchParams.set('ssoVerificationError', errorMessage) - if (searchParams) { - for (const [name, value] of Object.values(searchParams)) { - url.searchParams.set(name, value) - } - } - return url + router.get( + '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', + sessionMiddleware, + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }), + query: oidcCallbackRequestQuery + }), + async (req, res, next) => { + const trx = await db.transaction() + const handleOidcCallback = handleOidcCallbackFactory({ + authorizeResolver: authorizeResolverFactory({ + adminOverrideEnabled, + getRoles: getRolesFactory({ db: trx }), + getUserServerRole: getUserServerRoleFactory({ db: trx }), + getStream: getStreamFactory({ db: trx }), + getUserAclRole: getUserAclRoleFactory({ db: trx }) + }), + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db: trx }), + createOidcProvider: createOidcProviderFactory({ + getOIDCProviderValidationRequest: getOIDCProviderValidationRequestFactory({ + redis: getGenericRedis(), + decrypt: getDecryptor() + }), + saveSsoProviderRegistration: saveSsoProviderRegistrationFactory({ + getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ + db: trx, + decrypt: getDecryptor() + }), + storeProviderRecord: storeSsoProviderRecordFactory({ + db: trx, + encrypt: getEncryptor() + }), + associateSsoProviderWithWorkspace: associateSsoProviderWithWorkspaceFactory( + { + db: trx + } + ) + }) + }), + getOidcProvider: getOidcProviderFactory({ + getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ + db: trx, + decrypt: getDecryptor() + }) + }), + getOidcProviderUserData: getOidcProviderUserDataFactory(), + tryGetSpeckleUserData: tryGetSpeckleUserDataFactory({ + findEmail: findEmailFactory({ db: trx }), + getUser: getUserFactory({ db: trx }) + }), + createWorkspaceUserFromSsoProfile: createWorkspaceUserFromSsoProfileFactory({ + createUser: createUserFactory({ + getServerInfo: getServerInfoFactory({ db: trx }), + findEmail: findEmailFactory({ db: trx }), + storeUser: storeUserFactory({ db: trx }), + countAdminUsers: countAdminUsersFactory({ db: trx }), + storeUserAcl: storeUserAclFactory({ db: trx }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db: trx }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ + db: trx + }), + findEmail: findEmailFactory({ db: trx }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db: trx }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db: trx }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db: trx }), + getUser: getUserFactory({ db: trx }), + getServerInfo: getServerInfoFactory({ db: trx }), + deleteOldAndInsertNewVerification: + deleteOldAndInsertNewVerificationFactory({ db: trx }), + renderEmail, + sendEmail + }) + }), + usersEventsEmitter: UsersEmitter.emit + }), + upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db: trx }), + findInvite: findInviteFactory({ db: trx }), + deleteInvite: deleteInviteFactory({ db: trx }) + }), + linkUserWithSsoProvider: linkUserWithSsoProviderFactory({ + findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }), + createUserEmail: createUserEmailFactory({ db: trx }), + updateUserEmail: updateUserEmailFactory({ db: trx }) + }), + upsertUserSsoSession: upsertUserSsoSessionFactory({ db: trx }) + }) + + try { + await withTransaction(handleOidcCallback(req, res, next), trx) + return next() + } catch (e) { + res?.redirect(buildErrorUrl(e, req.params.workspaceSlug)) + } + }, + finalizeAuthMiddleware + ) + + return router } -router.get( - '/api/v1/workspaces/:workspaceSlug/sso', - validateRequest({ - params: z.object({ - workspaceSlug: z.string().min(1) - }) - }), +const workspaceSsoAuthRequestParams = z.object({ + workspaceSlug: z.string().min(1) +}) + +type WorkspaceSsoAuthRequestParams = z.infer + +/** + * Fetch public information about the workspace, including SSO provider metadata + */ +const handleGetLimitedWorkspaceRequestFactory = + ({ + getWorkspaceBySlug, + getWorkspaceSsoProvider + }: { + getWorkspaceBySlug: GetWorkspaceBySlug + getWorkspaceSsoProvider: GetWorkspaceSsoProvider + }): RequestHandler => async ({ params, res }) => { - const { workspaceSlug } = params + const workspace = await getWorkspaceBySlug({ workspaceSlug: params.workspaceSlug }) + if (!workspace) throw new WorkspaceNotFoundError() - const workspace = await getWorkspaceBySlugFactory({ db })({ - workspaceSlug - }) - - if (!workspace) { - throw new Error() - } - - const encryptionKeyPair = await getEncryptionKeyPair() - const { decrypt, dispose } = await buildDecryptor(encryptionKeyPair) - - const providerData = await getWorkspaceSsoProviderFactory({ db, decrypt })({ - workspaceId: workspace.id - }) + const ssoProviderData = await getWorkspaceSsoProvider({ workspaceId: workspace.id }) const limitedWorkspace = { name: workspace.name, logo: workspace.logo, defaultLogoIndex: workspace.defaultLogoIndex, - ssoProviderName: providerData?.provider?.providerName + ssoProviderName: ssoProviderData?.provider?.providerName } - dispose() res?.json(limitedWorkspace) } -) -router.get( - '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', - sessionMiddleware, - validateRequest({ - params: z.object({ - workspaceSlug: z.string().min(1) - }), - query: oidcProvider - }), - async ({ session, params, query, res }) => { +/** + * Start SSO sign-in or sign-up flow + */ +const handleSsoAuthRequestFactory = + ({ + getWorkspaceBySlug, + getWorkspaceSsoProvider + }: { + getWorkspaceBySlug: GetWorkspaceBySlug + getWorkspaceSsoProvider: GetWorkspaceSsoProvider + }): RequestHandler => + async ({ params, session, res }) => { try { - const provider = query - const encryptionKeyPair = await getEncryptionKeyPair() - const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - const codeVerifier = await startOIDCSsoProviderValidationFactory({ - getOIDCProviderAttributes, - storeOIDCProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ - redis: getGenericRedis(), - encrypt: encryptor.encrypt - }), - generateCodeVerifier: generators.codeVerifier - })({ - provider + const workspace = await getWorkspaceBySlug({ + workspaceSlug: params.workspaceSlug }) - const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug) + if (!workspace) throw new WorkspaceNotFoundError() + + const { provider } = + (await getWorkspaceSsoProvider({ workspaceId: workspace.id })) ?? {} + if (!provider) throw new SsoProviderMissingError() + + const codeVerifier = generators.codeVerifier() + const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, false) const authorizationUrl = await getProviderAuthorizationUrl({ provider, redirectUrl, codeVerifier }) - session.codeVerifier = await encryptor.encrypt(codeVerifier) - // maybe not needed - encryptor.dispose() + session.codeVerifier = await getEncryptor()(codeVerifier) res?.redirect(authorizationUrl.toString()) - } catch (err) { - session.destroy(noop) - const url = buildErrorUrl({ - err, - url: buildFinalizeUrl(params.workspaceSlug), - searchParams: query - }) - res?.redirect(url.toString()) + } catch (e) { + res?.redirect(buildErrorUrl(e, params.workspaceSlug)) } } -) -router.get( - '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', - sessionMiddleware, - validateRequest({ - params: z.object({ - workspaceSlug: z.string().min(1) - }), - query: z.object({ validate: z.string() }) - }), - async (req) => { - // this is the verify flow, login will be different - // req.context.userId can be authorized for the workspaceSlug if needed - const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) +type WorkspaceSsoValidationRequestQuery = z.infer - let provider: OIDCProvider | null = null - - if (req.query.validate === 'true') { - const workspace = await getWorkspaceBySlugFactory({ db })({ - workspaceSlug: req.params.workspaceSlug +/** + * Begin SSO configuration flow + */ +const handleSsoValidationRequestFactory = + ({ + getWorkspaceBySlug, + startOidcSsoProviderValidation + }: { + getWorkspaceBySlug: GetWorkspaceBySlug + startOidcSsoProviderValidation: ReturnType< + typeof startOidcSsoProviderValidationFactory + > + }): RequestHandler< + WorkspaceSsoAuthRequestParams, + never, + never, + WorkspaceSsoValidationRequestQuery + > => + async ({ session, params, query: provider, res, context }) => { + try { + const workspace = await getWorkspaceBySlug({ + workspaceSlug: params.workspaceSlug }) if (!workspace) throw new WorkspaceNotFoundError() + await authorizeResolver( - req.context.userId, + context.userId, workspace.id, Roles.Workspace.Admin, - req.context.resourceAccessRules + context.resourceAccessRules ) - // once we're authorized for the ws, we must have a userId - const userId = req.context.userId! - // point to the finalize page if there is one - let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug) - try { - const encryptionKeyPair = await getEncryptionKeyPair() - const decryptor = await buildDecryptor(encryptionKeyPair) + const codeVerifier = await startOidcSsoProviderValidation({ provider }) - // =================== - const encryptedValidationToken = req.session.codeVerifier - if (!encryptedValidationToken) - throw new Error('cannot find verification token, restart the flow') + const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, true) + const authorizationUrl = await getProviderAuthorizationUrl({ + provider, + redirectUrl, + codeVerifier + }) - const codeVerifier = await decryptor.decrypt(encryptedValidationToken) + session.codeVerifier = await getEncryptor()(codeVerifier) - provider = await getOIDCProviderFactory({ - redis: getGenericRedis(), - decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt - })({ - validationToken: codeVerifier - }) - if (!provider) throw new Error('validation request not found, please retry') - - const { client } = await initializeIssuerAndClient({ provider }) - const callbackParams = client.callbackParams(req) - const tokenSet = await client.callback( - buildAuthRedirectUrl(req.params.workspaceSlug).toString(), - callbackParams, - // eslint-disable-next-line camelcase - { code_verifier: codeVerifier } - ) - - // now that we have the user's email, we should compare it to the active user's email. - // Ask if they want to add the email to the oidc as a secondary email, if it doesn't match any of the user's emails - const ssoProviderUserInfo = await client.userinfo(tokenSet) - if (!ssoProviderUserInfo.email) - throw new Error('This should never happen, we are asking for an email claim') - - const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - const trx = await db.transaction() - - await saveSsoProviderRegistrationFactory({ - getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ - db: trx, - decrypt: decryptor.decrypt - }), - associateSsoProviderWithWorkspace: associateSsoProviderWithWorkspaceFactory({ - db: trx - }), - storeProviderRecord: storeProviderRecordFactory({ - db, - encrypt: encryptor.encrypt - }), - storeUserSsoSession: storeUserSsoSessionFactory({ db: trx }), - createUserEmail: createUserEmailFactory({ db: trx }) - })({ - provider, - userId, - workspaceId: workspace.id - // ssoProviderUserInfo - }) - await trx.commit() - redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') - } catch (err) { - logger.warn( - { error: err }, - 'Failed to verify OIDC sso provider for workspace {workspaceSlug}' - ) - redirectUrl = buildErrorUrl({ - err, - url: redirectUrl, - searchParams: provider || undefined - }) - } finally { - req.session.destroy(noop) - // redirectUrl. - req.res?.redirect(redirectUrl.toString()) - } - } else { - // this must be using the generic OIDC login flow somehow + res?.redirect(authorizationUrl.toString()) + } catch (e) { + res?.redirect(buildErrorUrl(e, params.workspaceSlug)) } - }, - finalizeAuthMiddleware -) + } -export default router +const oidcCallbackRequestQuery = z.object({ validate: z.string().optional() }) + +type WorkspaceSsoOidcCallbackRequestQuery = z.infer + +/** + * Finalize SSO flow for all OIDC paths + */ +const handleOidcCallbackFactory = + ({ + authorizeResolver, + getWorkspaceBySlug, + createOidcProvider, + getOidcProvider, + getOidcProviderUserData, + tryGetSpeckleUserData, + createWorkspaceUserFromSsoProfile, + linkUserWithSsoProvider, + upsertUserSsoSession + }: { + authorizeResolver: AuthorizeResolver + getWorkspaceBySlug: GetWorkspaceBySlug + createOidcProvider: ReturnType + getOidcProvider: ReturnType + getOidcProviderUserData: ReturnType + tryGetSpeckleUserData: ReturnType + createWorkspaceUserFromSsoProfile: ReturnType< + typeof createWorkspaceUserFromSsoProfileFactory + > + linkUserWithSsoProvider: ReturnType + upsertUserSsoSession: UpsertUserSsoSession + }): RequestHandler< + WorkspaceSsoAuthRequestParams, + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + any, + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + any, + WorkspaceSsoOidcCallbackRequestQuery + > => + async (req) => { + const workspace = await getWorkspaceBySlug({ + workspaceSlug: req.params.workspaceSlug + }) + if (!workspace) throw new WorkspaceNotFoundError() + + const decryptedOidcProvider: WorkspaceSsoProvider = + req.query.validate === 'true' + ? await createOidcProvider(req, workspace.id) + : await getOidcProvider(workspace.id) + + const oidcProviderUserData = await getOidcProviderUserData( + req, + decryptedOidcProvider.provider + ) + const speckleUserData = await tryGetSpeckleUserData(req, oidcProviderUserData) + + if (!speckleUserData) { + const newSpeckleUser = await createWorkspaceUserFromSsoProfile({ + ssoProfile: oidcProviderUserData, + workspaceId: decryptedOidcProvider.workspaceId + }) + req.user = { id: newSpeckleUser.id, email: newSpeckleUser.email, isNewUser: true } + } + + req.user ??= { id: speckleUserData!.id, email: speckleUserData!.email } + + if (!req.user || !req.user.id) throw new SsoGenericAuthenticationError() + + await linkUserWithSsoProvider({ + userId: req.user.id, + ssoProfile: oidcProviderUserData + }) + + // TODO: Implicitly consume invite here, if one exists + await authorizeResolver( + req.user.id, + workspace.id, + Roles.Workspace.Member, + req.context.resourceAccessRules + ) + + // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work + await upsertUserSsoSession({ + userSsoSession: { + userId: req.user.id, + providerId: decryptedOidcProvider.providerId, + createdAt: new Date(), + validUntil: getDefaultSsoSessionExpirationDate() + } + }) + + req.authRedirectPath = buildFinalizeUrl(workspace.slug).toString() + } + +const createOidcProviderFactory = + ({ + getOIDCProviderValidationRequest, + saveSsoProviderRegistration + }: { + getOIDCProviderValidationRequest: ReturnType< + typeof getOIDCProviderValidationRequestFactory + > + saveSsoProviderRegistration: ReturnType + }) => + async ( + req: Request, + workspaceId: string + ): Promise => { + if (!req.context.userId) + throw new WorkspacesNotAuthorizedError('You must be signed in to configure SSO') + + const encryptedCodeVerifier = req.session.codeVerifier + if (!encryptedCodeVerifier) throw new SsoVerificationCodeMissingError() + + const codeVerifier = await parseCodeVerifier(req) + + const oidcProvider = await getOIDCProviderValidationRequest({ + validationToken: codeVerifier + }) + if (!oidcProvider) + throw new SsoGenericProviderValidationError( + 'Validation request not found. Restart flow.' + ) + + await authorizeResolver( + req.context.userId, + workspaceId, + Roles.Workspace.Admin, + req.context.resourceAccessRules + ) + + const workspaceProviderRecord = await saveSsoProviderRegistration({ + provider: oidcProvider, + workspaceId + }) + + return { + ...workspaceProviderRecord, + providerId: workspaceProviderRecord.id, + workspaceId + } + } + +const getOidcProviderFactory = + ({ getWorkspaceSsoProvider }: { getWorkspaceSsoProvider: GetWorkspaceSsoProvider }) => + async (workspaceId: string): Promise => { + const provider = await getWorkspaceSsoProvider({ workspaceId }) + if (!provider) throw new SsoProviderMissingError() + return provider + } + +const getOidcProviderUserDataFactory = + () => + async ( + req: Request< + WorkspaceSsoAuthRequestParams, + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + any, + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + any, + WorkspaceSsoOidcCallbackRequestQuery + >, + provider: OidcProvider + ): Promise> => { + const codeVerifier = await parseCodeVerifier(req) + const { client } = await initializeIssuerAndClient({ provider }) + const callbackParams = client.callbackParams(req) + const tokenSet = await client.callback( + buildAuthRedirectUrl( + req.params.workspaceSlug, + req.query.validate === 'true' + ).toString(), + callbackParams, + { code_verifier: codeVerifier } + ) + + const oidcProviderUserData = await client.userinfo(tokenSet) + if (!oidcProviderUserData || !oidcProviderUserData.email) { + throw new SsoProviderProfileMissingError() + } + + return oidcProviderUserData as UserinfoResponse<{ email: string }> + } + +const tryGetSpeckleUserDataFactory = + ({ findEmail, getUser }: { findEmail: FindEmail; getUser: GetUser }) => + async ( + req: Request, + oidcProviderUserData: UserinfoResponse<{ email: string }> + ): Promise => { + // Get currently signed-in user, if available + const currentSessionUser = await getUser(req.context.userId ?? '') + + // Get user with email that matches OIDC provider user email, if match exists + const userEmail = await findEmail({ email: oidcProviderUserData.email }) + if (!!userEmail && !userEmail.verified) throw new SsoUserEmailUnverifiedError() + const existingSpeckleUser = await getUser(userEmail?.userId ?? '') + + // Confirm existing user matches signed-in user, if both are present + if (!!currentSessionUser && !!existingSpeckleUser) { + if (currentSessionUser.id !== existingSpeckleUser.id) { + throw new SsoUserClaimedError() + } + } + + // Return target user of sign in flow + return currentSessionUser ?? existingSpeckleUser + } diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index 4600206ee..20b5fcc72 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -1,31 +1,44 @@ +/* eslint-disable camelcase */ + import { - GetOIDCProviderAttributes, - OIDCProviderAttributes, - OIDCProvider, - StoreOIDCProviderValidationRequest, + GetOidcProviderAttributes, + StoreOidcProviderValidationRequest, StoreProviderRecord, - StoreUserSsoSession, - OIDCProviderRecord, AssociateSsoProviderWithWorkspace, GetWorkspaceSsoProvider -} from '@/modules/workspaces/domain/sso' -import { BaseError } from '@/modules/shared/errors/base' +} from '@/modules/workspaces/domain/sso/operations' +import { + OidcProvider, + OidcProviderRecord, + OidcProviderAttributes +} from '@/modules/workspaces/domain/sso/types' import cryptoRandomString from 'crypto-random-string' -import { CreateUserEmail } from '@/modules/core/domain/userEmails/operations' - -export class MissingOIDCProviderGrantType extends BaseError { - static defaultMessage = 'OIDC issuer does not support authorization_code grant type' - static code = 'OIDC_SSO_MISSING_GRANT_TYPE' - static statusCode = 400 -} +import { UserinfoResponse } from 'openid-client' +import { + CreateUserEmail, + FindEmailsByUserId, + UpdateUserEmail +} from '@/modules/core/domain/userEmails/operations' +import { isWorkspaceRole } from '@/modules/workspaces/domain/logic' +import { UserWithOptionalRole } from '@/modules/core/repositories/users' +import { DeleteInvite, FindInvite } from '@/modules/serverinvites/domain/operations' +import { UpsertWorkspaceRole } from '@/modules/workspaces/domain/operations' +import { CreateValidatedUser } from '@/modules/core/domain/users/operations' +import { + OidcProviderMissingGrantTypeError, + SsoProviderExistsError, + SsoProviderProfileInvalidError, + SsoUserInviteRequiredError +} from '@/modules/workspaces/errors/sso' +import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace' // this probably should go a lean validation endpoint too -const validateOIDCProviderAttributes = ({ +const validateOidcProviderAttributes = ({ // client, issuer -}: OIDCProviderAttributes): void => { +}: OidcProviderAttributes): void => { if (!issuer.grantTypesSupported.includes('authorization_code')) - throw new MissingOIDCProviderGrantType() + throw new OidcProviderMissingGrantTypeError() /* validate issuer: authorization_signing_alg_values_supported @@ -40,24 +53,28 @@ grant_types: ['authorization_code'], */ } -export const startOIDCSsoProviderValidationFactory = +/** + * Store information about the OIDC provider used for a given SSO auth request. + * Used by validation and auth + */ +export const startOidcSsoProviderValidationFactory = ({ - getOIDCProviderAttributes, - storeOIDCProviderValidationRequest, + getOidcProviderAttributes, + storeOidcProviderValidationRequest, generateCodeVerifier }: { - getOIDCProviderAttributes: GetOIDCProviderAttributes - storeOIDCProviderValidationRequest: StoreOIDCProviderValidationRequest + getOidcProviderAttributes: GetOidcProviderAttributes + storeOidcProviderValidationRequest: StoreOidcProviderValidationRequest generateCodeVerifier: () => string }) => - async ({ provider }: { provider: OIDCProvider }): Promise => { + async ({ provider }: { provider: OidcProvider }): Promise => { // get client information - const providerAttributes = await getOIDCProviderAttributes({ provider }) + const providerAttributes = await getOidcProviderAttributes({ provider }) // validate issuer and client data - validateOIDCProviderAttributes(providerAttributes) + validateOidcProviderAttributes(providerAttributes) // store provider validation with an id token const codeVerifier = generateCodeVerifier() - await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) + await storeOidcProviderValidationRequest({ token: codeVerifier, provider }) return codeVerifier } @@ -65,30 +82,22 @@ export const saveSsoProviderRegistrationFactory = ({ getWorkspaceSsoProvider, storeProviderRecord, - associateSsoProviderWithWorkspace, - storeUserSsoSession - }: // createUserEmail - { + associateSsoProviderWithWorkspace + }: { getWorkspaceSsoProvider: GetWorkspaceSsoProvider storeProviderRecord: StoreProviderRecord associateSsoProviderWithWorkspace: AssociateSsoProviderWithWorkspace - storeUserSsoSession: StoreUserSsoSession - createUserEmail: CreateUserEmail }) => async ({ provider, - workspaceId, - userId - }: // ssoProviderUserInfo - { - provider: OIDCProvider - userId: string + workspaceId + }: { + provider: OidcProvider workspaceId: string - // ssoProviderUserInfo: { email: string } - }) => { + }): Promise => { // create OIDC provider record with ID const providerId = cryptoRandomString({ length: 10 }) - const providerRecord: OIDCProviderRecord = { + const providerRecord: OidcProviderRecord = { provider, providerType: 'oidc', createdAt: new Date(), @@ -97,21 +106,127 @@ export const saveSsoProviderRegistrationFactory = } const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) // replace with a proper error - if (maybeExistingSsoProvider) - throw new Error('Workspace already has an SSO provider') + if (maybeExistingSsoProvider) throw new SsoProviderExistsError() await storeProviderRecord({ providerRecord }) // associate provider with workspace await associateSsoProviderWithWorkspace({ workspaceId, providerId }) - // create and associate userSso session (how long is the default validity?) - // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work - const lifespan = 6.048e8 // 1 week - await storeUserSsoSession({ - userSsoSession: { createdAt: new Date(), userId, providerId, lifespan } - }) - // 1. get userId's emails - - // 2. if the ssoUserInfoEmail is not in the user's emails, add it as verified - // 3. if its in the emails, but not verify, verify it - // 4. if its verified, do nothing - // await createUserEmail() + return providerRecord + } + +export const createWorkspaceUserFromSsoProfileFactory = + ({ + createUser, + upsertWorkspaceRole, + findInvite, + deleteInvite + }: { + createUser: CreateValidatedUser + upsertWorkspaceRole: UpsertWorkspaceRole + findInvite: FindInvite + deleteInvite: DeleteInvite + }) => + async (args: { + ssoProfile: UserinfoResponse<{ email: string }> + workspaceId: string + }): Promise> => { + // Check if user has email-based invite to given workspace + const invite = await findInvite({ + target: args.ssoProfile.email, + resourceFilter: { + resourceId: args.workspaceId, + resourceType: 'workspace' + } + }) + + if (!invite) { + throw new SsoUserInviteRequiredError() + } + + // Create Speckle user + const { name, email, email_verified } = args.ssoProfile + + if (!name) { + throw new SsoProviderProfileInvalidError('SSO provider user requires a name') + } + + if (!email_verified) { + throw new SsoProviderProfileInvalidError('SSO provider user email is unverified') + } + + const newSpeckleUser = { + name, + email, + verified: true, + role: invite.resource.secondaryResourceRoles?.server + } + const newSpeckleUserId = await createUser(newSpeckleUser) + + // Add user to workspace with role specified in invite + const { role: workspaceRole } = invite.resource + + if (!isWorkspaceRole(workspaceRole)) throw new WorkspaceInvalidRoleError() + + await upsertWorkspaceRole({ + userId: newSpeckleUserId, + workspaceId: args.workspaceId, + role: workspaceRole, + createdAt: new Date() + }) + + // Delete invite (i.e. we implicitly "use" the invite during this sign up flow) + await deleteInvite(invite.id) + + return { + ...newSpeckleUser, + id: newSpeckleUserId + } + } + +export const linkUserWithSsoProviderFactory = + ({ + findEmailsByUserId, + createUserEmail, + updateUserEmail + }: { + findEmailsByUserId: FindEmailsByUserId + createUserEmail: CreateUserEmail + updateUserEmail: UpdateUserEmail + }) => + async (args: { + userId: string + ssoProfile: UserinfoResponse<{ email: string }> + }): Promise => { + // TODO: Chuck's soapbox - + // + // Assert link between req.user.id & { providerId: decryptedOidcProvider.id, email: oidcProviderUserData.email } + // Create link implicitly if req.context.userId exists (user performed SSO flow while signed in) + // If req.context.userId does not exist, and link does not exist, throw and require user to sign in before SSO + + // Add oidcProviderUserData.email to req.user.id verified emails, if not already present + const userEmails = await findEmailsByUserId({ userId: args.userId }) + const maybeSsoEmail = userEmails.find( + (entry) => entry.email === args.ssoProfile.email + ) + + if (!maybeSsoEmail) { + await createUserEmail({ + userEmail: { + userId: args.userId, + email: args.ssoProfile.email, + verified: true + } + }) + } + + if (!!maybeSsoEmail && !maybeSsoEmail.verified) { + await updateUserEmail({ + query: { + id: maybeSsoEmail.id, + userId: args.userId + }, + update: { + verified: true + } + }) + } } diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index 3bff6288d..672115c67 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -47,6 +47,10 @@ import { import { getStreamFactory } from '@/modules/core/repositories/streams' import { getUserFactory } from '@/modules/core/repositories/users' import { getServerInfoFactory } from '@/modules/core/repositories/server' +import { storeSsoProviderRecordFactory } from '@/modules/workspaces/repositories/sso' +import { getEncryptor } from '@/modules/workspaces/helpers/sso' +import { OidcProvider } from '@/modules/workspaces/domain/sso/types' +import { getFrontendOrigin } from '@/modules/shared/helpers/envHelper' export type BasicTestWorkspace = { /** @@ -244,3 +248,25 @@ export const createWorkspaceInviteDirectly = async ( inviterResourceAccessRules: null }) } + +export const createTestOidcProvider = async ( + providerData: Partial = {} +) => { + const providerId = cryptoRandomString({ length: 9 }) + await storeSsoProviderRecordFactory({ db, encrypt: getEncryptor() })({ + providerRecord: { + id: providerId, + createdAt: new Date(), + updatedAt: new Date(), + providerType: 'oidc', + provider: { + providerName: 'Test Provider', + clientId: 'test-provider', + clientSecret: cryptoRandomString({ length: 12 }), + issuerUrl: new URL('', getFrontendOrigin()).toString(), + ...providerData + } + } + }) + return providerId +} diff --git a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts index 0a7c6a8c3..e83834ff2 100644 --- a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts @@ -59,8 +59,8 @@ const updateUserEmail = updateUserEmailFactory({ db }) const getUserDiscoverableWorkspaces = getUserDiscoverableWorkspacesFactory({ db }) const upsertProjectRole = upsertProjectRoleFactory({ db }) const grantStreamPermissions = grantStreamPermissionsFactory({ db }) - const upsertWorkspace = upsertWorkspaceFactory({ db }) + const createAndStoreTestWorkspace = createAndStoreTestWorkspaceFactory({ upsertWorkspace }) diff --git a/packages/server/modules/workspaces/tests/integration/sso.spec.ts b/packages/server/modules/workspaces/tests/integration/sso.spec.ts new file mode 100644 index 000000000..301675da1 --- /dev/null +++ b/packages/server/modules/workspaces/tests/integration/sso.spec.ts @@ -0,0 +1,117 @@ +import { + associateSsoProviderWithWorkspaceFactory, + getWorkspaceSsoProviderFactory, + upsertUserSsoSessionFactory +} from '@/modules/workspaces/repositories/sso' +import { + BasicTestWorkspace, + createTestOidcProvider, + createTestWorkspace +} from '@/modules/workspaces/tests/helpers/creation' +import { BasicTestUser, createTestUser } from '@/test/authHelper' +import { Roles, wait } from '@speckle/shared' +import db from '@/db/knex' +import { getDecryptor } from '@/modules/workspaces/helpers/sso' +import cryptoRandomString from 'crypto-random-string' +import { expect } from 'chai' +import { UserSsoSessionRecord } from '@/modules/workspaces/domain/sso/types' + +const associateSsoProviderWithWorkspace = associateSsoProviderWithWorkspaceFactory({ + db +}) +const upsertUserSsoSession = upsertUserSsoSessionFactory({ db }) + +describe('Workspace SSO repositories', () => { + const serverAdminUser: BasicTestUser = { + id: '', + name: 'John Speckle', + email: 'john-sso-speckle@example.org', + role: Roles.Server.Admin + } + + before(async () => { + await createTestUser(serverAdminUser) + }) + + describe('getWorkspaceSsoProviderFactory returns a function, that', () => { + const workspace: BasicTestWorkspace = { + id: '', + ownerId: '', + slug: `test-workspace-${cryptoRandomString({ length: 6 })}`, + name: 'Test Workspace' + } + + it('fetches and decrypts oidc provider information for the given workspace', async () => { + await createTestWorkspace(workspace, serverAdminUser) + const providerId = await createTestOidcProvider() + await associateSsoProviderWithWorkspace({ workspaceId: workspace.id, providerId }) + const provider = await getWorkspaceSsoProviderFactory({ + db, + decrypt: getDecryptor() + })({ workspaceId: workspace.id }) + + expect(provider).to.not.be.undefined + expect(provider?.id).to.equal(providerId) + expect(typeof provider?.provider).to.not.equal('string') + }) + it('returns null if the provider does not exist', async () => { + const provider = await getWorkspaceSsoProviderFactory({ + db, + decrypt: getDecryptor() + })({ workspaceId: cryptoRandomString({ length: 6 }) }) + expect(provider).to.be.null + }) + }) + + describe('upsertUserSsoSessionFactory returns a function, that', () => { + it('creates a session if none exists', async () => { + const providerId = await createTestOidcProvider() + + const userSsoSession: UserSsoSessionRecord = { + userId: serverAdminUser.id, + providerId, + createdAt: new Date(), + validUntil: new Date() + } + + await upsertUserSsoSession({ userSsoSession }) + + // TODO: Use future repo function + const sessions = await db('user_sso_sessions').where({ + providerId, + userId: serverAdminUser.id + }) + + expect(sessions[0].providerId).to.equal(providerId) + }) + + it('updates an existing session, if one exists', async () => { + const providerId = await createTestOidcProvider() + const initialValidUntil = new Date() + + const userSsoSession: UserSsoSessionRecord = { + userId: serverAdminUser.id, + providerId, + createdAt: new Date(), + validUntil: initialValidUntil + } + await upsertUserSsoSession({ userSsoSession }) + await wait(50) + await upsertUserSsoSession({ + userSsoSession: { + ...userSsoSession, + validUntil: new Date() + } + }) + + // TODO: Use future repo function + const sessions = await db('user_sso_sessions').where({ + providerId, + userId: serverAdminUser.id + }) + + expect(sessions.length).to.equal(1) + expect(sessions[0].validUntil.getTime()).to.not.equal(initialValidUntil.getTime()) + }) + }) +}) diff --git a/packages/server/modules/workspaces/tests/unit/domain/logic.spec.ts b/packages/server/modules/workspaces/tests/unit/domain/logic.spec.ts index eed673900..e2103ac7f 100644 --- a/packages/server/modules/workspaces/tests/unit/domain/logic.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/domain/logic.spec.ts @@ -1,11 +1,13 @@ import { UserEmail } from '@/modules/core/domain/userEmails/types' import { anyEmailCompliantWithWorkspaceDomains, + isWorkspaceRole, userEmailsCompliantWithWorkspaceDomains } from '@/modules/workspaces/domain/logic' import { WorkspaceDomainsInvalidState } from '@/modules/workspaces/errors/workspace' import { WorkspaceDomain } from '@/modules/workspacesCore/domain/types' import { expectToThrow } from '@/test/assertionHelper' +import { Roles } from '@speckle/shared' import { expect } from 'chai' import cryptoRandomString from 'crypto-random-string' import { merge } from 'lodash' @@ -103,4 +105,15 @@ describe('workspace domain logic', () => { expect(isCompliant).to.be.true }) }) + describe('isWorkspaceRole', () => { + it('returns false for non-role values', () => { + expect(isWorkspaceRole('not-a-role')).to.equal(false) + }) + it('returns false for non-workspace roles', () => { + expect(isWorkspaceRole(Roles.Server.Admin)).to.equal(false) + }) + it('returns true for workspace roles', () => { + expect(isWorkspaceRole(Roles.Workspace.Admin)).to.equal(true) + }) + }) }) diff --git a/packages/server/modules/workspaces/tests/unit/helpers/sso.spec.ts b/packages/server/modules/workspaces/tests/unit/helpers/sso.spec.ts new file mode 100644 index 000000000..8bc5b9988 --- /dev/null +++ b/packages/server/modules/workspaces/tests/unit/helpers/sso.spec.ts @@ -0,0 +1,36 @@ +import { + buildAuthRedirectUrl, + buildErrorUrl, + buildFinalizeUrl +} from '@/modules/workspaces/helpers/sso' +import { expect } from 'chai' + +describe('buildAuthRedirectUrl', () => { + it('should include workspace slug provided', () => { + const url = buildAuthRedirectUrl('my-workspace', false) + expect(url.toString().includes('my-workspace')).to.equal(true) + }) + it('should include validate param if provided', () => { + const url = buildAuthRedirectUrl('my-workspace', true) + expect(url.searchParams.get('validate')).to.equal('true') + }) +}) + +describe('buildFinalizeUrl', () => { + it('should include workspace slug provided', () => { + const url = buildFinalizeUrl('my-workspace') + expect(url.toString().includes('my-workspace')).to.equal(true) + }) +}) + +describe('buildErrorUrl', () => { + it('should include workspace slug provided', () => { + const url = buildErrorUrl({}, 'my-workspace') + expect(url.toString().includes('my-workspace')).to.equal(true) + }) + it('should include error message provided', () => { + const url = buildErrorUrl(new Error('test'), 'my-workspace') + expect(url.toString().includes('error')).to.equal(true) + expect(url.toString().includes('test')).to.equal(true) + }) +}) diff --git a/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts b/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts new file mode 100644 index 000000000..d948101c3 --- /dev/null +++ b/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts @@ -0,0 +1,347 @@ +/* eslint-disable camelcase */ + +import { UserEmail } from '@/modules/core/domain/userEmails/types' +import { + OidcProvider, + WorkspaceSsoProvider +} from '@/modules/workspaces/domain/sso/types' +import { + OidcProviderMissingGrantTypeError, + SsoProviderExistsError, + SsoUserInviteRequiredError +} from '@/modules/workspaces/errors/sso' +import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace' +import { + createWorkspaceUserFromSsoProfileFactory, + linkUserWithSsoProviderFactory, + saveSsoProviderRegistrationFactory, + startOidcSsoProviderValidationFactory +} from '@/modules/workspaces/services/sso' +import { expectToThrow } from '@/test/assertionHelper' +import { assert, expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' + +describe('Workspace SSO services', () => { + describe('startOidcSsoProviderValidationFactory creates a function, that', () => { + it('throws if given provider has invalid attributes', async () => { + const startOidcSsoProviderValidation = startOidcSsoProviderValidationFactory({ + getOidcProviderAttributes: async () => ({ + issuer: { + claimsSupported: [], + grantTypesSupported: [], + responseTypesSupported: [] + }, + client: { + grantTypes: [] + } + }), + storeOidcProviderValidationRequest: async () => { + assert.fail() + }, + generateCodeVerifier: () => '' + }) + + const err = await expectToThrow(() => + startOidcSsoProviderValidation({ provider: {} as OidcProvider }) + ) + expect(err.message).to.equal(OidcProviderMissingGrantTypeError.defaultMessage) + }) + }) + describe('saveSsoProviderRegistrationFactory creates a function, that', () => { + it('throws if a provider is already configured for the workspace', async () => { + const saveSsoProviderRegistration = saveSsoProviderRegistrationFactory({ + getWorkspaceSsoProvider: async () => ({} as WorkspaceSsoProvider), + storeProviderRecord: async () => { + assert.fail() + }, + associateSsoProviderWithWorkspace: async () => { + assert.fail() + } + }) + + const err = await expectToThrow(() => + saveSsoProviderRegistration({ + provider: {} as OidcProvider, + workspaceId: cryptoRandomString({ length: 9 }) + }) + ) + expect(err.message).to.equal(SsoProviderExistsError.defaultMessage) + }) + }) + /* eslint-disable @typescript-eslint/no-explicit-any */ + /* eslint-disable @typescript-eslint/no-unsafe-return */ + describe('createWorkspaceUserFromSsoProfileFactory creates a function, that', () => { + it('throws if target email does not have a valid invite to the given workspace', async () => { + const createWorkspaceUserFromSsoProfile = + createWorkspaceUserFromSsoProfileFactory({ + createUser: async () => '', + upsertWorkspaceRole: async () => {}, + findInvite: async () => null, + deleteInvite: async () => true + }) + + const err = await expectToThrow(() => + createWorkspaceUserFromSsoProfile({ + ssoProfile: { + sub: '', + email: '' + }, + workspaceId: cryptoRandomString({ length: 9 }) + }) + ) + expect(err.message).to.equal(SsoUserInviteRequiredError.defaultMessage) + }) + it('throws if SSO provider user profile does not have a name configured', async () => { + const createWorkspaceUserFromSsoProfile = + createWorkspaceUserFromSsoProfileFactory({ + createUser: async () => '', + upsertWorkspaceRole: async () => {}, + findInvite: async () => ({} as unknown as any), + deleteInvite: async () => true + }) + + const err = await expectToThrow(() => + createWorkspaceUserFromSsoProfile({ + ssoProfile: { + sub: '', + email: '' + }, + workspaceId: cryptoRandomString({ length: 9 }) + }) + ) + expect(err.message).to.include('requires a name') + }) + it('throws if SSO provider user profile does not have a verified email', async () => { + const createWorkspaceUserFromSsoProfile = + createWorkspaceUserFromSsoProfileFactory({ + createUser: async () => '', + upsertWorkspaceRole: async () => {}, + findInvite: async () => ({} as unknown as any), + deleteInvite: async () => true + }) + + const err = await expectToThrow(() => + createWorkspaceUserFromSsoProfile({ + ssoProfile: { + name: 'John Speckle', + sub: '', + email: '', + email_verified: false + }, + workspaceId: cryptoRandomString({ length: 9 }) + }) + ) + expect(err.message).to.include('email is unverified') + }) + it('throws if workspace role on invite is not a valid workspace role', async () => { + const createWorkspaceUserFromSsoProfile = + createWorkspaceUserFromSsoProfileFactory({ + createUser: async () => '', + upsertWorkspaceRole: async () => {}, + findInvite: async () => + ({ + resource: { + role: 'not-a-role' + } + } as unknown as any), + deleteInvite: async () => true + }) + + const err = await expectToThrow(() => + createWorkspaceUserFromSsoProfile({ + ssoProfile: { + name: 'John Speckle', + sub: '', + email: '', + email_verified: true + }, + workspaceId: cryptoRandomString({ length: 9 }) + }) + ) + expect(err.message).to.equal(WorkspaceInvalidRoleError.defaultMessage) + }) + it('correctly sets both the workspace role and the server role on the given invite', async () => { + let serverRole: string | undefined = undefined + let workspaceRole: string | undefined = undefined + + const createWorkspaceUserFromSsoProfile = + createWorkspaceUserFromSsoProfileFactory({ + createUser: async ({ role }) => { + serverRole = role + return '' + }, + upsertWorkspaceRole: async ({ role }) => { + workspaceRole = role + }, + findInvite: async () => + ({ + resource: { + role: 'workspace:admin', + secondaryResourceRoles: { + server: 'server:admin' + } + } + } as unknown as any), + deleteInvite: async () => true + }) + + await createWorkspaceUserFromSsoProfile({ + ssoProfile: { + name: 'John Speckle', + sub: '', + email: '', + email_verified: true + }, + workspaceId: cryptoRandomString({ length: 9 }) + }) + + expect(serverRole).to.equal('server:admin') + expect(workspaceRole).to.equal('workspace:admin') + }) + it('deletes the workspace invite after creating the user and assigning all roles', async () => { + let isDeleteCalled = false + + const createWorkspaceUserFromSsoProfile = + createWorkspaceUserFromSsoProfileFactory({ + createUser: async () => '', + upsertWorkspaceRole: async () => {}, + findInvite: async () => + ({ + resource: { + role: 'workspace:admin', + secondaryResourceRoles: { + server: 'server:admin' + } + } + } as unknown as any), + deleteInvite: async () => { + isDeleteCalled = true + return true + } + }) + + await createWorkspaceUserFromSsoProfile({ + ssoProfile: { + name: 'John Speckle', + sub: '', + email: '', + email_verified: true + }, + workspaceId: cryptoRandomString({ length: 9 }) + }) + + expect(isDeleteCalled).to.be.true + }) + }) + /* eslint-enable @typescript-eslint/no-explicit-any */ + /* eslint-enable @typescript-eslint/no-unsafe-return */ + describe('linkUserWithSsoProviderFactory creates a function, that', () => { + it('does no work if user is already associated with provider', async () => { + const userId = cryptoRandomString({ length: 9 }) + const email = 'test@example.org' + + const linkUserWithSsoProvider = linkUserWithSsoProviderFactory({ + findEmailsByUserId: async () => [ + { + id: cryptoRandomString({ length: 9 }), + userId, + email, + verified: true, + primary: true, + createdAt: new Date(), + updatedAt: new Date() + } + ], + createUserEmail: async () => { + assert.fail() + }, + updateUserEmail: async () => { + assert.fail() + } + }) + + await linkUserWithSsoProvider({ + userId, + ssoProfile: { + sub: cryptoRandomString({ length: 9 }), + email + } + }) + }) + it('verifies user email if sso email is already associated with the user', async () => { + const userId = cryptoRandomString({ length: 9 }) + const email = 'test@example.org' + + let isVerified = false + + const linkUserWithSsoProvider = linkUserWithSsoProviderFactory({ + findEmailsByUserId: async () => [ + { + id: cryptoRandomString({ length: 9 }), + userId, + email, + verified: false, + primary: true, + createdAt: new Date(), + updatedAt: new Date() + } + ], + createUserEmail: async () => { + assert.fail() + }, + updateUserEmail: async () => { + isVerified = true + return {} as UserEmail + } + }) + + await linkUserWithSsoProvider({ + userId, + ssoProfile: { + sub: cryptoRandomString({ length: 9 }), + email + } + }) + + expect(isVerified).to.be.true + }) + it('adds sso email to user emails if not already present', async () => { + const userId = cryptoRandomString({ length: 9 }) + const email = 'test@example.org' + + const userEmails: UserEmail[] = [] + + const linkUserWithSsoProvider = linkUserWithSsoProviderFactory({ + findEmailsByUserId: async () => [], + createUserEmail: async ({ userEmail }) => { + const email: UserEmail = { + id: cryptoRandomString({ length: 9 }), + userId, + email: userEmail.email, + verified: true, + primary: true, + createdAt: new Date(), + updatedAt: new Date() + } + userEmails.push(email) + return email + }, + updateUserEmail: async () => { + assert.fail() + } + }) + + await linkUserWithSsoProvider({ + userId, + ssoProfile: { + sub: cryptoRandomString({ length: 9 }), + email + } + }) + + expect(userEmails.length).to.equal(1) + expect(userEmails[0].email).to.equal(email) + expect(userEmails[0].verified).to.be.true + }) + }) +}) diff --git a/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts b/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts new file mode 100644 index 000000000..fa0a6352a --- /dev/null +++ b/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts @@ -0,0 +1,19 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('user_sso_sessions', (table) => { + table.dropColumn('lifespan') + table + .timestamp('validUntil', { precision: 3, useTz: true }) + .defaultTo(knex.fn.now()) + .notNullable() + }) +} + +export async function down(knex: Knex): Promise { + const lifespan = 6.048e8 // 1 week + await knex.schema.alterTable('user_sso_sessions', (table) => { + table.dropColumn('createdAt') + table.bigint('lifespan').defaultTo(lifespan).notNullable() + }) +}