Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sso): protect access with sso sessions #3441

Merged
merged 64 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
b38133e
feat(workspaces): add workspace sso feature flag
gjedlicska Sep 23, 2024
2486e5c
Merge branch 'main' of github.com:specklesystems/speckle-server into …
gjedlicska Sep 25, 2024
29d6bdc
feat(workspaceSso): wip validate sso
gjedlicska Sep 26, 2024
8b8b9e6
Merge branch 'main' of github.com:specklesystems/speckle-server into …
gjedlicska Sep 26, 2024
1c8f16e
Merge branch 'main' of github.com:specklesystems/speckle-server into …
gjedlicska Sep 27, 2024
7da225b
feat(workspaces): validate and add sso provider to the workspace with…
gjedlicska Oct 1, 2024
6149b9e
feat(workspaces): validate and add sso provider to the workspace with…
gjedlicska Oct 1, 2024
57295b9
WIP
Mikehrn Oct 1, 2024
36944b5
fix(sso): restructure to handle all branches at end of flow
cdriesler Oct 1, 2024
a6e6307
fix(sso): add and validate emails used for sso
cdriesler Oct 1, 2024
19a834d
Merged main
Mikehrn Oct 2, 2024
a78e9d3
fix(sso): park progress
cdriesler Oct 2, 2024
a014c10
chore(workspaces): review sso login/valdate
gjedlicska Oct 2, 2024
6f48668
Merge branch 'main' into mike/sso
Mikehrn Oct 7, 2024
b8726bf
fix(sso): adjust validate url
cdriesler Oct 7, 2024
b0d2bbf
chore(sso): auth header puzzle
cdriesler Oct 7, 2024
49bc0a4
fix(sso): happy-path config
cdriesler Oct 8, 2024
54e0450
Merge branch 'main' of github.com:specklesystems/speckle-server into …
cdriesler Oct 9, 2024
3063d33
chore(gql): gqlgen
cdriesler Oct 9, 2024
db041b9
Merge branch 'mike/sso' into charles/more-sso-validation
cdriesler Oct 9, 2024
c98a783
Merge branch 'main' into charles/more-sso-validation
cdriesler Oct 10, 2024
3df52df
fix(sso): almost almost
cdriesler Oct 10, 2024
27ac047
fix(sso): auth endpoint
cdriesler Oct 10, 2024
7a7424b
a lil more terse
cdriesler Oct 13, 2024
4566840
fix(sso): light at the end of the tunnel
cdriesler Oct 13, 2024
7dae4a7
fix(sso): improve catch block error messages
cdriesler Oct 13, 2024
355aaa7
fix(sso): session lifespan => validUntil
cdriesler Oct 14, 2024
0abc084
fix(sso): I think we've got it
cdriesler Oct 14, 2024
6011280
feat(sso): limited workspace values for public sso login
cdriesler Oct 16, 2024
2fc11a7
fix(sso): use factory functions
cdriesler Oct 20, 2024
611200c
Merge remote-tracking branch 'origin' into charles/limitedWorkspace
cdriesler Oct 20, 2024
2b5abde
Merge branch 'charles/limitedWorkspace' into charles/more-sso-validation
cdriesler Oct 20, 2024
48814f9
fix(sso): til decrypt is single-use
cdriesler Oct 20, 2024
55c505d
fix(sso): correct usage of access codes
cdriesler Oct 21, 2024
3d9b749
fix(sso): use finalize middleware in all routes
cdriesler Oct 21, 2024
e56124b
chore(sso): cheeky tweak
cdriesler Oct 21, 2024
73c3dc2
Merge branch 'main' of github.com:specklesystems/speckle-server into …
cdriesler Oct 22, 2024
7f11a8d
fix(sso): move some types around
cdriesler Oct 22, 2024
ea42192
fix(sso): stencil final shape I'm sleepy
cdriesler Oct 23, 2024
d4c7d26
fix(sso): more factories more factories
cdriesler Oct 23, 2024
5e296fc
fix(sso): on to final boss of factories
cdriesler Oct 23, 2024
5225752
fix(sso): needs a haircut but she works
cdriesler Oct 23, 2024
b7811c7
Merge remote-tracking branch 'origin' into charles/more-sso-validation
cdriesler Oct 23, 2024
786da16
fix(sso): init rest w function, not side-effects
cdriesler Oct 24, 2024
fe29fc7
fix(sso): /authn => /sso
cdriesler Oct 24, 2024
af24a3f
chore(sso): errors
cdriesler Oct 28, 2024
441e39d
chore(sso): test test test
cdriesler Oct 29, 2024
b4ecd16
chore(sso): test all the corners
cdriesler Oct 30, 2024
1b80b41
Merge branch 'main' of github.com:specklesystems/speckle-server into …
cdriesler Oct 30, 2024
d72808a
feat(sso): list workspace sso memberships
cdriesler Oct 31, 2024
78cceac
chore(sso): tests, expose in rest
cdriesler Oct 31, 2024
c9e9a2c
fix(sso): sketch active user auth
cdriesler Oct 31, 2024
8e5018a
Merge remote-tracking branch 'origin' into charles/tryFindWorkspace
cdriesler Oct 31, 2024
7b97821
fix(sso): expose search via gql
cdriesler Oct 31, 2024
e294be9
Merge remote-tracking branch 'origin' into charles/tryFindWorkspace
cdriesler Oct 31, 2024
2e05b86
Merge branch 'charles/tryFindWorkspace' into charles/activeUserSso
cdriesler Oct 31, 2024
f5ff307
fix(sso): active user session information
cdriesler Oct 31, 2024
3757eb3
Merge branch 'main' into charles/activeUserSso
cdriesler Nov 1, 2024
df1e40e
chore(sso): sso session test utils
cdriesler Nov 3, 2024
4f59462
chore(sso): test sso session repo/services
cdriesler Nov 4, 2024
e4f502d
chore(sso): gqlgen
cdriesler Nov 4, 2024
e775f9e
feat(sso): throw error on missing or expired sso session
cdriesler Nov 4, 2024
8d1fac2
Merge branch 'main' of github.com:specklesystems/speckle-server into …
cdriesler Nov 5, 2024
640f756
chore(sso): tests for SSO access protection
cdriesler Nov 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/server/modules/workspaces/errors/sso.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { BaseError } from '@/modules/shared/errors/base'

export class SsoSessionMissingOrExpiredError extends BaseError {
static defaultMessage =
'No valid SSO session found for the given workspace. Please sign in.'
static code = 'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
static statusCode = 401
}

export class SsoVerificationCodeMissingError extends BaseError {
static defaultMessage = 'Cannot find verification token. Restart authentication flow.'
static code = 'SSO_VERIFICATION_CODE_MISSING_ERROR'
Expand Down
52 changes: 52 additions & 0 deletions packages/server/modules/workspaces/events/eventListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
upsertProjectRoleFactory
} from '@/modules/core/repositories/streams'
import {
GetWorkspace,
GetWorkspaceRoleForUser,
GetWorkspaceRoles,
GetWorkspaceRoleToDefaultProjectRoleMapping,
QueryAllWorkspaceProjects
Expand All @@ -35,6 +37,7 @@ import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events'
import { Knex } from 'knex'
import {
getWorkspaceFactory,
getWorkspaceRoleForUserFactory,
getWorkspaceRolesFactory,
getWorkspaceWithDomainsFactory,
upsertWorkspaceRoleFactory
Expand All @@ -46,6 +49,17 @@ import {
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
import { findVerifiedEmailsByUserIdFactory } from '@/modules/core/repositories/userEmails'
import { GetStream } from '@/modules/core/domain/streams/operations'
import {
GetUserSsoSession,
GetWorkspaceSsoProviderRecord
} from '@/modules/workspaces/domain/sso/operations'
import { isValidSsoSession } from '@/modules/workspaces/domain/sso/logic'
import { SsoSessionMissingOrExpiredError } from '@/modules/workspaces/errors/sso'
import {
getUserSsoSessionFactory,
getWorkspaceSsoProviderRecordFactory
} from '@/modules/workspaces/repositories/sso'
import { WorkspacesNotAuthorizedError } from '@/modules/workspaces/errors/workspace'

export const onProjectCreatedFactory =
({
Expand Down Expand Up @@ -131,6 +145,35 @@ export const onInviteFinalizedFactory =
})
}

export const onWorkspaceAuthorizedFactory =
({
getWorkspace,
getWorkspaceRoleForUser,
getWorkspaceSsoProviderRecord,
getUserSsoSession
}: {
getWorkspace: GetWorkspace
getWorkspaceRoleForUser: GetWorkspaceRoleForUser
getWorkspaceSsoProviderRecord: GetWorkspaceSsoProviderRecord
getUserSsoSession: GetUserSsoSession
}) =>
async ({ userId, workspaceId }: { userId: string | null; workspaceId: string }) => {
if (!userId) throw new WorkspacesNotAuthorizedError()

// Guests cannot use (and are not restricted by) SSO
const workspaceRole = await getWorkspaceRoleForUser({ userId, workspaceId })
if (workspaceRole?.role === Roles.Workspace.Guest) return

const provider = await getWorkspaceSsoProviderRecord({ workspaceId })
if (!provider) return

const session = await getUserSsoSession({ userId, workspaceId })
if (!session || !isValidSsoSession(session)) {
const workspace = await getWorkspace({ workspaceId })
throw new SsoSessionMissingOrExpiredError(workspace?.slug)
}
}

export const onWorkspaceRoleDeletedFactory =
({
queryAllWorkspaceProjects,
Expand Down Expand Up @@ -244,6 +287,15 @@ export const initializeEventListenersFactory =
})
await onInviteFinalized(payload)
}),
eventBus.listen(WorkspaceEvents.Authorized, async ({ payload }) => {
const onWorkspaceAuthorized = onWorkspaceAuthorizedFactory({
getWorkspace: getWorkspaceFactory({ db }),
getWorkspaceRoleForUser: getWorkspaceRoleForUserFactory({ db }),
getWorkspaceSsoProviderRecord: getWorkspaceSsoProviderRecordFactory({ db }),
getUserSsoSession: getUserSsoSessionFactory({ db })
})
await onWorkspaceAuthorized(payload)
}),
eventBus.listen(WorkspaceEvents.RoleDeleted, async ({ payload }) => {
const trx = await db.transaction()
const onWorkspaceRoleDeleted = onWorkspaceRoleDeletedFactory({
Expand Down
1 change: 1 addition & 0 deletions packages/server/modules/workspaces/repositories/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export const listWorkspaceSsoMembershipsFactory =
.where((builder) => {
builder.where({ userId })
builder.whereNotNull('providerId')
builder.whereNot('role', 'workspace:guest')
})
return workspaces
}
251 changes: 251 additions & 0 deletions packages/server/modules/workspaces/tests/integration/sso.graph.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import {
assignToWorkspaces,
BasicTestWorkspace,
createTestOidcProvider,
createTestSsoSession,
createTestWorkspaces
} from '@/modules/workspaces/tests/helpers/creation'
import {
BasicTestUser,
createAuthTokenForUser,
createTestUsers
} from '@/test/authHelper'
import {
ActiveUserExpiredSsoSessionsDocument,
GetActiveUserWorkspacesDocument,
GetProjectDocument,
GetWorkspaceDocument,
GetWorkspaceProjectsDocument,
GetWorkspaceSsoDocument
} from '@/test/graphql/generated/graphql'
import {
createTestContext,
testApolloServer,
TestApolloServer
} from '@/test/graphqlHelper'
import { truncateTables } from '@/test/hooks'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { AllScopes, Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'

describe('Workspace SSO', () => {
let memberApollo: TestApolloServer
let guestApollo: TestApolloServer

const workspaceAdmin: BasicTestUser = {
id: '',
name: 'John Admin',
email: `${cryptoRandomString({ length: 9 })}@example.org`,
role: Roles.Server.Admin
}

const workspaceMember: BasicTestUser = {
id: '',
name: 'John Member',
email: `${cryptoRandomString({ length: 9 })}@example.org`
}

const workspaceGuest: BasicTestUser = {
id: '',
name: 'John Guest',
email: `${cryptoRandomString({ length: 9 })}@example.org`
}

const testWorkspaceWithSso: BasicTestWorkspace = {
id: '',
ownerId: '',
name: 'Test SSO Workspace',
slug: 'gql-sso-workspace'
}
let testWorkspaceWithSsoProviderId = ''
let testWorkspaceWithSsoProjectId = ''

const testWorkspaceWithoutSso: BasicTestWorkspace = {
id: '',
ownerId: '',
name: 'Test Non-SSO Workspace',
slug: 'gql-no-sso-workspace'
}

before(async () => {
await createTestUsers([workspaceAdmin, workspaceMember, workspaceGuest])
await createTestWorkspaces([
[testWorkspaceWithSso, workspaceAdmin],
[testWorkspaceWithoutSso, workspaceAdmin]
])
testWorkspaceWithSsoProviderId = await createTestOidcProvider(
testWorkspaceWithSso.id
)

await assignToWorkspaces([
[testWorkspaceWithSso, workspaceMember, Roles.Workspace.Member],
[testWorkspaceWithSso, workspaceGuest, Roles.Workspace.Guest],
[testWorkspaceWithoutSso, workspaceMember, Roles.Workspace.Member],
[testWorkspaceWithoutSso, workspaceGuest, Roles.Workspace.Guest]
])

memberApollo = await testApolloServer({
context: createTestContext({
auth: true,
userId: workspaceMember.id,
token: await createAuthTokenForUser(workspaceMember.id),
role: Roles.Server.User,
scopes: AllScopes
})
})
guestApollo = await testApolloServer({
context: createTestContext({
auth: true,
userId: workspaceGuest.id,
token: await createAuthTokenForUser(workspaceGuest.id),
role: Roles.Server.User,
scopes: AllScopes
})
})

const testProject: BasicTestStream = {
id: '',
ownerId: '',
isPublic: false,
name: 'Workspace Project',
workspaceId: testWorkspaceWithSso.id
}

await createTestStream(testProject, workspaceAdmin)
testWorkspaceWithSsoProjectId = testProject.id
})

afterEach(async () => {
truncateTables(['user_sso_sessions'])
})

describe('given a workspace with SSO configured', () => {
describe('when a workspace member requests workspace information', () => {
describe('with a valid SSO session', () => {
beforeEach(async () => {
await createTestSsoSession(workspaceMember.id, testWorkspaceWithSso.id)
})

it('should allow the request', async () => {
const res = await memberApollo.execute(GetWorkspaceDocument, {
workspaceId: testWorkspaceWithSso.id
})

expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.slug).to.equal('gql-sso-workspace')
})

it('should provide active SSO session information on workspace type', async () => {
const res = await memberApollo.execute(GetWorkspaceSsoDocument, {
id: testWorkspaceWithSso.id
})

expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.sso).to.not.be.undefined
expect(res.data?.workspace.sso?.provider?.id).to.equal(
testWorkspaceWithSsoProviderId
)
expect(res.data?.workspace.sso?.session).to.not.be.undefined
})
})

describe('without a valid SSO session', () => {
it('should throw and provide redirect information', async () => {
const resA = await memberApollo.execute(GetWorkspaceDocument, {
workspaceId: testWorkspaceWithSso.id
})
const resB = await memberApollo.execute(GetWorkspaceProjectsDocument, {
id: testWorkspaceWithSso.id
})
const resC = await memberApollo.execute(GetProjectDocument, {
id: testWorkspaceWithSsoProjectId
})

for (const res of [resA, resB, resC]) {
expect(res).to.haveGraphQLErrors({ message: 'gql-sso-workspace' })
expect(res).to.haveGraphQLErrors({
code: 'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
})
}
})

it('should allow limited access to workspace memberships', async () => {
const res = await memberApollo.execute(GetActiveUserWorkspacesDocument, {})

expect(res).to.not.haveGraphQLErrors()
expect(res.data?.activeUser?.workspaces.items.length).to.equal(2)
})

it('should surface expired session', async () => {
const res = await memberApollo.execute(
ActiveUserExpiredSsoSessionsDocument,
{}
)

expect(res).to.not.haveGraphQLErrors()
expect(res.data?.activeUser?.expiredSsoSessions.length).to.equal(1)
expect(res.data?.activeUser?.expiredSsoSessions[0].slug).to.equal(
'gql-sso-workspace'
)
})
})
})

describe('when a workspace guest requests workspace information', () => {
describe('without a valid SSO session', () => {
it('should allow the request', async () => {
const res = await guestApollo.execute(GetWorkspaceDocument, {
workspaceId: testWorkspaceWithSso.id
})

expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.slug).to.equal('gql-sso-workspace')
})

it('should not show the workspace as an expired SSO session', async () => {
const res = await guestApollo.execute(
ActiveUserExpiredSsoSessionsDocument,
{}
)

expect(res).to.not.haveGraphQLErrors()
expect(res.data?.activeUser?.expiredSsoSessions.length).to.equal(0)
})
})
})
})

describe('given a workspace without SSO configured', () => {
describe('when a workspace member requests workspace information', () => {
it('should allow the request', async () => {
const res = await memberApollo.execute(GetWorkspaceDocument, {
workspaceId: testWorkspaceWithoutSso.id
})

expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.slug).to.equal('gql-no-sso-workspace')
})

it('should return workspace provider information as `null`', async () => {
const res = await memberApollo.execute(GetWorkspaceSsoDocument, {
id: testWorkspaceWithoutSso.id
})

expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.sso).to.be.null
})
})

describe('when a workspace guest requests workspace information', () => {
it('should allow the request', async () => {
const res = await guestApollo.execute(GetWorkspaceDocument, {
workspaceId: testWorkspaceWithoutSso.id
})

expect(res).to.not.haveGraphQLErrors()
expect(res.data?.workspace.slug).to.equal('gql-no-sso-workspace')
})
})
})
})
Loading