diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 934de0a81..b47d17ba1 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -3518,6 +3518,13 @@ export type User = { /** Only returned if API user is the user being requested or an admin */ email?: Maybe; emails: Array; + /** + * A list of workspaces for the active user where: + * (1) The user is a member or admin + * (2) The workspace has SSO provider enabled + * (3) The user does not have a valid SSO session for the given SSO provider + */ + expiredSsoSessions: Array; /** * All the streams that a active user has favorited. * Note: You can't use this to retrieve another user's favorite streams. @@ -3996,6 +4003,8 @@ export type Workspace = { /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ role?: Maybe; slug: Scalars['String']['output']; + /** Information about the workspace's SSO configuration and the current user's SSO session, if present */ + sso?: Maybe; subscription?: Maybe; team: WorkspaceCollaboratorCollection; updatedAt: Scalars['DateTime']['output']; @@ -4311,6 +4320,27 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export type WorkspaceSso = { + __typename?: 'WorkspaceSso'; + /** If null, the workspace does not have SSO configured */ + provider?: Maybe; + session?: Maybe; +}; + +export type WorkspaceSsoProvider = { + __typename?: 'WorkspaceSsoProvider'; + clientId: Scalars['String']['output']; + id: Scalars['ID']['output']; + issuerUrl: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type WorkspaceSsoSession = { + __typename?: 'WorkspaceSsoSession'; + createdAt: Scalars['DateTime']['output']; + validUntil: Scalars['DateTime']['output']; +}; + export type WorkspaceSubscription = { __typename?: 'WorkspaceSubscription'; billingInterval: BillingInterval; @@ -6404,6 +6434,9 @@ export type AllObjectTypes = { WorkspaceMutations: WorkspaceMutations, WorkspacePlan: WorkspacePlan, WorkspaceProjectMutations: WorkspaceProjectMutations, + WorkspaceSso: WorkspaceSso, + WorkspaceSsoProvider: WorkspaceSsoProvider, + WorkspaceSsoSession: WorkspaceSsoSession, WorkspaceSubscription: WorkspaceSubscription, WorkspaceVersionsCount: WorkspaceVersionsCount, } @@ -7351,6 +7384,7 @@ export type UserFieldArgs = { discoverableWorkspaces: {}, email: {}, emails: {}, + expiredSsoSessions: {}, favoriteStreams: UserFavoriteStreamsArgs, hasPendingVerification: {}, id: {}, @@ -7497,6 +7531,7 @@ export type WorkspaceFieldArgs = { projects: WorkspaceProjectsArgs, role: {}, slug: {}, + sso: {}, subscription: {}, team: WorkspaceTeamArgs, updatedAt: {}, @@ -7574,6 +7609,20 @@ export type WorkspaceProjectMutationsFieldArgs = { moveToWorkspace: WorkspaceProjectMutationsMoveToWorkspaceArgs, updateRole: WorkspaceProjectMutationsUpdateRoleArgs, } +export type WorkspaceSsoFieldArgs = { + provider: {}, + session: {}, +} +export type WorkspaceSsoProviderFieldArgs = { + clientId: {}, + id: {}, + issuerUrl: {}, + name: {}, +} +export type WorkspaceSsoSessionFieldArgs = { + createdAt: {}, + validUntil: {}, +} export type WorkspaceSubscriptionFieldArgs = { billingInterval: {}, createdAt: {}, @@ -7726,6 +7775,9 @@ export type AllObjectFieldArgTypes = { WorkspaceMutations: WorkspaceMutationsFieldArgs, WorkspacePlan: WorkspacePlanFieldArgs, WorkspaceProjectMutations: WorkspaceProjectMutationsFieldArgs, + WorkspaceSso: WorkspaceSsoFieldArgs, + WorkspaceSsoProvider: WorkspaceSsoProviderFieldArgs, + WorkspaceSsoSession: WorkspaceSsoSessionFieldArgs, WorkspaceSubscription: WorkspaceSubscriptionFieldArgs, WorkspaceVersionsCount: WorkspaceVersionsCountFieldArgs, } diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index 36af863d6..795c8b561 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -319,6 +319,10 @@ type Workspace { """ billing: WorkspaceBilling @hasWorkspaceRole(role: MEMBER) """ + Information about the workspace's SSO configuration and the current user's SSO session, if present + """ + sso: WorkspaceSso + """ Enable/Disable restriction to invite users to workspace as Guests only """ domainBasedMembershipProtectionEnabled: Boolean! @@ -328,6 +332,26 @@ type Workspace { discoverabilityEnabled: Boolean! } +type WorkspaceSso { + """ + If null, the workspace does not have SSO configured + """ + provider: WorkspaceSsoProvider + session: WorkspaceSsoSession +} + +type WorkspaceSsoProvider { + id: ID! + name: String! + clientId: String! + issuerUrl: String! +} + +type WorkspaceSsoSession { + createdAt: DateTime! + validUntil: DateTime! +} + """ Workspace metadata visible to non-workspace members. """ @@ -446,6 +470,14 @@ extend type User { """ discoverableWorkspaces: [LimitedWorkspace!]! + """ + A list of workspaces for the active user where: + (1) The user is a member or admin + (2) The workspace has SSO provider enabled + (3) The user does not have a valid SSO session for the given SSO provider + """ + expiredSsoSessions: [LimitedWorkspace!]! + """ Get the workspaces for the user """ diff --git a/packages/server/codegen.yml b/packages/server/codegen.yml index 3dce498f2..172f2fb06 100644 --- a/packages/server/codegen.yml +++ b/packages/server/codegen.yml @@ -57,6 +57,7 @@ generates: UserAutomateInfo: '@/modules/automate/helpers/graphTypes#UserAutomateInfoGraphQLReturn' Workspace: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceGraphQLReturn' WorkspaceBilling: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceBillingGraphQLReturn' + WorkspaceSso: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceSsoGraphQLReturn' WorkspaceMutations: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceMutationsGraphQLReturn' WorkspaceInviteMutations: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceInviteMutationsGraphQLReturn' WorkspaceProjectMutations: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceProjectMutationsGraphQLReturn' diff --git a/packages/server/modules/core/graph/directives/hasRole.ts b/packages/server/modules/core/graph/directives/hasRole.ts index ad411854d..3ce189e58 100644 --- a/packages/server/modules/core/graph/directives/hasRole.ts +++ b/packages/server/modules/core/graph/directives/hasRole.ts @@ -19,6 +19,7 @@ import { getUserServerRoleFactory } from '@/modules/shared/repositories/acl' import { getStreamFactory } from '@/modules/core/repositories/streams' +import { getEventBus } from '@/modules/shared/services/eventBus' const getStream = getStreamFactory({ db }) const throwForNotHavingServerRole = throwForNotHavingServerRoleFactory({ @@ -31,7 +32,8 @@ const authorizeResolver = authorizeResolverFactory({ adminOverrideEnabled, getUserServerRole: getUserServerRoleFactory({ db }), getStream, - getUserAclRole: getUserAclRoleFactory({ db }) + getUserAclRole: getUserAclRoleFactory({ db }), + emitWorkspaceEvent: getEventBus().emit }) /** diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 4ab994a79..b5f7ef7c1 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -5,7 +5,7 @@ import { CommentReplyAuthorCollectionGraphQLReturn, CommentGraphQLReturn } from import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/helpers/graphTypes'; import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types'; import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes'; -import { WorkspaceGraphQLReturn, WorkspaceBillingGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, ProjectRoleGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes'; +import { WorkspaceGraphQLReturn, WorkspaceBillingGraphQLReturn, WorkspaceSsoGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, ProjectRoleGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes'; import { WorkspaceBillingMutationsGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes'; import { WebhookGraphQLReturn } from '@/modules/webhooks/helpers/graphTypes'; import { SmartTextEditorValueSchema } from '@/modules/core/services/richTextEditorService'; @@ -3540,6 +3540,13 @@ export type User = { /** Only returned if API user is the user being requested or an admin */ email?: Maybe; emails: Array; + /** + * A list of workspaces for the active user where: + * (1) The user is a member or admin + * (2) The workspace has SSO provider enabled + * (3) The user does not have a valid SSO session for the given SSO provider + */ + expiredSsoSessions: Array; /** * All the streams that a active user has favorited. * Note: You can't use this to retrieve another user's favorite streams. @@ -4018,6 +4025,8 @@ export type Workspace = { /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ role?: Maybe; slug: Scalars['String']['output']; + /** Information about the workspace's SSO configuration and the current user's SSO session, if present */ + sso?: Maybe; subscription?: Maybe; team: WorkspaceCollaboratorCollection; updatedAt: Scalars['DateTime']['output']; @@ -4333,6 +4342,27 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export type WorkspaceSso = { + __typename?: 'WorkspaceSso'; + /** If null, the workspace does not have SSO configured */ + provider?: Maybe; + session?: Maybe; +}; + +export type WorkspaceSsoProvider = { + __typename?: 'WorkspaceSsoProvider'; + clientId: Scalars['String']['output']; + id: Scalars['ID']['output']; + issuerUrl: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type WorkspaceSsoSession = { + __typename?: 'WorkspaceSsoSession'; + createdAt: Scalars['DateTime']['output']; + validUntil: Scalars['DateTime']['output']; +}; + export type WorkspaceSubscription = { __typename?: 'WorkspaceSubscription'; billingInterval: BillingInterval; @@ -4710,6 +4740,9 @@ export type ResolversTypes = { WorkspaceRole: WorkspaceRole; WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; + WorkspaceSso: ResolverTypeWrapper; + WorkspaceSsoProvider: ResolverTypeWrapper; + WorkspaceSsoSession: ResolverTypeWrapper; WorkspaceSubscription: ResolverTypeWrapper; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; @@ -4960,6 +4993,9 @@ export type ResolversParentTypes = { WorkspaceProjectsFilter: WorkspaceProjectsFilter; WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput; WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput; + WorkspaceSso: WorkspaceSsoGraphQLReturn; + WorkspaceSsoProvider: WorkspaceSsoProvider; + WorkspaceSsoSession: WorkspaceSsoSession; WorkspaceSubscription: WorkspaceSubscription; WorkspaceTeamFilter: WorkspaceTeamFilter; WorkspaceUpdateInput: WorkspaceUpdateInput; @@ -6179,6 +6215,7 @@ export type UserResolvers, ParentType, ContextType>; email?: Resolver, ParentType, ContextType>; emails?: Resolver, ParentType, ContextType>; + expiredSsoSessions?: Resolver, ParentType, ContextType>; favoriteStreams?: Resolver>; hasPendingVerification?: Resolver, ParentType, ContextType>; id?: Resolver; @@ -6361,6 +6398,7 @@ export type WorkspaceResolvers>; role?: Resolver, ParentType, ContextType>; slug?: Resolver; + sso?: Resolver, ParentType, ContextType>; subscription?: Resolver, ParentType, ContextType>; team?: Resolver>; updatedAt?: Resolver; @@ -6466,6 +6504,26 @@ export type WorkspaceProjectMutationsResolvers; }; +export type WorkspaceSsoResolvers = { + provider?: Resolver, ParentType, ContextType>; + session?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WorkspaceSsoProviderResolvers = { + clientId?: Resolver; + id?: Resolver; + issuerUrl?: Resolver; + name?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WorkspaceSsoSessionResolvers = { + createdAt?: Resolver; + validUntil?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type WorkspaceSubscriptionResolvers = { billingInterval?: Resolver; createdAt?: Resolver; @@ -6627,6 +6685,9 @@ export type Resolvers = { WorkspaceMutations?: WorkspaceMutationsResolvers; WorkspacePlan?: WorkspacePlanResolvers; WorkspaceProjectMutations?: WorkspaceProjectMutationsResolvers; + WorkspaceSso?: WorkspaceSsoResolvers; + WorkspaceSsoProvider?: WorkspaceSsoProviderResolvers; + WorkspaceSsoSession?: WorkspaceSsoSessionResolvers; WorkspaceSubscription?: WorkspaceSubscriptionResolvers; WorkspaceVersionsCount?: WorkspaceVersionsCountResolvers; }; diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 92f656681..586605e13 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -3521,6 +3521,13 @@ export type User = { /** Only returned if API user is the user being requested or an admin */ email?: Maybe; emails: Array; + /** + * A list of workspaces for the active user where: + * (1) The user is a member or admin + * (2) The workspace has SSO provider enabled + * (3) The user does not have a valid SSO session for the given SSO provider + */ + expiredSsoSessions: Array; /** * All the streams that a active user has favorited. * Note: You can't use this to retrieve another user's favorite streams. @@ -3999,6 +4006,8 @@ export type Workspace = { /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ role?: Maybe; slug: Scalars['String']['output']; + /** Information about the workspace's SSO configuration and the current user's SSO session, if present */ + sso?: Maybe; subscription?: Maybe; team: WorkspaceCollaboratorCollection; updatedAt: Scalars['DateTime']['output']; @@ -4314,6 +4323,27 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export type WorkspaceSso = { + __typename?: 'WorkspaceSso'; + /** If null, the workspace does not have SSO configured */ + provider?: Maybe; + session?: Maybe; +}; + +export type WorkspaceSsoProvider = { + __typename?: 'WorkspaceSsoProvider'; + clientId: Scalars['String']['output']; + id: Scalars['ID']['output']; + issuerUrl: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type WorkspaceSsoSession = { + __typename?: 'WorkspaceSsoSession'; + createdAt: Scalars['DateTime']['output']; + validUntil: Scalars['DateTime']['output']; +}; + export type WorkspaceSubscription = { __typename?: 'WorkspaceSubscription'; billingInterval: BillingInterval; diff --git a/packages/server/modules/shared/index.ts b/packages/server/modules/shared/index.ts index c04e122ce..0bd2cca59 100644 --- a/packages/server/modules/shared/index.ts +++ b/packages/server/modules/shared/index.ts @@ -10,6 +10,7 @@ import { authorizeResolverFactory, validateScopesFactory } from '@/modules/shared/services/auth' +import { getEventBus } from '@/modules/shared/services/eventBus' import { pubsub, StreamSubscriptions, @@ -30,5 +31,6 @@ export const authorizeResolver = authorizeResolverFactory({ adminOverrideEnabled, getUserServerRole: getUserServerRoleFactory({ db }), getStream: getStreamFactory({ db }), - getUserAclRole: getUserAclRoleFactory({ db }) + getUserAclRole: getUserAclRoleFactory({ db }), + emitWorkspaceEvent: getEventBus().emit }) diff --git a/packages/server/modules/shared/services/auth.ts b/packages/server/modules/shared/services/auth.ts index da99780d6..16ffeb81f 100644 --- a/packages/server/modules/shared/services/auth.ts +++ b/packages/server/modules/shared/services/auth.ts @@ -13,7 +13,8 @@ import { import { GetRoles } from '@/modules/shared/domain/rolesAndScopes/operations' import { ForbiddenError } from '@/modules/shared/errors' import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' -import { Roles } from '@speckle/shared' +import { EventBusEmit } from '@/modules/shared/services/eventBus' +import { isNullOrUndefined, Roles } from '@speckle/shared' /** * Validates the scope against a list of scopes of the current session. @@ -38,6 +39,7 @@ export const authorizeResolverFactory = getUserServerRole: GetUserServerRole getStream: GetStream getUserAclRole: GetUserAclRole + emitWorkspaceEvent: EventBusEmit }): AuthorizeResolver => async (userId, resourceId, requiredRole, userResourceAccessLimits) => { userId = userId || null @@ -65,21 +67,30 @@ export const authorizeResolverFactory = if (serverRole === Roles.Server.Admin) return } + let targetWorkspaceId: string | null = null + if (role.resourceTarget === RoleResourceTargets.Streams) { const stream = await deps.getStream({ userId: userId || undefined, streamId: resourceId }) + if (!stream) { throw new ForbiddenError( `Resource of type ${role.resourceTarget} with ${resourceId} not found` ) } + targetWorkspaceId = stream.workspaceId + const isPublic = !!stream?.isPublic if (isPublic && role.weight < 200) return } + if (role.resourceTarget === RoleResourceTargets.Workspaces) { + targetWorkspaceId = resourceId + } + const userAclRole = userId ? await deps.getUserAclRole({ aclTableName: role.aclTableName, @@ -94,6 +105,17 @@ export const authorizeResolverFactory = const fullRole = roles.find((r) => r.name === userAclRole) - if (fullRole && fullRole.weight >= role.weight) return - throw new ForbiddenError('You are not authorized.') + if (fullRole && fullRole.weight < role.weight) { + throw new ForbiddenError('You are not authorized.') + } + + if (!isNullOrUndefined(targetWorkspaceId)) { + await deps.emitWorkspaceEvent({ + eventName: 'workspace.authorized', + payload: { + workspaceId: targetWorkspaceId, + userId + } + }) + } } diff --git a/packages/server/modules/workspaces/domain/sso/logic.ts b/packages/server/modules/workspaces/domain/sso/logic.ts index cd06b6ff3..09700b911 100644 --- a/packages/server/modules/workspaces/domain/sso/logic.ts +++ b/packages/server/modules/workspaces/domain/sso/logic.ts @@ -1,3 +1,5 @@ +import { UserSsoSessionRecord } from '@/modules/workspaces/domain/sso/types' + /** * Get the default expiration time for an SSO session based on the current time. * TODO: Is 7 days a good default session length? @@ -7,3 +9,7 @@ export const getDefaultSsoSessionExpirationDate = (): Date => { now.setDate(now.getDate() + 7) return now } + +export const isValidSsoSession = (session: UserSsoSessionRecord): boolean => { + return session.validUntil.getTime() > new Date().getTime() +} diff --git a/packages/server/modules/workspaces/domain/sso/operations.ts b/packages/server/modules/workspaces/domain/sso/operations.ts index c9453a8f6..5e7a7b5ec 100644 --- a/packages/server/modules/workspaces/domain/sso/operations.ts +++ b/packages/server/modules/workspaces/domain/sso/operations.ts @@ -1,10 +1,11 @@ import type { - ProviderRecord, + SsoProviderRecord, WorkspaceSsoProvider, UserSsoSessionRecord, OidcProvider, OidcProviderAttributes, - OidcProviderValidationRequest + OidcProviderValidationRequest, + WorkspaceSsoProviderRecord } from '@/modules/workspaces/domain/sso/types' import { Workspace } from '@/modules/workspacesCore/domain/types' @@ -15,12 +16,17 @@ export type AssociateSsoProviderWithWorkspace = (args: { providerId: string }) => Promise +/** Get and decrypt the full set of information about a given workspace's SSO provider */ export type GetWorkspaceSsoProvider = (args: { workspaceId: string }) => Promise +export type GetWorkspaceSsoProviderRecord = (args: { + workspaceId: string +}) => Promise + export type StoreProviderRecord = (args: { - providerRecord: ProviderRecord + providerRecord: SsoProviderRecord }) => Promise // User session management @@ -34,6 +40,17 @@ export type ListWorkspaceSsoMemberships = (args: { userId: string }) => Promise +export type ListUserSsoSessions = (args: { + userId: string + // Optional workspaces to limit search to + workspaceIds?: string[] +}) => Promise<(UserSsoSessionRecord & WorkspaceSsoProviderRecord)[]> + +export type GetUserSsoSession = (args: { + userId: string + workspaceId: string +}) => Promise<(UserSsoSessionRecord & WorkspaceSsoProviderRecord) | null> + export type UpsertUserSsoSession = (args: { userSsoSession: UserSsoSessionRecord }) => Promise diff --git a/packages/server/modules/workspaces/domain/sso/types.ts b/packages/server/modules/workspaces/domain/sso/types.ts index ea2909442..7cfcb25aa 100644 --- a/packages/server/modules/workspaces/domain/sso/types.ts +++ b/packages/server/modules/workspaces/domain/sso/types.ts @@ -16,13 +16,15 @@ export type OidcProviderRecord = { // 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 SsoProviderRecord = OidcProviderRecord -export type WorkspaceSsoProvider = { +export type WorkspaceSsoProviderRecord = { workspaceId: string - // Equals id in `ProviderRecord` (used for join) + // Matches `id` in `SsoProviderRecord` providerId: string -} & ProviderRecord +} + +export type WorkspaceSsoProvider = WorkspaceSsoProviderRecord & SsoProviderRecord export type UserSsoSessionRecord = { userId: string diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index a112eb15b..30356d9b8 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -156,8 +156,18 @@ import { import { getServerInfoFactory } from '@/modules/core/repositories/server' import { commandFactory } from '@/modules/shared/command' import { withTransaction } from '@/modules/shared/helpers/dbHelper' -import { listWorkspaceSsoMembershipsByUserEmailFactory } from '@/modules/workspaces/services/sso' -import { listWorkspaceSsoMembershipsFactory } from '@/modules/workspaces/repositories/sso' +import { + listUserExpiredSsoSessionsFactory, + listWorkspaceSsoMembershipsByUserEmailFactory +} from '@/modules/workspaces/services/sso' +import { + getUserSsoSessionFactory, + getWorkspaceSsoProviderFactory, + getWorkspaceSsoProviderRecordFactory, + listUserSsoSessionsFactory, + listWorkspaceSsoMembershipsFactory +} from '@/modules/workspaces/repositories/sso' +import { getDecryptor } from '@/modules/workspaces/helpers/sso' const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) @@ -873,7 +883,12 @@ export = FF_WORKSPACES_MODULE_ENABLED domains: async (parent) => { return await getWorkspaceDomainsFactory({ db })({ workspaceIds: [parent.id] }) }, - billing: (parent) => ({ parent }) + billing: (parent) => ({ parent }), + sso: async (parent) => { + return await getWorkspaceSsoProviderRecordFactory({ db })({ + workspaceId: parent.id + }) + } }, WorkspaceBilling: { versionsCount: async ({ parent }) => { @@ -897,6 +912,30 @@ export = FF_WORKSPACES_MODULE_ENABLED })({ workspaceId }) } }, + WorkspaceSso: { + provider: async ({ workspaceId }) => { + const provider = await getWorkspaceSsoProviderFactory({ + db, + decrypt: getDecryptor() + })({ + workspaceId + }) + if (!provider) return null + + return { + id: provider.id, + name: provider.provider.providerName, + clientId: provider.provider.clientId, + issuerUrl: provider.provider.issuerUrl + } + }, + session: async (parent, _args, context) => { + return await getUserSsoSessionFactory({ db })({ + userId: context.userId!, + workspaceId: parent.workspaceId + }) + } + }, WorkspaceCollaborator: { user: async (parent) => { return parent @@ -991,20 +1030,31 @@ export = FF_WORKSPACES_MODULE_ENABLED return await getDiscoverableWorkspacesForUser({ userId: context.userId }) }, + expiredSsoSessions: async (_parent, _args, context) => { + if (!context.userId) { + throw new WorkspacesNotAuthorizedError() + } + + const listExpiredSsoSessions = listUserExpiredSsoSessionsFactory({ + listWorkspaceSsoMemberships: listWorkspaceSsoMembershipsFactory({ db }), + listUserSsoSessions: listUserSsoSessionsFactory({ db }) + }) + + return await listExpiredSsoSessions({ userId: context.userId }) + }, workspaces: async (_parent, _args, context) => { if (!context.userId) { throw new WorkspacesNotAuthorizedError() } - const getWorkspace = getWorkspaceFactory({ db }) - const getWorkspaceRolesForUser = getWorkspaceRolesForUserFactory({ db }) - - const getWorkspacesForUser = getWorkspacesForUserFactory({ - getWorkspace, - getWorkspaceRolesForUser + const getWorkspaces = getWorkspacesForUserFactory({ + getWorkspace: getWorkspaceFactory({ db }), + getWorkspaceRolesForUser: getWorkspaceRolesForUserFactory({ db }) }) - const workspaces = await getWorkspacesForUser({ userId: context.userId }) + const workspaces = await getWorkspaces({ + userId: context.userId + }) // TODO: Pagination return { diff --git a/packages/server/modules/workspaces/index.ts b/packages/server/modules/workspaces/index.ts index 38f456bf0..7d13304e4 100644 --- a/packages/server/modules/workspaces/index.ts +++ b/packages/server/modules/workspaces/index.ts @@ -40,7 +40,6 @@ const workspacesModule: SpeckleModule = { if (FF_WORKSPACES_SSO_ENABLED) app.use(getSsoRouter()) if (isInitial) { - // register the SSO endpoints quitListeners = initializeEventListenersFactory({ db })() } await Promise.all([initScopes(), initRoles()]) diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index a6a178fae..dd62c5dbe 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -6,11 +6,15 @@ import { StoreOidcProviderValidationRequest, StoreProviderRecord, UpsertUserSsoSession, - ListWorkspaceSsoMemberships + ListWorkspaceSsoMemberships, + GetWorkspaceSsoProviderRecord, + ListUserSsoSessions, + GetUserSsoSession } from '@/modules/workspaces/domain/sso/operations' import { - ProviderRecord, - UserSsoSessionRecord + SsoProviderRecord, + UserSsoSessionRecord, + WorkspaceSsoProviderRecord } from '@/modules/workspaces/domain/sso/types' import { SsoProviderTypeNotSupportedError } from '@/modules/workspaces/errors/sso' import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' @@ -20,13 +24,12 @@ import { omit } from 'lodash' type Crypt = (input: string) => Promise -type SsoProviderRecord = Omit & { +type EncryptedSsoProviderRecord = Omit & { encryptedProviderData: string } -type WorkspaceSsoProviderRecord = { workspaceId: string; providerId: string } const tables = { - ssoProviders: (db: Knex) => db('sso_providers'), + ssoProviders: (db: Knex) => db('sso_providers'), userSsoSessions: (db: Knex) => db('user_sso_sessions'), workspaceAcl: (db: Knex) => db('workspace_acl'), workspaceSsoProviders: (db: Knex) => @@ -61,7 +64,7 @@ export const getWorkspaceSsoProviderFactory = async ({ workspaceId }) => { const maybeProvider = await tables .workspaceSsoProviders(db) - .select('*') + .select('*') .where({ workspaceId }) .join('sso_providers', 'id', 'providerId') .first() @@ -81,6 +84,14 @@ export const getWorkspaceSsoProviderFactory = } } +export const getWorkspaceSsoProviderRecordFactory = + ({ db }: { db: Knex }): GetWorkspaceSsoProviderRecord => + async ({ workspaceId }) => { + return ( + (await tables.workspaceSsoProviders(db).where({ workspaceId }).first()) ?? null + ) + } + export const storeSsoProviderRecordFactory = ({ db, encrypt }: { db: Knex; encrypt: Crypt }): StoreProviderRecord => async ({ providerRecord }) => { @@ -105,6 +116,42 @@ export const upsertUserSsoSessionFactory = .merge(['createdAt', 'validUntil']) } +const listUserSsoSessionsBaseQuery = + ({ db }: { db: Knex }) => + (args: { userId: string; workspaceIds?: string[] }) => { + const q = tables + .userSsoSessions(db) + .select('*') + .join( + 'workspace_sso_providers', + 'workspace_sso_providers.providerId', + 'user_sso_sessions.providerId' + ) + .where({ userId: args.userId }) + + if (args.workspaceIds) { + q.whereIn('workspaceId', args.workspaceIds) + } + + return q + } + +export const listUserSsoSessionsFactory = + ({ db }: { db: Knex }): ListUserSsoSessions => + async ({ userId, workspaceIds }) => { + return await listUserSsoSessionsBaseQuery({ db })({ userId, workspaceIds }) + } + +export const getUserSsoSessionFactory = + ({ db }: { db: Knex }): GetUserSsoSession => + async ({ userId, workspaceId }) => { + const sessions = await listUserSsoSessionsBaseQuery({ db })({ + userId, + workspaceIds: [workspaceId] + }) + return sessions[0] ?? null + } + export const listWorkspaceSsoMembershipsFactory = ({ db }: { db: Knex }): ListWorkspaceSsoMemberships => async ({ userId }) => { diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 36a41c7e6..d5389367e 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -111,6 +111,7 @@ import { SsoUserEmailUnverifiedError, SsoVerificationCodeMissingError } from '@/modules/workspaces/errors/sso' +import { getEventBus } from '@/modules/shared/services/eventBus' const moveAuthParamsToSessionMiddleware = moveAuthParamsToSessionMiddlewareFactory() const sessionMiddleware = sessionMiddlewareFactory() @@ -219,7 +220,8 @@ export const getSsoRouter = (): Router => { getRoles: getRolesFactory({ db: trx }), getUserServerRole: getUserServerRoleFactory({ db: trx }), getStream: getStreamFactory({ db: trx }), - getUserAclRole: getUserAclRoleFactory({ db: trx }) + getUserAclRole: getUserAclRoleFactory({ db: trx }), + emitWorkspaceEvent: getEventBus().emit }), getWorkspaceBySlug: getWorkspaceBySlugFactory({ db: trx }), createOidcProvider: createOidcProviderFactory({ diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index da0951f32..337321f0d 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -6,7 +6,8 @@ import { StoreProviderRecord, AssociateSsoProviderWithWorkspace, GetWorkspaceSsoProvider, - ListWorkspaceSsoMemberships + ListWorkspaceSsoMemberships, + ListUserSsoSessions } from '@/modules/workspaces/domain/sso/operations' import { OidcProvider, @@ -36,6 +37,7 @@ import { } from '@/modules/workspaces/errors/sso' import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace' import { LimitedWorkspace } from '@/modules/workspacesCore/domain/types' +import { isValidSsoSession } from '@/modules/workspaces/domain/sso/logic' // this probably should go a lean validation endpoint too const validateOidcProviderAttributes = ({ @@ -253,3 +255,25 @@ export const listWorkspaceSsoMembershipsByUserEmailFactory = // Return limited workspace version of each workspace return workspaces.map(toLimitedWorkspace) } + +export const listUserExpiredSsoSessionsFactory = + ({ + listWorkspaceSsoMemberships, + listUserSsoSessions + }: { + listWorkspaceSsoMemberships: ListWorkspaceSsoMemberships + listUserSsoSessions: ListUserSsoSessions + }) => + async (args: { userId: string }): Promise => { + const workspaces = await listWorkspaceSsoMemberships({ userId: args.userId }) + const sessions = await listUserSsoSessions({ userId: args.userId }) + + const validSessions = sessions.filter(isValidSsoSession) + + return workspaces + .filter( + (workspace) => + !validSessions.some((session) => session.workspaceId === workspace.id) + ) + .map(toLimitedWorkspace) + } diff --git a/packages/server/modules/workspaces/tests/helpers/creation.ts b/packages/server/modules/workspaces/tests/helpers/creation.ts index 672115c67..6ca6198a8 100644 --- a/packages/server/modules/workspaces/tests/helpers/creation.ts +++ b/packages/server/modules/workspaces/tests/helpers/creation.ts @@ -47,10 +47,16 @@ 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 { + associateSsoProviderWithWorkspaceFactory, + getWorkspaceSsoProviderRecordFactory, + storeSsoProviderRecordFactory, + upsertUserSsoSessionFactory +} 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' +import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic' export type BasicTestWorkspace = { /** @@ -250,6 +256,7 @@ export const createWorkspaceInviteDirectly = async ( } export const createTestOidcProvider = async ( + workspaceId: string, providerData: Partial = {} ) => { const providerId = cryptoRandomString({ length: 9 }) @@ -268,5 +275,27 @@ export const createTestOidcProvider = async ( } } }) + await associateSsoProviderWithWorkspaceFactory({ db })({ + workspaceId, + providerId + }) return providerId } + +export const createTestSsoSession = async ( + userId: string, + workspaceId: string, + validUntil?: Date +) => { + const { providerId } = + (await getWorkspaceSsoProviderRecordFactory({ db })({ workspaceId })) ?? {} + if (!providerId) throw new Error('No provider found') + await upsertUserSsoSessionFactory({ db })({ + userSsoSession: { + userId, + providerId, + createdAt: new Date(), + validUntil: validUntil ?? getDefaultSsoSessionExpirationDate() + } + }) +} diff --git a/packages/server/modules/workspaces/tests/integration/sso.spec.ts b/packages/server/modules/workspaces/tests/integration/sso.spec.ts index b8b336444..8d77da647 100644 --- a/packages/server/modules/workspaces/tests/integration/sso.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/sso.spec.ts @@ -1,27 +1,31 @@ import { - associateSsoProviderWithWorkspaceFactory, + getUserSsoSessionFactory, getWorkspaceSsoProviderFactory, + listUserSsoSessionsFactory, listWorkspaceSsoMembershipsFactory, upsertUserSsoSessionFactory } from '@/modules/workspaces/repositories/sso' import { BasicTestWorkspace, createTestOidcProvider, - createTestWorkspace + createTestSsoSession, + createTestWorkspace, + createTestWorkspaces } from '@/modules/workspaces/tests/helpers/creation' -import { BasicTestUser, createTestUser } from '@/test/authHelper' +import { BasicTestUser, createTestUser, createTestUsers } 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' +import { truncateTables } from '@/test/hooks' +import { isValidSsoSession } from '@/modules/workspaces/domain/sso/logic' -const associateSsoProviderWithWorkspace = associateSsoProviderWithWorkspaceFactory({ - db -}) +const listUserSsoSessions = listUserSsoSessionsFactory({ db }) const listWorkspaceSsoMemberships = listWorkspaceSsoMembershipsFactory({ db }) const upsertUserSsoSession = upsertUserSsoSessionFactory({ db }) +const getUserSsoSession = getUserSsoSessionFactory({ db }) describe('Workspace SSO repositories', () => { const serverAdminUser: BasicTestUser = { @@ -53,8 +57,7 @@ describe('Workspace SSO repositories', () => { 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 providerId = await createTestOidcProvider(workspace.id) const provider = await getWorkspaceSsoProviderFactory({ db, decrypt: getDecryptor() @@ -74,9 +77,21 @@ describe('Workspace SSO repositories', () => { }) describe('upsertUserSsoSessionFactory returns a function, that', () => { - it('creates a session if none exists', async () => { - const providerId = await createTestOidcProvider() + const testWorkspace: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'Test Workspace', + slug: 'upsert-session-test-workspace' + } + let providerId: string = '' + + before(async () => { + await createTestWorkspace(testWorkspace, serverAdminUser) + providerId = await createTestOidcProvider(testWorkspace.id) + }) + + it('creates a session if none exists', async () => { const userSsoSession: UserSsoSessionRecord = { userId: serverAdminUser.id, providerId, @@ -96,7 +111,6 @@ describe('Workspace SSO repositories', () => { }) it('updates an existing session, if one exists', async () => { - const providerId = await createTestOidcProvider() const initialValidUntil = new Date() const userSsoSession: UserSsoSessionRecord = { @@ -149,14 +163,11 @@ describe('Workspace SSO repositories', () => { before(async () => { await createTestUser(ssoUser) - await createTestWorkspace(ssoWorkspace, ssoUser) - await createTestWorkspace(nonSsoWorkspace, serverAdminUser) - const providerId = await createTestOidcProvider() - await associateSsoProviderWithWorkspace({ - workspaceId: ssoWorkspace.id, - providerId - }) + await createTestWorkspace(ssoWorkspace, ssoUser) + await createTestOidcProvider(ssoWorkspace.id) + + await createTestWorkspace(nonSsoWorkspace, serverAdminUser) }) it('lists correct workspaces for the given user', async () => { @@ -197,4 +208,164 @@ describe('Workspace SSO repositories', () => { expect(workspaces.length).to.equal(0) }) }) + + describe('listUserSsoSessionsFactory returns a function, that', async () => { + const testUserA: BasicTestUser = { + id: '', + name: 'John Speckle', + email: `${cryptoRandomString({ length: 9 })}@example.org` + } + + const testWorkspaceA: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'Test Workspace A', + slug: 'list-sessions-workspace-a' + } + + const testWorkspaceB: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'Test Workspace B', + slug: 'list-sessions-workspace-b' + } + + const testWorkspaceC: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'Test Workspace C', + slug: 'list-sessions-workspace-c' + } + + before(async () => { + await createTestUsers([testUserA]) + await createTestWorkspaces([ + [testWorkspaceA, testUserA], + [testWorkspaceB, testUserA], + [testWorkspaceC, testUserA] + ]) + + await createTestOidcProvider(testWorkspaceA.id) + await createTestOidcProvider(testWorkspaceB.id) + await createTestOidcProvider(testWorkspaceC.id) + }) + + afterEach(async () => { + truncateTables(['user_sso_sessions']) + }) + + it('returns an empty array if there are no sessions', async () => { + const sessions = await listUserSsoSessions({ userId: testUserA.id }) + expect(sessions.length).to.equal(0) + }) + + it('returns all sessions for the given user', async () => { + await createTestSsoSession(testUserA.id, testWorkspaceA.id) + await createTestSsoSession(testUserA.id, testWorkspaceB.id) + await createTestSsoSession(testUserA.id, testWorkspaceC.id) + + const sessions = await listUserSsoSessions({ userId: testUserA.id }) + expect(sessions.length).to.equal(3) + }) + + it('includes sessions that are expired but have not yet been deleted', async () => { + await createTestSsoSession(testUserA.id, testWorkspaceA.id) + await createTestSsoSession(testUserA.id, testWorkspaceB.id) + await createTestSsoSession(testUserA.id, testWorkspaceC.id, new Date()) + + await wait(150) + + const sessions = await listUserSsoSessions({ userId: testUserA.id }) + expect(sessions.length).to.equal(3) + expect(sessions.filter((session) => isValidSsoSession(session)).length).to.equal( + 2 + ) + }) + }) + + describe('getUserSsoSessionFactory returns a function, that', async () => { + const testUser: BasicTestUser = { + id: '', + name: 'John Speckle', + email: `${cryptoRandomString({ length: 9 })}@example.org` + } + + const testWorkspaceWithSsoA: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'Workspace With SSO A', + slug: 'workspace-with-sso-a' + } + + const testWorkspaceWithSsoB: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'Workspace With SSO B', + slug: 'workspace-with-sso-b' + } + + const testWorkspaceWithoutSso: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'Workspace Without SSO', + slug: 'workspace-without-sso' + } + + before(async () => { + await createTestUser(testUser) + + await createTestWorkspace(testWorkspaceWithSsoA, testUser) + await createTestOidcProvider(testWorkspaceWithSsoA.id) + + await createTestWorkspace(testWorkspaceWithSsoB, testUser) + await createTestOidcProvider(testWorkspaceWithSsoB.id) + + await createTestWorkspace(testWorkspaceWithoutSso, testUser) + }) + + it('returns the session for the specified user and workspace', async () => { + await createTestSsoSession(testUser.id, testWorkspaceWithSsoA.id) + const session = await getUserSsoSession({ + userId: testUser.id, + workspaceId: testWorkspaceWithSsoA.id + }) + expect(session).to.not.be.undefined + expect(session?.workspaceId).to.equal(testWorkspaceWithSsoA.id) + }) + + it('returns the session if it has expired but has not yet been deleted', async () => { + const validUntil = new Date() + validUntil.setDate(validUntil.getDate() - 1) + await createTestSsoSession(testUser.id, testWorkspaceWithSsoB.id, validUntil) + const session = await getUserSsoSession({ + userId: testUser.id, + workspaceId: testWorkspaceWithSsoB.id + }) + expect(session).to.not.be.undefined + }) + + it('returns null if the session does not exist', async () => { + const session = await getUserSsoSession({ + userId: testUser.id, + workspaceId: testWorkspaceWithoutSso.id + }) + expect(session).to.be.null + }) + + it('returns null if the workspace does not exist', async () => { + const session = await getUserSsoSession({ + userId: testUser.id, + workspaceId: cryptoRandomString({ length: 9 }) + }) + expect(session).to.be.null + }) + + it('returns null if the user does not exist', async () => { + const session = await getUserSsoSession({ + userId: cryptoRandomString({ length: 9 }), + workspaceId: testWorkspaceWithSsoA.id + }) + expect(session).to.be.null + }) + }) }) 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 e2103ac7f..129b35140 100644 --- a/packages/server/modules/workspaces/tests/unit/domain/logic.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/domain/logic.spec.ts @@ -4,6 +4,10 @@ import { isWorkspaceRole, userEmailsCompliantWithWorkspaceDomains } from '@/modules/workspaces/domain/logic' +import { + getDefaultSsoSessionExpirationDate, + isValidSsoSession +} from '@/modules/workspaces/domain/sso/logic' import { WorkspaceDomainsInvalidState } from '@/modules/workspaces/errors/workspace' import { WorkspaceDomain } from '@/modules/workspacesCore/domain/types' import { expectToThrow } from '@/test/assertionHelper' @@ -116,4 +120,26 @@ describe('workspace domain logic', () => { expect(isWorkspaceRole(Roles.Workspace.Admin)).to.equal(true) }) }) + describe('isValidSsoSession', () => { + it('returns true for sessions that have not yet expired', () => { + expect( + isValidSsoSession({ + userId: '', + providerId: '', + createdAt: new Date(), + validUntil: getDefaultSsoSessionExpirationDate() + }) + ).to.be.true + }) + it('returns false for sessions that have expired', () => { + expect( + isValidSsoSession({ + userId: '', + providerId: '', + createdAt: new Date(), + validUntil: new Date() + }) + ).to.be.false + }) + }) }) diff --git a/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts b/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts index ba6a678b1..ca6631029 100644 --- a/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts @@ -2,9 +2,12 @@ import { UserEmail } from '@/modules/core/domain/userEmails/types' import { UserWithOptionalRole } from '@/modules/core/repositories/users' +import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic' import { OidcProvider, - WorkspaceSsoProvider + UserSsoSessionRecord, + WorkspaceSsoProvider, + WorkspaceSsoProviderRecord } from '@/modules/workspaces/domain/sso/types' import { OidcProviderMissingGrantTypeError, @@ -15,11 +18,14 @@ import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace import { createWorkspaceUserFromSsoProfileFactory, linkUserWithSsoProviderFactory, + listUserExpiredSsoSessionsFactory, listWorkspaceSsoMembershipsByUserEmailFactory, saveSsoProviderRegistrationFactory, startOidcSsoProviderValidationFactory } from '@/modules/workspaces/services/sso' +import { Workspace } from '@/modules/workspacesCore/domain/types' import { expectToThrow } from '@/test/assertionHelper' +import { wait } from '@speckle/shared' import { assert, expect } from 'chai' import cryptoRandomString from 'crypto-random-string' @@ -395,4 +401,94 @@ describe('Workspace SSO services', () => { expect(Object.keys(workspaces[0]).includes('defaultProjectRole')).to.be.false }) }) + describe('listUserExpiredSsoSessionsFactory creates a function, that', () => { + it('returns an empty array if the user has valid sessions for all of their SSO-enabled workspaces', async () => { + const listUserExpiredSsoSessions = listUserExpiredSsoSessionsFactory({ + listWorkspaceSsoMemberships: async () => [ + { + id: 'workspace-a' + } as Workspace, + { + id: 'workspace-b' + } as Workspace + ], + listUserSsoSessions: async () => [ + { + workspaceId: 'workspace-a', + validUntil: getDefaultSsoSessionExpirationDate() + } as UserSsoSessionRecord & WorkspaceSsoProviderRecord, + { + workspaceId: 'workspace-b', + validUntil: getDefaultSsoSessionExpirationDate() + } as UserSsoSessionRecord & WorkspaceSsoProviderRecord + ] + }) + + const expiredSessions = await listUserExpiredSsoSessions({ userId: '' }) + + expect(expiredSessions.length).to.equal(0) + }) + it("returns workspaces where the user's SSO session does not exist", async () => { + const listUserExpiredSsoSessions = listUserExpiredSsoSessionsFactory({ + listWorkspaceSsoMemberships: async () => [ + { + id: 'workspace-a' + } as Workspace, + { + id: 'workspace-b' + } as Workspace + ], + listUserSsoSessions: async () => [ + { + workspaceId: 'workspace-a', + validUntil: getDefaultSsoSessionExpirationDate() + } as UserSsoSessionRecord & WorkspaceSsoProviderRecord + ] + }) + + const expiredSessions = await listUserExpiredSsoSessions({ userId: '' }) + + expect(expiredSessions.length).to.equal(1) + expect(expiredSessions[0].id).to.equal('workspace-b') + }) + it("returns workspaces where the user's SSO session exists but has expired", async () => { + const listUserExpiredSsoSessions = listUserExpiredSsoSessionsFactory({ + listWorkspaceSsoMemberships: async () => [ + { + id: 'workspace-a' + } as Workspace, + { + id: 'workspace-b' + } as Workspace + ], + listUserSsoSessions: async () => [ + { + workspaceId: 'workspace-a', + validUntil: getDefaultSsoSessionExpirationDate() + } as UserSsoSessionRecord & WorkspaceSsoProviderRecord, + { + workspaceId: 'workspace-b', + validUntil: new Date() + } as UserSsoSessionRecord & WorkspaceSsoProviderRecord + ] + }) + + await wait(50) + + const expiredSessions = await listUserExpiredSsoSessions({ userId: '' }) + + expect(expiredSessions.length).to.equal(1) + expect(expiredSessions[0].id).to.equal('workspace-b') + }) + it('returns an empty array if the user belongs to no SSO-enabled workspaces', async () => { + const listUserExpiredSsoSessions = listUserExpiredSsoSessionsFactory({ + listWorkspaceSsoMemberships: async () => [], + listUserSsoSessions: async () => [] + }) + + const expiredSessions = await listUserExpiredSsoSessions({ userId: '' }) + + expect(expiredSessions.length).to.equal(0) + }) + }) }) diff --git a/packages/server/modules/workspacesCore/domain/events.ts b/packages/server/modules/workspacesCore/domain/events.ts index 03f01ad1a..0aefc6086 100644 --- a/packages/server/modules/workspacesCore/domain/events.ts +++ b/packages/server/modules/workspacesCore/domain/events.ts @@ -6,6 +6,7 @@ export const workspaceEventNamespace = 'workspace' as const const workspaceEventPrefix = `${workspaceEventNamespace}.` as const export const WorkspaceEvents = { + Authorized: `${workspaceEventPrefix}authorized`, Created: `${workspaceEventPrefix}created`, Updated: `${workspaceEventPrefix}updated`, RoleDeleted: `${workspaceEventPrefix}role-deleted`, @@ -15,6 +16,10 @@ export const WorkspaceEvents = { export type WorkspaceEvents = (typeof WorkspaceEvents)[keyof typeof WorkspaceEvents] +type WorkspaceAuthorizedPayload = { + userId: string | null + workspaceId: string +} type WorkspaceCreatedPayload = Workspace & { createdByUserId: string } @@ -31,6 +36,7 @@ type WorkspaceJoinedFromDiscoveryPayload = { } export type WorkspaceEventsPayloads = { + [WorkspaceEvents.Authorized]: WorkspaceAuthorizedPayload [WorkspaceEvents.Created]: WorkspaceCreatedPayload [WorkspaceEvents.Updated]: WorkspaceUpdatedPayload [WorkspaceEvents.RoleDeleted]: WorkspaceRoleDeletedPayload diff --git a/packages/server/modules/workspacesCore/helpers/graphTypes.ts b/packages/server/modules/workspacesCore/helpers/graphTypes.ts index 8168e0754..34cc0f81d 100644 --- a/packages/server/modules/workspacesCore/helpers/graphTypes.ts +++ b/packages/server/modules/workspacesCore/helpers/graphTypes.ts @@ -1,11 +1,13 @@ import { MutationsObjectGraphQLReturn } from '@/modules/core/helpers/graphTypes' import { LimitedUserRecord } from '@/modules/core/helpers/types' +import { WorkspaceSsoProviderRecord } from '@/modules/workspaces/domain/sso/types' import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types' import { Workspace } from '@/modules/workspacesCore/domain/types' import { WorkspaceRoles } from '@speckle/shared' export type WorkspaceGraphQLReturn = Workspace export type WorkspaceBillingGraphQLReturn = { parent: Workspace } +export type WorkspaceSsoGraphQLReturn = WorkspaceSsoProviderRecord export type WorkspaceMutationsGraphQLReturn = MutationsObjectGraphQLReturn export type WorkspaceInviteMutationsGraphQLReturn = MutationsObjectGraphQLReturn export type WorkspaceProjectMutationsGraphQLReturn = MutationsObjectGraphQLReturn diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 8a2379d44..33fd535d3 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -3522,6 +3522,13 @@ export type User = { /** Only returned if API user is the user being requested or an admin */ email?: Maybe; emails: Array; + /** + * A list of workspaces for the active user where: + * (1) The user is a member or admin + * (2) The workspace has SSO provider enabled + * (3) The user does not have a valid SSO session for the given SSO provider + */ + expiredSsoSessions: Array; /** * All the streams that a active user has favorited. * Note: You can't use this to retrieve another user's favorite streams. @@ -4000,6 +4007,8 @@ export type Workspace = { /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ role?: Maybe; slug: Scalars['String']['output']; + /** Information about the workspace's SSO configuration and the current user's SSO session, if present */ + sso?: Maybe; subscription?: Maybe; team: WorkspaceCollaboratorCollection; updatedAt: Scalars['DateTime']['output']; @@ -4315,6 +4324,27 @@ export type WorkspaceRoleUpdateInput = { workspaceId: Scalars['String']['input']; }; +export type WorkspaceSso = { + __typename?: 'WorkspaceSso'; + /** If null, the workspace does not have SSO configured */ + provider?: Maybe; + session?: Maybe; +}; + +export type WorkspaceSsoProvider = { + __typename?: 'WorkspaceSsoProvider'; + clientId: Scalars['String']['output']; + id: Scalars['ID']['output']; + issuerUrl: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type WorkspaceSsoSession = { + __typename?: 'WorkspaceSsoSession'; + createdAt: Scalars['DateTime']['output']; + validUntil: Scalars['DateTime']['output']; +}; + export type WorkspaceSubscription = { __typename?: 'WorkspaceSubscription'; billingInterval: BillingInterval;