246 lines
8.4 KiB
TypeScript
246 lines
8.4 KiB
TypeScript
import { db } from '@/db/knex'
|
|
import { validateRequest } from 'zod-express'
|
|
import { Router } from 'express'
|
|
import { z } from 'zod'
|
|
import {
|
|
saveSsoProviderRegistrationFactory,
|
|
startOIDCSsoProviderValidationFactory
|
|
} from '@/modules/workspaces/services/sso'
|
|
import {
|
|
getOIDCProviderAttributes,
|
|
getProviderAuthorizationUrl,
|
|
initializeIssuerAndClient
|
|
} from '@/modules/workspaces/clients/oidcProvider'
|
|
import { getFrontendOrigin, getServerOrigin } from '@/modules/shared/helpers/envHelper'
|
|
import {
|
|
storeOIDCProviderValidationRequestFactory,
|
|
getOIDCProviderFactory,
|
|
associateSsoProviderWithWorkspaceFactory,
|
|
storeProviderRecordFactory,
|
|
storeUserSsoSessionFactory,
|
|
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 { authorizeResolver } from '@/modules/shared'
|
|
import { Roles } from '@speckle/shared'
|
|
import { createUserEmailFactory } from '@/modules/core/repositories/userEmails'
|
|
import {
|
|
finalizeAuthMiddlewareFactory,
|
|
sessionMiddlewareFactory
|
|
} from '@/modules/auth/middleware'
|
|
import { createAuthorizationCodeFactory } from '@/modules/auth/repositories/apps'
|
|
import { legacyGetUserFactory } from '@/modules/core/repositories/users'
|
|
|
|
const router = Router()
|
|
|
|
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()
|
|
)
|
|
|
|
const buildFinalizeUrl = (workspaceSlug: string): URL =>
|
|
new URL(`workspaces/${workspaceSlug}/?settings=server/general`, getFrontendOrigin())
|
|
|
|
const ssoVerificationStatusKey = 'ssoVerificationStatus'
|
|
|
|
const buildErrorUrl = ({
|
|
err,
|
|
url,
|
|
searchParams
|
|
}: {
|
|
err: unknown
|
|
url: URL
|
|
searchParams?: Record<string, string>
|
|
}): 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/validate',
|
|
sessionMiddleware,
|
|
validateRequest({
|
|
params: z.object({
|
|
workspaceSlug: z.string().min(1)
|
|
}),
|
|
query: oidcProvider
|
|
}),
|
|
async ({ session, params, query, 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 redirectUrl = buildAuthRedirectUrl(params.workspaceSlug)
|
|
const authorizationUrl = await getProviderAuthorizationUrl({
|
|
provider,
|
|
redirectUrl,
|
|
codeVerifier
|
|
})
|
|
session.codeVerifier = await encryptor.encrypt(codeVerifier)
|
|
|
|
// maybe not needed
|
|
encryptor.dispose()
|
|
res?.redirect(authorizationUrl.toString())
|
|
} catch (err) {
|
|
session.destroy(noop)
|
|
const url = buildErrorUrl({
|
|
err,
|
|
url: buildFinalizeUrl(params.workspaceSlug),
|
|
searchParams: query
|
|
})
|
|
res?.redirect(url.toString())
|
|
}
|
|
}
|
|
)
|
|
|
|
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 })
|
|
|
|
let provider: OIDCProvider | null = null
|
|
|
|
if (req.query.validate === 'true') {
|
|
const workspace = await getWorkspaceBySlugFactory({ db })({
|
|
workspaceSlug: req.params.workspaceSlug
|
|
})
|
|
if (!workspace) throw new WorkspaceNotFoundError()
|
|
await authorizeResolver(
|
|
req.context.userId,
|
|
workspace.id,
|
|
Roles.Workspace.Admin,
|
|
req.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 encryptedValidationToken = req.session.codeVerifier
|
|
if (!encryptedValidationToken)
|
|
throw new Error('cannot find verification token, restart the flow')
|
|
|
|
const codeVerifier = await decryptor.decrypt(encryptedValidationToken)
|
|
|
|
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
|
|
}
|
|
},
|
|
finalizeAuthMiddleware
|
|
)
|
|
|
|
export default router
|