Files
speckle-server/packages/server/modules/workspaces/rest/sso.ts
T
Kristaps Fabians Geikins cfa0b249d1 feat: seat type selection in invite flows (#4908)
* backend seems to work

* bigger menu support

* FE nearly done

* merge fix

* ui changes

* workspace invite disclaimer

* project disclaimer

* project invite viewer -> editor flow

* extra minor fixes

* Change project role input label

* extra test assertions

---------

Co-authored-by: Benjamin Ottensten <benjamin.ottensten@gmail.com>
2025-06-13 09:45:58 +03:00

788 lines
27 KiB
TypeScript

/* eslint-disable camelcase */
import { db } from '@/db/knex'
import { validateRequest } from 'zod-express'
import { Request, RequestHandler, Router } from 'express'
import { z } from 'zod'
import {
createWorkspaceUserFromSsoProfileFactory,
linkUserWithSsoProviderFactory,
saveSsoProviderRegistrationFactory,
startOidcSsoProviderValidationFactory
} from '@/modules/workspaces/services/sso'
import {
getOIDCProviderAttributes,
getProviderAuthorizationUrl,
initializeIssuerAndClient
} from '@/modules/workspaces/clients/oidcProvider'
import { getFeatureFlags, isProdEnv } from '@/modules/shared/helpers/envHelper'
import {
storeOIDCProviderValidationRequestFactory,
getOIDCProviderValidationRequestFactory,
associateSsoProviderWithWorkspaceFactory,
storeSsoProviderRecordFactory,
upsertUserSsoSessionFactory,
getWorkspaceSsoProviderFactory
} from '@/modules/workspaces/repositories/sso'
import { getGenericRedis } from '@/modules/shared/redis/redis'
import { generators, UserinfoResponse } from 'openid-client'
import { oidcProvider } from '@/modules/workspaces/domain/sso/models'
import {
OidcProfile,
OidcProvider,
SsoSessionState,
WorkspaceSsoProvider
} from '@/modules/workspaces/domain/sso/types'
import {
getWorkspaceBySlugFactory,
getWorkspaceFactory,
getWorkspaceRoleForUserFactory,
getWorkspaceRolesFactory,
getWorkspaceWithDomainsFactory,
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,
ensureNoPrimaryEmailForUserFactory,
findEmailFactory,
findEmailsByUserIdFactory,
findVerifiedEmailsByUserIdFactory,
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 { 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 {
getDefaultSsoSessionExpirationDate,
isValidOidcProfile,
getEmailFromOidcProfile
} from '@/modules/workspaces/domain/sso/logic'
import {
GetWorkspaceBySlug,
GetWorkspaceRoles
} from '@/modules/workspaces/domain/operations'
import {
GetWorkspaceSsoProvider,
UpsertUserSsoSession
} from '@/modules/workspaces/domain/sso/operations'
import { GetUser } from '@/modules/core/domain/users/operations'
import {
FindEmail,
FindEmailsByUserId
} from '@/modules/core/domain/userEmails/operations'
import {
buildAuthErrorRedirectUrl,
buildAuthFinalizeRedirectUrl,
buildAuthRedirectUrl,
buildValidationErrorRedirectUrl,
getDecryptor,
getEncryptor,
getSsoSessionState,
getErrorMessage,
parseCodeVerifier
} from '@/modules/workspaces/helpers/sso'
import {
OidcStateInvalidError,
SsoGenericAuthenticationError,
SsoGenericProviderValidationError,
SsoProviderMissingError,
SsoProviderProfileMissingError,
SsoProviderProfileMissingPropertiesError,
SsoUserClaimedError,
SsoUserEmailUnverifiedError,
SsoVerificationCodeMissingError
} from '@/modules/workspaces/errors/sso'
import { FeatureAccessForbiddenError } from '@/modules/gatekeeper/errors/features'
import { canWorkspaceUseOidcSsoFactory } from '@/modules/gatekeeper/services/featureAuthorization'
import { getWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing'
import cryptoRandomString from 'crypto-random-string'
import { base64Encode } from '@/modules/shared/helpers/cryptoHelper'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { addOrUpdateWorkspaceRoleFactory } from '@/modules/workspaces/services/management'
import {
assignWorkspaceSeatFactory,
ensureValidWorkspaceRoleSeatFactory,
getWorkspaceDefaultSeatTypeFactory
} from '@/modules/workspaces/services/workspaceSeat'
import {
createWorkspaceSeatFactory,
getWorkspaceUserSeatFactory
} from '@/modules/gatekeeper/repositories/workspaceSeat'
const moveAuthParamsToSessionMiddleware = moveAuthParamsToSessionMiddlewareFactory()
const sessionMiddleware = sessionMiddlewareFactory()
const finalizeAuthMiddleware = finalizeAuthMiddlewareFactory({
createAuthorizationCode: createAuthorizationCodeFactory({ db }),
getUser: legacyGetUserFactory({ db }),
emitEvent: getEventBus().emit
})
const moveWorkspaceIdToSessionMiddleware: RequestHandler<
WorkspaceSsoAuthRequestParams
> = async (req, _res, next) => {
const workspace = await getWorkspaceBySlugFactory({ db })({
workspaceSlug: req.params.workspaceSlug
})
req.session.workspaceId = workspace?.id
next()
}
const validateFeatureAccessMiddleware: RequestHandler<
WorkspaceSsoAuthRequestParams
> = async (req, res, next) => {
try {
if (!req.session.workspaceId) throw new FeatureAccessForbiddenError()
const isGatekeeperEnabled =
getFeatureFlags().FF_GATEKEEPER_MODULE_ENABLED && isProdEnv()
if (!isGatekeeperEnabled) return next()
const isAllowed = await canWorkspaceUseOidcSsoFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db })
})({ workspaceId: req.session.workspaceId })
if (!isAllowed) throw new FeatureAccessForbiddenError()
next()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : `${e}`
res?.redirect(
buildValidationErrorRedirectUrl(req.params.workspaceSlug, errorMessage).toString()
)
}
}
const createSsoStateMiddlewareFactory =
(state: SsoSessionState): RequestHandler<WorkspaceSsoAuthRequestParams> =>
async (req, _res, next) => {
const nonce = cryptoRandomString({ length: 9 })
req.session.ssoNonce = nonce
req.session.ssoState = state
return next()
}
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()
})
})
)
router.get(
'/api/v1/workspaces/:workspaceSlug/sso/auth',
sessionMiddleware,
moveAuthParamsToSessionMiddleware,
moveWorkspaceIdToSessionMiddleware,
validateFeatureAccessMiddleware,
validateRequest({
params: z.object({
workspaceSlug: z.string().min(1)
})
}),
createSsoStateMiddlewareFactory({
isValidationFlow: false
}),
handleSsoAuthRequestFactory({
getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({
db,
decrypt: getDecryptor()
})
})
)
router.get(
'/api/v1/workspaces/:workspaceSlug/sso/oidc/validate',
sessionMiddleware,
moveAuthParamsToSessionMiddleware,
moveWorkspaceIdToSessionMiddleware,
validateFeatureAccessMiddleware,
validateRequest({
params: z.object({
workspaceSlug: z.string().min(1)
}),
query: oidcProvider
}),
createSsoStateMiddlewareFactory({
isValidationFlow: true
}),
handleSsoValidationRequestFactory({
startOidcSsoProviderValidation: startOidcSsoProviderValidationFactory({
getOidcProviderAttributes: getOIDCProviderAttributes,
storeOidcProviderValidationRequest: storeOIDCProviderValidationRequestFactory({
redis: getGenericRedis(),
encrypt: getEncryptor()
}),
generateCodeVerifier: generators.codeVerifier
})
})
)
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) => {
try {
await withTransaction(
async ({ db: trx }) => {
const handleOidcCallback = handleOidcCallbackFactory({
getWorkspaceRoles: getWorkspaceRolesFactory({ 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 }),
getUserEmails: findEmailsByUserIdFactory({ 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
})
}),
emitEvent: getEventBus().emit
}),
addOrUpdateWorkspaceRole: addOrUpdateWorkspaceRoleFactory({
getWorkspaceWithDomains: getWorkspaceWithDomainsFactory({
db: trx
}),
findVerifiedEmailsByUserId: findVerifiedEmailsByUserIdFactory({
db: trx
}),
getWorkspaceRoles: getWorkspaceRolesFactory({ db: trx }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db: trx }),
emitWorkspaceEvent: getEventBus().emit,
ensureValidWorkspaceRoleSeat: ensureValidWorkspaceRoleSeatFactory({
createWorkspaceSeat: createWorkspaceSeatFactory({ db: trx }),
getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db: trx }),
getWorkspaceDefaultSeatType: getWorkspaceDefaultSeatTypeFactory({
getWorkspace: getWorkspaceFactory({ db: trx })
}),
eventEmit: getEventBus().emit
}),
assignWorkspaceSeat: assignWorkspaceSeatFactory({
createWorkspaceSeat: createWorkspaceSeatFactory({ db: trx }),
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({
db: trx
}),
eventEmit: getEventBus().emit,
getWorkspaceUserSeat: getWorkspaceUserSeatFactory({ db: trx })
})
}),
findInvite: findInviteFactory({ db: trx }),
deleteInvite: deleteInviteFactory({ db: trx })
}),
linkUserWithSsoProvider: linkUserWithSsoProviderFactory({
findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }),
createUserEmail: createUserEmailFactory({ db: trx }),
updateUserEmail: updateUserEmailFactory({ db: trx }),
logger: req.log
}),
upsertUserSsoSession: upsertUserSsoSessionFactory({ db: trx })
})
await handleOidcCallback(req, res, next)
},
{ db }
)
return next()
} catch (e) {
const errorMessage = getErrorMessage(e)
if (req.session.ssoState?.isValidationFlow) {
res?.redirect(
buildValidationErrorRedirectUrl(
req.params.workspaceSlug,
errorMessage,
req.session.oidcProvider
).toString()
)
} else {
res?.redirect(
buildAuthErrorRedirectUrl(req.params.workspaceSlug, errorMessage).toString()
)
}
}
},
finalizeAuthMiddleware
)
return router
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const workspaceSsoAuthRequestParams = z.object({
workspaceSlug: z.string().min(1)
})
type WorkspaceSsoAuthRequestParams = z.infer<typeof workspaceSsoAuthRequestParams>
/**
* Fetch public information about the workspace, including SSO provider metadata
*/
const handleGetLimitedWorkspaceRequestFactory =
({
getWorkspaceBySlug,
getWorkspaceSsoProvider
}: {
getWorkspaceBySlug: GetWorkspaceBySlug
getWorkspaceSsoProvider: GetWorkspaceSsoProvider
}): RequestHandler<WorkspaceSsoAuthRequestParams> =>
async ({ params, res }) => {
const workspace = await getWorkspaceBySlug({ workspaceSlug: params.workspaceSlug })
if (!workspace) throw new WorkspaceNotFoundError()
const ssoProviderData = await getWorkspaceSsoProvider({ workspaceId: workspace.id })
const limitedWorkspace = {
name: workspace.name,
logo: workspace.logo,
ssoProviderName: ssoProviderData?.provider?.providerName
}
res?.json(limitedWorkspace)
}
/**
* Start SSO sign-in or sign-up flow
*/
const handleSsoAuthRequestFactory =
({
getWorkspaceSsoProvider
}: {
getWorkspaceSsoProvider: GetWorkspaceSsoProvider
}): RequestHandler<WorkspaceSsoAuthRequestParams> =>
async ({ params, session, res }) => {
try {
if (!session.workspaceId) throw new WorkspaceNotFoundError()
if (!session.ssoNonce) throw new OidcStateInvalidError()
const { provider } =
(await getWorkspaceSsoProvider({ workspaceId: session.workspaceId })) ?? {}
if (!provider) throw new SsoProviderMissingError()
const codeVerifier = generators.codeVerifier()
const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug)
const authorizationUrl = await getProviderAuthorizationUrl({
provider,
redirectUrl,
codeVerifier,
state: base64Encode(session.ssoNonce)
})
session.codeVerifier = await getEncryptor()(codeVerifier)
res?.redirect(authorizationUrl.toString())
} catch (e) {
res?.redirect(
buildAuthErrorRedirectUrl(params.workspaceSlug, getErrorMessage(e)).toString()
)
}
}
type WorkspaceSsoValidationRequestQuery = z.infer<typeof oidcProvider>
/**
* Begin SSO configuration flow
*/
const handleSsoValidationRequestFactory =
({
startOidcSsoProviderValidation
}: {
startOidcSsoProviderValidation: ReturnType<
typeof startOidcSsoProviderValidationFactory
>
}): RequestHandler<
WorkspaceSsoAuthRequestParams,
never,
never,
WorkspaceSsoValidationRequestQuery
> =>
async ({ session, params, query: provider, res, context }) => {
try {
if (!session.workspaceId) throw new WorkspaceNotFoundError()
if (!session.ssoNonce) throw new OidcStateInvalidError()
await authorizeResolver(
context.userId,
session.workspaceId,
Roles.Workspace.Admin,
context.resourceAccessRules
)
const codeVerifier = await startOidcSsoProviderValidation({ provider })
const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug)
const authorizationUrl = await getProviderAuthorizationUrl({
provider,
redirectUrl,
codeVerifier,
state: base64Encode(session.ssoNonce)
})
session.codeVerifier = await getEncryptor()(codeVerifier)
res?.redirect(authorizationUrl.toString())
} catch (e) {
res?.redirect(
buildValidationErrorRedirectUrl(
params.workspaceSlug,
getErrorMessage(e),
provider
).toString()
)
}
}
const oidcCallbackRequestQuery = z.object({ state: z.string() })
type WorkspaceSsoOidcCallbackRequestQuery = z.infer<typeof oidcCallbackRequestQuery>
/**
* Finalize SSO flow for all OIDC paths
*/
const handleOidcCallbackFactory =
({
getWorkspaceRoles,
getWorkspaceBySlug,
createOidcProvider,
getOidcProvider,
getOidcProviderUserData,
tryGetSpeckleUserData,
createWorkspaceUserFromSsoProfile,
linkUserWithSsoProvider,
upsertUserSsoSession
}: {
getWorkspaceRoles: GetWorkspaceRoles
getWorkspaceBySlug: GetWorkspaceBySlug
createOidcProvider: ReturnType<typeof createOidcProviderFactory>
getOidcProvider: ReturnType<typeof getOidcProviderFactory>
getOidcProviderUserData: ReturnType<typeof getOidcProviderUserDataFactory>
tryGetSpeckleUserData: ReturnType<typeof tryGetSpeckleUserDataFactory>
createWorkspaceUserFromSsoProfile: ReturnType<
typeof createWorkspaceUserFromSsoProfileFactory
>
linkUserWithSsoProvider: ReturnType<typeof linkUserWithSsoProviderFactory>
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 { isValidationFlow } = getSsoSessionState(req)
const workspace = await getWorkspaceBySlug({
workspaceSlug: req.params.workspaceSlug
})
if (!workspace) throw new WorkspaceNotFoundError()
const decryptedOidcProvider: WorkspaceSsoProvider = isValidationFlow
? await createOidcProvider(req, workspace.id)
: await getOidcProvider(workspace.id)
req.session.oidcProvider = decryptedOidcProvider.provider
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
const workspaceRoles = await getWorkspaceRoles({ workspaceId: workspace.id })
if (!workspaceRoles.some((role) => role.userId === req.user?.id))
throw new SsoGenericAuthenticationError()
// 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()
}
})
const params: Record<string, string> = isValidationFlow
? { ssoValidationSuccess: 'true' }
: {}
req.authRedirectPath = buildAuthFinalizeRedirectUrl(
req.params.workspaceSlug,
params
).toString()
}
const createOidcProviderFactory =
({
getOIDCProviderValidationRequest,
saveSsoProviderRegistration
}: {
getOIDCProviderValidationRequest: ReturnType<
typeof getOIDCProviderValidationRequestFactory
>
saveSsoProviderRegistration: ReturnType<typeof saveSsoProviderRegistrationFactory>
}) =>
async (
req: Request<WorkspaceSsoAuthRequestParams>,
workspaceId: string
): Promise<WorkspaceSsoProvider> => {
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<WorkspaceSsoProvider> => {
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<UserinfoResponse<OidcProfile>> => {
if (!req.session.ssoNonce) throw new OidcStateInvalidError()
const codeVerifier = await parseCodeVerifier(req)
const { client } = await initializeIssuerAndClient({ provider })
const callbackParams = client.callbackParams(req)
const tokenSet = await client.callback(
buildAuthRedirectUrl(req.params.workspaceSlug).toString(),
callbackParams,
{ code_verifier: codeVerifier, state: base64Encode(req.session.ssoNonce) }
)
const oidcProviderUserData = await client.userinfo(tokenSet)
if (!oidcProviderUserData) {
throw new SsoProviderProfileMissingError()
}
if (!isValidOidcProfile(oidcProviderUserData)) {
req.log.error(
{ providedClaims: Object.keys(oidcProviderUserData) },
'Missing required properties on OIDC provider.'
)
throw new SsoProviderProfileMissingPropertiesError(['email'])
}
return oidcProviderUserData as UserinfoResponse<OidcProfile>
}
const tryGetSpeckleUserDataFactory =
({
findEmail,
getUser,
getUserEmails
}: {
findEmail: FindEmail
getUser: GetUser
getUserEmails: FindEmailsByUserId
}) =>
async (
req: Request<WorkspaceSsoAuthRequestParams>,
oidcProviderUserData: UserinfoResponse<OidcProfile>
): Promise<UserWithOptionalRole | null> => {
// 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 providerEmail = getEmailFromOidcProfile(oidcProviderUserData)
const userEmail = await findEmail({ email: providerEmail.toLowerCase() })
if (!!userEmail && !userEmail.verified) throw new SsoUserEmailUnverifiedError()
const existingSpeckleUser = await getUser(userEmail?.userId ?? '')
// Log details about users we're comparing
req.log.info(
{
providerEmail,
currentSessionUserId: currentSessionUser?.id,
existingSpeckleUserId: existingSpeckleUser?.id
},
'Computing active user information given current auth context:'
)
// Confirm existing user matches signed-in user, if both are present
if (!!currentSessionUser && !!existingSpeckleUser) {
if (currentSessionUser.id !== existingSpeckleUser.id) {
const currentSessionUserEmails = await getUserEmails({
userId: currentSessionUser.id
})
throw new SsoUserClaimedError({
currentUser: currentSessionUser,
currentUserEmails: currentSessionUserEmails,
existingUser: existingSpeckleUser,
existingUserEmail: providerEmail
})
}
}
// Return target user of sign in flow
return currentSessionUser ?? existingSpeckleUser
}