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 @@
+
+
+
+ Continue with {{ ssoProviderName }} SSO
+
+
+
+
+
+
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()
+ })
+}