From e6e65a2f7d3c28849885f2e4a0782f84d83b28a0 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Fri, 1 Nov 2024 10:27:12 +0000 Subject: [PATCH] feat(sso): list sso associations by user email (#3420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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): expose search via gql --------- Co-authored-by: Gergő Jedlicska Co-authored-by: Mike Tasset --- .../lib/common/generated/gql/graphql.ts | 8 ++ .../typedefs/workspaces.graphql | 5 ++ .../modules/core/graph/generated/graphql.ts | 8 ++ .../graph/generated/graphql.ts | 7 ++ .../server/modules/workspaces/domain/logic.ts | 14 ++++ .../workspaces/domain/sso/operations.ts | 10 +++ .../workspaces/graph/resolvers/workspaces.ts | 17 +++- .../modules/workspaces/repositories/sso.ts | 24 +++++- .../server/modules/workspaces/rest/sso.ts | 23 +++++ .../server/modules/workspaces/services/sso.ts | 29 ++++++- .../workspaces/tests/integration/sso.spec.ts | 83 +++++++++++++++++++ .../tests/unit/services/sso.spec.ts | 51 ++++++++++++ .../modules/workspacesCore/domain/types.ts | 5 ++ .../server/test/graphql/generated/graphql.ts | 7 ++ 14 files changed, 286 insertions(+), 5 deletions(-) diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 6d5d2bf05..0840b5a45 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -2543,6 +2543,8 @@ export type Query = { */ workspaceInvite?: Maybe; workspacePricingPlans: Scalars['JSONObject']['output']; + /** Find workspaces a given user email can use SSO to sign with */ + workspaceSsoByEmail: Array; }; @@ -2690,6 +2692,11 @@ export type QueryWorkspaceInviteArgs = { workspaceId?: InputMaybe; }; + +export type QueryWorkspaceSsoByEmailArgs = { + email: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ @@ -7107,6 +7114,7 @@ export type QueryFieldArgs = { workspaceBySlug: QueryWorkspaceBySlugArgs, workspaceInvite: QueryWorkspaceInviteArgs, workspacePricingPlans: {}, + workspaceSsoByEmail: QueryWorkspaceSsoByEmailArgs, } export type ResourceIdentifierFieldArgs = { resourceId: {}, diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index 22f74d2ee..36af863d6 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -7,6 +7,11 @@ extend type Query { @hasServerRole(role: SERVER_GUEST) @hasScope(scope: "workspace:read") + """ + Find workspaces a given user email can use SSO to sign with + """ + workspaceSsoByEmail(email: String!): [LimitedWorkspace!]! + """ Look for an invitation to a workspace, for the current user (authed or not). diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 5a5e109b4..4ab994a79 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -2565,6 +2565,8 @@ export type Query = { */ workspaceInvite?: Maybe; workspacePricingPlans: Scalars['JSONObject']['output']; + /** Find workspaces a given user email can use SSO to sign with */ + workspaceSsoByEmail: Array; }; @@ -2712,6 +2714,11 @@ export type QueryWorkspaceInviteArgs = { workspaceId?: InputMaybe; }; + +export type QueryWorkspaceSsoByEmailArgs = { + email: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ @@ -5874,6 +5881,7 @@ export type QueryResolvers>; workspaceInvite?: Resolver, ParentType, ContextType, Partial>; workspacePricingPlans?: Resolver; + workspaceSsoByEmail?: Resolver, ParentType, ContextType, RequireFields>; }; export type ResourceIdentifierResolvers = { 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 868de681c..92f656681 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -2546,6 +2546,8 @@ export type Query = { */ workspaceInvite?: Maybe; workspacePricingPlans: Scalars['JSONObject']['output']; + /** Find workspaces a given user email can use SSO to sign with */ + workspaceSsoByEmail: Array; }; @@ -2693,6 +2695,11 @@ export type QueryWorkspaceInviteArgs = { workspaceId?: InputMaybe; }; + +export type QueryWorkspaceSsoByEmailArgs = { + email: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ diff --git a/packages/server/modules/workspaces/domain/logic.ts b/packages/server/modules/workspaces/domain/logic.ts index 0d1032aff..261389197 100644 --- a/packages/server/modules/workspaces/domain/logic.ts +++ b/packages/server/modules/workspaces/domain/logic.ts @@ -4,10 +4,13 @@ import { WorkspaceInvalidUpdateError } from '@/modules/workspaces/errors/workspace' import { + LimitedWorkspace, + Workspace, WorkspaceDefaultProjectRole, WorkspaceDomain } from '@/modules/workspacesCore/domain/types' import { Roles, WorkspaceRoles } from '@speckle/shared' +import { pick } from 'lodash' export const userEmailsCompliantWithWorkspaceDomains = ({ userEmails, @@ -64,3 +67,14 @@ export const isWorkspaceRole = (role: string): role is WorkspaceRoles => { const validRoles: string[] = Object.values(Roles.Workspace) return validRoles.includes(role) } + +export const toLimitedWorkspace = (workspace: Workspace): LimitedWorkspace => { + return pick(workspace, [ + 'id', + 'slug', + 'name', + 'description', + 'logo', + 'defaultLogoIndex' + ]) +} diff --git a/packages/server/modules/workspaces/domain/sso/operations.ts b/packages/server/modules/workspaces/domain/sso/operations.ts index c3415e5ea..c9453a8f6 100644 --- a/packages/server/modules/workspaces/domain/sso/operations.ts +++ b/packages/server/modules/workspaces/domain/sso/operations.ts @@ -6,6 +6,7 @@ import type { OidcProviderAttributes, OidcProviderValidationRequest } from '@/modules/workspaces/domain/sso/types' +import { Workspace } from '@/modules/workspacesCore/domain/types' // Workspace SSO provider management @@ -24,6 +25,15 @@ export type StoreProviderRecord = (args: { // User session management +/** + * List workspaces where: + * (1) User is a member + * (2) Workspace has SSO configured + */ +export type ListWorkspaceSsoMemberships = (args: { + userId: string +}) => Promise + export type UpsertUserSsoSession = (args: { userSsoSession: UserSsoSessionRecord }) => Promise diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 8b6dc2e12..a112eb15b 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -148,10 +148,16 @@ import { } from '@/modules/activitystream/services/streamActivity' import { publish } from '@/modules/shared/utils/subscriptions' import { updateStreamRoleAndNotifyFactory } from '@/modules/core/services/streams/management' -import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users' +import { + getUserByEmailFactory, + getUserFactory, + getUsersFactory +} from '@/modules/core/repositories/users' 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' const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) @@ -281,6 +287,15 @@ export = FF_WORKSPACES_MODULE_ENABLED return workspace }, + workspaceSsoByEmail: async (_parent, args) => { + const workspaces = await listWorkspaceSsoMembershipsByUserEmailFactory({ + getUserByEmail: getUserByEmailFactory({ db }), + listWorkspaceSsoMemberships: listWorkspaceSsoMembershipsFactory({ db }) + })({ + userEmail: args.email + }) + return workspaces + }, workspaceInvite: async (_parent, args, ctx) => { const getPendingInvite = getUserPendingWorkspaceInviteFactory({ findInvite: findInviteFactory({ diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index 0aa46b30c..a6a178fae 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -5,13 +5,15 @@ import { GetWorkspaceSsoProvider, StoreOidcProviderValidationRequest, StoreProviderRecord, - UpsertUserSsoSession + UpsertUserSsoSession, + ListWorkspaceSsoMemberships } from '@/modules/workspaces/domain/sso/operations' import { ProviderRecord, UserSsoSessionRecord } from '@/modules/workspaces/domain/sso/types' import { SsoProviderTypeNotSupportedError } from '@/modules/workspaces/errors/sso' +import { Workspace, WorkspaceAcl } from '@/modules/workspacesCore/domain/types' import Redis from 'ioredis' import { Knex } from 'knex' import { omit } from 'lodash' @@ -26,6 +28,7 @@ type WorkspaceSsoProviderRecord = { workspaceId: string; providerId: string } const tables = { ssoProviders: (db: Knex) => db('sso_providers'), userSsoSessions: (db: Knex) => db('user_sso_sessions'), + workspaceAcl: (db: Knex) => db('workspace_acl'), workspaceSsoProviders: (db: Knex) => db('workspace_sso_providers') } @@ -101,3 +104,22 @@ export const upsertUserSsoSessionFactory = .onConflict(['userId', 'providerId']) .merge(['createdAt', 'validUntil']) } + +export const listWorkspaceSsoMembershipsFactory = + ({ db }: { db: Knex }): ListWorkspaceSsoMemberships => + async ({ userId }) => { + const workspaces = await tables + .workspaceAcl(db) + .select('*') + .join( + 'workspace_sso_providers', + 'workspace_sso_providers.workspaceId', + 'workspace_acl.workspaceId' + ) + .join('workspaces', 'id', 'workspace_sso_providers.workspaceId') + .where((builder) => { + builder.where({ userId }) + builder.whereNotNull('providerId') + }) + return workspaces + } diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index a6b67a268..36a41c7e6 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -179,6 +179,29 @@ export const getSsoRouter = (): Router => { }) ) + router.get( + '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', + sessionMiddleware, + moveAuthParamsToSessionMiddleware, + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }), + query: oidcProvider + }), + handleSsoValidationRequestFactory({ + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }), + startOidcSsoProviderValidation: startOidcSsoProviderValidationFactory({ + getOidcProviderAttributes: getOIDCProviderAttributes, + storeOidcProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ + redis: getGenericRedis(), + encrypt: getEncryptor() + }), + generateCodeVerifier: generators.codeVerifier + }) + }) + ) + router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', sessionMiddleware, diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index 20b5fcc72..da0951f32 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -5,7 +5,8 @@ import { StoreOidcProviderValidationRequest, StoreProviderRecord, AssociateSsoProviderWithWorkspace, - GetWorkspaceSsoProvider + GetWorkspaceSsoProvider, + ListWorkspaceSsoMemberships } from '@/modules/workspaces/domain/sso/operations' import { OidcProvider, @@ -19,11 +20,14 @@ import { FindEmailsByUserId, UpdateUserEmail } from '@/modules/core/domain/userEmails/operations' -import { isWorkspaceRole } from '@/modules/workspaces/domain/logic' +import { isWorkspaceRole, toLimitedWorkspace } from '@/modules/workspaces/domain/logic' import { UserWithOptionalRole } from '@/modules/core/repositories/users' import { DeleteInvite, FindInvite } from '@/modules/serverinvites/domain/operations' import { UpsertWorkspaceRole } from '@/modules/workspaces/domain/operations' -import { CreateValidatedUser } from '@/modules/core/domain/users/operations' +import { + CreateValidatedUser, + GetUserByEmail +} from '@/modules/core/domain/users/operations' import { OidcProviderMissingGrantTypeError, SsoProviderExistsError, @@ -31,6 +35,7 @@ import { SsoUserInviteRequiredError } from '@/modules/workspaces/errors/sso' import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace' +import { LimitedWorkspace } from '@/modules/workspacesCore/domain/types' // this probably should go a lean validation endpoint too const validateOidcProviderAttributes = ({ @@ -230,3 +235,21 @@ export const linkUserWithSsoProviderFactory = }) } } + +export const listWorkspaceSsoMembershipsByUserEmailFactory = + ({ + getUserByEmail, + listWorkspaceSsoMemberships + }: { + getUserByEmail: GetUserByEmail + listWorkspaceSsoMemberships: ListWorkspaceSsoMemberships + }) => + async (args: { userEmail: string }): Promise => { + const user = await getUserByEmail(args.userEmail) + if (!user) return [] + + const workspaces = await listWorkspaceSsoMemberships({ userId: user.id }) + + // Return limited workspace version of each workspace + return workspaces.map(toLimitedWorkspace) + } diff --git a/packages/server/modules/workspaces/tests/integration/sso.spec.ts b/packages/server/modules/workspaces/tests/integration/sso.spec.ts index 301675da1..b8b336444 100644 --- a/packages/server/modules/workspaces/tests/integration/sso.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/sso.spec.ts @@ -1,6 +1,7 @@ import { associateSsoProviderWithWorkspaceFactory, getWorkspaceSsoProviderFactory, + listWorkspaceSsoMembershipsFactory, upsertUserSsoSessionFactory } from '@/modules/workspaces/repositories/sso' import { @@ -19,6 +20,7 @@ import { UserSsoSessionRecord } from '@/modules/workspaces/domain/sso/types' const associateSsoProviderWithWorkspace = associateSsoProviderWithWorkspaceFactory({ db }) +const listWorkspaceSsoMemberships = listWorkspaceSsoMembershipsFactory({ db }) const upsertUserSsoSession = upsertUserSsoSessionFactory({ db }) describe('Workspace SSO repositories', () => { @@ -29,8 +31,16 @@ describe('Workspace SSO repositories', () => { role: Roles.Server.Admin } + const testWorkspace: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'My Test Workspace', + slug: 'test-workspace' + } + before(async () => { await createTestUser(serverAdminUser) + await createTestWorkspace(testWorkspace, serverAdminUser) }) describe('getWorkspaceSsoProviderFactory returns a function, that', () => { @@ -114,4 +124,77 @@ describe('Workspace SSO repositories', () => { expect(sessions[0].validUntil.getTime()).to.not.equal(initialValidUntil.getTime()) }) }) + + describe('listWorkspaceSsoMembershipsFactory returns a function, that', async () => { + const ssoUser: BasicTestUser = { + id: '', + email: 'sso-speckle@example.org', + name: 'SSO Speckle', + role: Roles.Server.Admin + } + + const ssoWorkspace: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'Workspace With SSO', + slug: 'yes-sso' + } + + const nonSsoWorkspace: BasicTestWorkspace = { + id: '', + ownerId: '', + name: 'Workspace Without SSO', + slug: 'no-sso-very-sad' + } + + before(async () => { + await createTestUser(ssoUser) + await createTestWorkspace(ssoWorkspace, ssoUser) + await createTestWorkspace(nonSsoWorkspace, serverAdminUser) + + const providerId = await createTestOidcProvider() + await associateSsoProviderWithWorkspace({ + workspaceId: ssoWorkspace.id, + providerId + }) + }) + + it('lists correct workspaces for the given user', async () => { + const workspaces = await listWorkspaceSsoMemberships({ + userId: ssoUser.id + }) + + // Includes workspaces with SSO + expect(workspaces.length).to.equal(1) + expect(workspaces.some((workspace) => workspace.id === ssoWorkspace.id)).to.be + .true + + // Omits workspaces without SSO + expect(workspaces.some((workspace) => workspace.id === nonSsoWorkspace.id)).to.be + .false + }) + + it('returns an empty array if the user is not part of any workspaces', async () => { + const testServerUser: BasicTestUser = { + id: '', + name: 'Jane Speckle', + email: 'jane-sso-speckle@example.org' + } + + await createTestUser(testServerUser) + + const workspaces = await listWorkspaceSsoMemberships({ + userId: testServerUser.id + }) + + expect(workspaces.length).to.equal(0) + }) + + it('returns an empty array if the user does not exist', async () => { + const workspaces = await listWorkspaceSsoMemberships({ + userId: cryptoRandomString({ length: 9 }) + }) + expect(workspaces.length).to.equal(0) + }) + }) }) 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 d948101c3..ba6a678b1 100644 --- a/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts +++ b/packages/server/modules/workspaces/tests/unit/services/sso.spec.ts @@ -1,6 +1,7 @@ /* eslint-disable camelcase */ import { UserEmail } from '@/modules/core/domain/userEmails/types' +import { UserWithOptionalRole } from '@/modules/core/repositories/users' import { OidcProvider, WorkspaceSsoProvider @@ -14,6 +15,7 @@ import { WorkspaceInvalidRoleError } from '@/modules/workspaces/errors/workspace import { createWorkspaceUserFromSsoProfileFactory, linkUserWithSsoProviderFactory, + listWorkspaceSsoMembershipsByUserEmailFactory, saveSsoProviderRegistrationFactory, startOidcSsoProviderValidationFactory } from '@/modules/workspaces/services/sso' @@ -344,4 +346,53 @@ describe('Workspace SSO services', () => { expect(userEmails[0].verified).to.be.true }) }) + describe('listWorkspaceSsoMembershipsByUserEmailFactory creates a function, that', () => { + it('returns an empty array if the user does not exist', async () => { + const listWorkspaceSsoMemberships = listWorkspaceSsoMembershipsByUserEmailFactory( + { + getUserByEmail: async () => null, + listWorkspaceSsoMemberships: async () => { + assert.fail() + } + } + ) + + const workspaces = await listWorkspaceSsoMemberships({ + userEmail: 'fake@example.org ' + }) + + expect(workspaces.length).to.equal(0) + }) + it('returns sanitized results if any matches are found', async () => { + const listWorkspaceSsoMemberships = listWorkspaceSsoMembershipsByUserEmailFactory( + { + getUserByEmail: async () => + ({ + id: cryptoRandomString({ length: 9 }) + } as UserWithOptionalRole), + listWorkspaceSsoMemberships: async () => [ + { + id: '', + slug: '', + name: '', + description: '', + logo: null, + defaultLogoIndex: 0, + defaultProjectRole: 'stream:contributor', + domainBasedMembershipProtectionEnabled: false, + discoverabilityEnabled: false, + createdAt: new Date(), + updatedAt: new Date() + } + ] + } + ) + + const workspaces = await listWorkspaceSsoMemberships({ + userEmail: 'anything@example.org' + }) + + expect(Object.keys(workspaces[0]).includes('defaultProjectRole')).to.be.false + }) + }) }) diff --git a/packages/server/modules/workspacesCore/domain/types.ts b/packages/server/modules/workspacesCore/domain/types.ts index 57659f73e..0f72e01c2 100644 --- a/packages/server/modules/workspacesCore/domain/types.ts +++ b/packages/server/modules/workspacesCore/domain/types.ts @@ -33,6 +33,11 @@ export type Workspace = { discoverabilityEnabled: boolean } +export type LimitedWorkspace = Pick< + Workspace, + 'id' | 'slug' | 'name' | 'description' | 'logo' | 'defaultLogoIndex' +> + export type WorkspaceWithDomains = Workspace & { domains: WorkspaceDomain[] } export type WorkspaceDefaultProjectRole = Exclude diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index eb45d7037..8a2379d44 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -2547,6 +2547,8 @@ export type Query = { */ workspaceInvite?: Maybe; workspacePricingPlans: Scalars['JSONObject']['output']; + /** Find workspaces a given user email can use SSO to sign with */ + workspaceSsoByEmail: Array; }; @@ -2694,6 +2696,11 @@ export type QueryWorkspaceInviteArgs = { workspaceId?: InputMaybe; }; + +export type QueryWorkspaceSsoByEmailArgs = { + email: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */