Skip to content

Commit

Permalink
fix(sso): gatekeeper (#3442)
Browse files Browse the repository at this point in the history
* feat(workspaces): add workspace sso feature flag

* feat(workspaceSso): wip validate sso

* feat(workspaces): validate and add sso provider to the workspace with user sso sessions

* feat(workspaces): validate and add sso provider to the workspace with user sso sessions

* WIP

* fix(sso): restructure to handle all branches at end of flow

* fix(sso): add and validate emails used for sso

* fix(sso): park progress

* chore(workspaces): review sso login/valdate

* fix(sso): adjust validate url

* chore(sso): auth header puzzle

* fix(sso): happy-path config

* chore(gql): gqlgen

* fix(sso): almost almost

* fix(sso): auth endpoint

* a lil more terse

* fix(sso): light at the end of the tunnel

* fix(sso): improve catch block error messages

* fix(sso): session lifespan => validUntil

* fix(sso): I think we've got it

* feat(sso): limited workspace values for public sso login

* fix(sso): use factory functions

* fix(sso): til decrypt is single-use

* fix(sso): correct usage of access codes

* fix(sso): use finalize middleware in all routes

* chore(sso): cheeky tweak

* fix(sso): move some types around

* fix(sso): stencil final shape I'm sleepy

* fix(sso): more factories more factories

* fix(sso): on to final boss of factories

* fix(sso): needs a haircut but she works

* fix(sso): init rest w function, not side-effects

* fix(sso): /authn => /sso

* chore(sso): errors

* chore(sso): test test test

* chore(sso): test all the corners

* feat(sso): list workspace sso memberships

* chore(sso): tests, expose in rest

* fix(sso): sketch active user auth

* fix(sso): expose search via gql

* fix(sso): active user session information

* chore(sso): sso session test utils

* chore(sso): test sso session repo/services

* chore(sso): gqlgen

* feat(sso): throw error on missing or expired sso session

* chore(sso): tests for SSO access protection

* fix(sso): use gatekeeper to protect sso access

---------

Co-authored-by: Gergő Jedlicska <[email protected]>
Co-authored-by: Mike Tasset <[email protected]>
  • Loading branch information
3 people authored Nov 6, 2024
1 parent f210d9b commit 0ab5311
Show file tree
Hide file tree
Showing 3 changed files with 61 additions and 41 deletions.
7 changes: 7 additions & 0 deletions packages/server/modules/gatekeeper/errors/features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { BaseError } from '@/modules/shared/errors'

export class FeatureAccessForbiddenError extends BaseError {
static defaultMessage = 'Access to feature forbidden by current plan level.'
static code = 'GATEKEEPER_FEATURE_ACCESS_FORBIDDEN_ERROR'
static statusCode = 403
}
6 changes: 6 additions & 0 deletions packages/server/modules/workspaces/helpers/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { buildDecryptor, buildEncryptor } from '@/modules/shared/utils/libsodium
import { SsoVerificationCodeMissingError } from '@/modules/workspaces/errors/sso'
import { Request } from 'express'

declare module 'express-session' {
interface SessionData {
workspaceId?: string
}
}

/**
* Generate Speckle URL to redirect users to after they complete authorization
* with the given SSO provider.
Expand Down
89 changes: 48 additions & 41 deletions packages/server/modules/workspaces/rest/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {
getProviderAuthorizationUrl,
initializeIssuerAndClient
} from '@/modules/workspaces/clients/oidcProvider'
import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper'
import {
adminOverrideEnabled,
getFeatureFlags,
isProdEnv
} from '@/modules/shared/helpers/envHelper'
import {
storeOIDCProviderValidationRequestFactory,
getOIDCProviderValidationRequestFactory,
Expand Down Expand Up @@ -112,6 +116,9 @@ import {
SsoVerificationCodeMissingError
} from '@/modules/workspaces/errors/sso'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { FeatureAccessForbiddenError } from '@/modules/gatekeeper/errors/features'
import { canWorkspaceUseOidcSsoFactory } from '@/modules/gatekeeper/services/featureAuthorization'
import { getWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing'

const moveAuthParamsToSessionMiddleware = moveAuthParamsToSessionMiddlewareFactory()
const sessionMiddleware = sessionMiddlewareFactory()
Expand All @@ -120,6 +127,37 @@ const finalizeAuthMiddleware = finalizeAuthMiddlewareFactory({
getUser: legacyGetUserFactory({ db })
})

const moveWorkspaceIdToSessionMiddleware: RequestHandler<
WorkspaceSsoAuthRequestParams
> = async (req, _res, next) => {
const workspace = await getWorkspaceBySlugFactory({ db })({
workspaceSlug: req.params.workspaceSlug
})
req.session.workspaceId = workspace?.id
next()
}

const validateFeatureAccessMiddlewareFactory: 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) {
res?.redirect(buildErrorUrl(e, req.params.workspaceSlug))
}
}

export const getSsoRouter = (): Router => {
const router = Router()

Expand All @@ -143,13 +181,14 @@ export const getSsoRouter = (): Router => {
'/api/v1/workspaces/:workspaceSlug/sso/auth',
sessionMiddleware,
moveAuthParamsToSessionMiddleware,
moveWorkspaceIdToSessionMiddleware,
validateFeatureAccessMiddlewareFactory,
validateRequest({
params: z.object({
workspaceSlug: z.string().min(1)
})
}),
handleSsoAuthRequestFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }),
getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({
db,
decrypt: getDecryptor()
Expand All @@ -161,37 +200,15 @@ export const getSsoRouter = (): Router => {
'/api/v1/workspaces/:workspaceSlug/sso/oidc/validate',
sessionMiddleware,
moveAuthParamsToSessionMiddleware,
moveWorkspaceIdToSessionMiddleware,
validateFeatureAccessMiddlewareFactory,
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
})
})
)

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({
Expand Down Expand Up @@ -348,21 +365,16 @@ const handleGetLimitedWorkspaceRequestFactory =
*/
const handleSsoAuthRequestFactory =
({
getWorkspaceBySlug,
getWorkspaceSsoProvider
}: {
getWorkspaceBySlug: GetWorkspaceBySlug
getWorkspaceSsoProvider: GetWorkspaceSsoProvider
}): RequestHandler<WorkspaceSsoAuthRequestParams> =>
async ({ params, session, res }) => {
try {
const workspace = await getWorkspaceBySlug({
workspaceSlug: params.workspaceSlug
})
if (!workspace) throw new WorkspaceNotFoundError()
if (!session.workspaceId) throw new WorkspaceNotFoundError()

const { provider } =
(await getWorkspaceSsoProvider({ workspaceId: workspace.id })) ?? {}
(await getWorkspaceSsoProvider({ workspaceId: session.workspaceId })) ?? {}
if (!provider) throw new SsoProviderMissingError()

const codeVerifier = generators.codeVerifier()
Expand All @@ -387,10 +399,8 @@ type WorkspaceSsoValidationRequestQuery = z.infer<typeof oidcProvider>
*/
const handleSsoValidationRequestFactory =
({
getWorkspaceBySlug,
startOidcSsoProviderValidation
}: {
getWorkspaceBySlug: GetWorkspaceBySlug
startOidcSsoProviderValidation: ReturnType<
typeof startOidcSsoProviderValidationFactory
>
Expand All @@ -402,14 +412,11 @@ const handleSsoValidationRequestFactory =
> =>
async ({ session, params, query: provider, res, context }) => {
try {
const workspace = await getWorkspaceBySlug({
workspaceSlug: params.workspaceSlug
})
if (!workspace) throw new WorkspaceNotFoundError()
if (!session.workspaceId) throw new WorkspaceNotFoundError()

await authorizeResolver(
context.userId,
workspace.id,
session.workspaceId,
Roles.Workspace.Admin,
context.resourceAccessRules
)
Expand Down Expand Up @@ -521,7 +528,7 @@ const handleOidcCallbackFactory =
}
})

req.authRedirectPath = buildFinalizeUrl(workspace.slug).toString()
req.authRedirectPath = buildFinalizeUrl(req.params.workspaceSlug).toString()
}

const createOidcProviderFactory =
Expand Down

0 comments on commit 0ab5311

Please sign in to comment.