Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
598 changes: 598 additions & 0 deletions packages/server/__tests__/Okta-SCIM-20-SPEC-Test.json
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was taken from Okta (referenced here). I removed 1 optional group test and fixed one which passed an invalid email but was supposed to fail on something else.
It's a Runscope/Radar spec, but the test parses this file and runs it.

Large diffs are not rendered by default.

870 changes: 870 additions & 0 deletions packages/server/__tests__/SCIM.test.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/server/__tests__/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import AuthToken from '../database/types/AuthToken'
import getKysely from '../postgres/getKysely'
import encodeAuthToken from '../utils/encodeAuthToken'

export const HOST = `${process.env.HOST}:${process.env.PORT}` || 'localhost:3000'
export const HOST =
process.env.HOST === 'localhost' ? `localhost:${process.env.PORT}` : process.env.HOST!
export const PROTOCOL = process.env.PROTO || 'http'
const WS_PROTOCOL = PROTOCOL === 'https' ? 'wss' : 'ws'

Expand Down
1 change: 0 additions & 1 deletion packages/server/__tests__/isTeamActive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ describe('isTeamActive', () => {
afterAll(async () => {
await pg.schema.dropSchema(TEST_DB).cascade().execute()
await pg.destroy()
console.log('isTeamActive destroy')
})

const setupBaseTestData = async () => {
Expand Down
1 change: 0 additions & 1 deletion packages/server/__tests__/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,6 @@ test('Leaving a team updates User.tms', async () => {
})
const teamId = user1.data.viewer.tms[0]
const teamMemberId = TeamMemberId.join(teamId, user.userId)
console.log('teamMemberId', teamMemberId)

const leftTeam = await sendPublic({
query: `
Expand Down
1 change: 0 additions & 1 deletion packages/server/dataloader/__tests__/isOrgVerified.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ afterAll(async () => {
const pg = getKysely()
await pg.schema.dropSchema(TEST_DB).cascade().execute()
await pg.destroy()
console.log('org verified destroy')
})

test('Founder is billing lead', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/graphql/uWSAsyncHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type uWSHandler = (res: HttpResponse, req: HttpRequest) => Promise<void>
const uWSAsyncHandler =
(handler: uWSHandler, ignoreDone?: boolean) => async (res: HttpResponse, req: HttpRequest) => {
safetyPatchRes(res)
const authToken = getReqAuth(req)
const authToken = getReqAuth(req, false)
try {
await handler(res, req)
if (!ignoreDone && !res.done) {
Expand Down
2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"dayjs": "^1.11.13",
"dotenv": "8.6.0",
"dotenv-expand": "5.1.0",
"email-addresses": "^3.1.0",
"emoji-mart": "^5.6.0",
"fast-json-stable-stringify": "^2.1.0",
"fast-xml-parser": "^5.3.4",
Expand Down Expand Up @@ -110,6 +111,7 @@
"rrule-rust": "^2.0.2",
"samlify": "^2.10.2",
"sanitize-html": "^2.13.0",
"scimmy": "^1.3.5",
"sharp": "0.34.3",
"stripe": "^9.13.0",
"tseep": "^1.3.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {type Kysely} from 'kysely'

// `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface.
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('User')
.addColumn('scimId', 'varchar(100)', (col) => col.references('SAML.id').onDelete('set null'))
.addColumn('scimExternalId', 'varchar(255)')
.addColumn('scimUserName', 'varchar(255)')
Comment thread
Dschoordsch marked this conversation as resolved.
// Okta wants these
.addColumn('scimGivenName', 'varchar(255)')
.addColumn('scimFamilyName', 'varchar(255)')
.execute()
await db.schema
.createIndex('User_scimId_scimUserName_idx')
.unique()
.on('User')
.columns(['scimId', 'scimUserName'])
.where('scimId', 'is not', null)
.where('scimUserName', 'is not', null)
.execute()
}

// `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface.
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('User_scimId_scimUserName_idx').execute()
await db.schema
.alterTable('User')
.dropColumn('scimId')
.dropColumn('scimExternalId')
.dropColumn('scimUserName')
.dropColumn('scimGivenName')
.dropColumn('scimFamilyName')
.execute()
}
41 changes: 41 additions & 0 deletions packages/server/scim/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Parabol SCIM Provisioning
An enterprise organization with verified domains (i.e. SAML enabled) can use SCIM to provision and de-provision users. The users that can be managed via SCIM are limited by the verified domains associated with the organization.
The SCIM setup is tightly coupled to the organization.

## User categories used here
- provisioned: users that were originally created via this SCIM provisioning
- domain-matched: users that belong to the verified domains associated with the organization
- externals: users that don't belong to the verified domains and were not provisioned via this SCIM but are members of the organization

SCIM provisioning **can**:
- provision users regardless of domain that will be added to the organization.
- update provisioned and domain-matched users.
- list provisioned, domain-matched, and external users in the organization.
- de-provision provisioned and domain-matched users.

SCIM provisioning **cannot**:
- de-provision externals. They will be removed from the organization but not deleted from Parabol.
- update externals.
- manage groups/teams.
- apply roles.

## Notes
- degress will remove a user from the organization and all associated teams. Re-provisioning them will not re-add team membership again. Externals will remain a Parabol user.
- egress will list all users in the organization combined with users provisioned via this SCIM combined with users bolonging to the verified domains.
- all ingress will add the users (new and existing) to the organization regardless of their email domain.

## Attributes
The following attributes are supported for SCIM provisioning:
- `userName`: unused by Parabol, can be used to query users; will fall back to SAML PersistentNameID, NameID or email if unknown
- `displayName`: the user's full name, can be changed locally in Parabol
- `emails` (required): Parabol will use the primary email provided, falling back to the first one; Parabol lowercases all email addresses
- `externalId`: only stored and echoed back
- `name.givenName`: only stored and echoed back, guessed when unknown[^1]
- `name.familyName`: only stored and echoed back, guessed when unknown[^1]
[^1]: If `name.givenName` or `name.familyName` are unknown, the missing attribute(s) are guessed by the following algorithm:
- if `displayName` consists of multiple parts (e.g. "Jane H. Doe"), then `name.givenName` will be the first, `name.familyName` the last part, (e.g. "Jane" and "Doe")
- else if `email` consists of multiple parts separated by `.` (e.g. "jane.h.doe@example.com"), then `name.givenName` will be the first, `name.familyName` the last part, (e.g. "Jane" and "Doe")
- else `name.givenName` will be set to `displayName` and `name.lastName` will be set to the local email capitalized, so for "Jane<doe@example.com>" it will be "Jane" and "Doe"

## Warning
Org admins can be de-provisioned via SCIM, but currently users cannot be promoted to org admins via SCIM. Be sure to manually promote a new org admin before de-provisioning the last org admin.
Comment thread
mattkrick marked this conversation as resolved.
8 changes: 8 additions & 0 deletions packages/server/scim/SCIMContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {JwtPayload} from 'jsonwebtoken'
import {DataLoaderWorker} from '../graphql/graphql'

export type SCIMContext = {
authToken: JwtPayload
dataLoader: DataLoaderWorker
ip: string
}
196 changes: 196 additions & 0 deletions packages/server/scim/SCIMHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import {HttpRequest, HttpResponse, TemplatedApp} from 'uWebSockets.js'
import querystring from 'querystring'
import SCIMMY from 'scimmy'
import appOrigin from '../appOrigin'
import {getNewDataLoader} from '../dataloader/getNewDataLoader'
import uWSAsyncHandler from '../graphql/uWSAsyncHandler'
import parseBody from '../parseBody'
import getVerifiedAuthToken from '../utils/getVerifiedAuthToken'
import {SCIMContext} from './SCIMContext'
import './UserEgress'
import './UserIngress'
import './UserDegress'
import {decode} from 'jsonwebtoken'
import makeAppURL from '../../client/utils/makeAppURL'
import {Logger} from '../utils/Logger'
import uwsGetIP from '../utils/uwsGetIP'

SCIMMY.Config.set({
//documentationUri: "https://example.com/docs/scim.html",
patch: true,
filter: true,
bulk: false,
authenticationSchemes: [
{
type: 'oauth2',
name: 'OAuth 2.0 Authorization Framework',
description: 'Authentication scheme using the OAuth 2.0 Authorization Framework Standard',
specUri: 'https://datatracker.ietf.org/doc/html/rfc6749'
},
{
type: 'oauthbearertoken',
name: 'OAuth Bearer Token',
description: 'Authentication scheme using the OAuth Bearer Token Standard',
specUri: 'https://datatracker.ietf.org/doc/html/rfc6750'
}
]
})

type Handler = (
res: HttpResponse,
req: {query: Record<string, string | number>; id?: string; body: Json},
ctx: SCIMContext
) => Promise<void>
const scimHandler = (handler: Handler) =>
uWSAsyncHandler(async (res: HttpResponse, req: HttpRequest) => {
const dataLoader = getNewDataLoader('SCIMHandler')
try {
const ip = uwsGetIP(res, req)
const authHeader =
req.getHeader('x-application-authorization') || req.getHeader('authorization')
const token = authHeader?.slice(7)

// For scimAuthenticationType === bearerToken the token is unsigned as we compare it to the stored token.
// But we want to get the scimId from it so that we know what to compare it to. For OAuth we check the signature later.
const unverifiedToken = token ? decode(token, {json: true}) : null
if (unverifiedToken?.aud !== 'action-scim' || !unverifiedToken.sub) {
throw new SCIMMY.Types.Error(401, '', 'Unauthorized')
}

// We haven't verified the token yet, but we need to read the request before we can await on the DB request
const query: Record<string, string | number> = {}
new URLSearchParams(req.getQuery()).forEach((v, k) => {
// workaround for https://github.com/scimmyjs/scimmy/issues/89
if (['startIndex', 'count'].includes(k)) {
const int = parseInt(v)
if (!isNaN(int)) {
query[k] = int
return
}
}
query[k] = v
})
const parameter = req.getParameter(0)
const id = parameter !== undefined ? querystring.unescape(parameter) : undefined
Comment thread
mattkrick marked this conversation as resolved.
const [body, saml] = await Promise.all([
parseBody({res}),
dataLoader.get('saml').load(unverifiedToken.sub)
])
if (!saml?.scimAuthenticationType) {
throw new SCIMMY.Types.Error(401, '', 'SCIM not enabled for this SSO Domain')
}

// Verify the token
// token was refreshed, so previous token is invalid
if (saml.scimAuthenticationType === 'bearerToken' && saml.scimBearerToken !== token) {
throw new SCIMMY.Types.Error(401, '', 'Unauthorized')
}
// Verify the signature if it's not a bearer token
if (saml.scimAuthenticationType === 'oauthClientCredentials') {
const authToken = getVerifiedAuthToken(token)
if (!authToken.sub) {
throw new SCIMMY.Types.Error(401, '', 'Unauthorized')
}
}

await handler(res, {query, id, body}, {ip, authToken: unverifiedToken, dataLoader})
} catch (err) {
const response = new SCIMMY.Messages.Error(err as any)
if (response.status >= 500) {
Logger.error(err)
}
res
.writeStatus(`${response.status}`)
.writeHeader('Content-Type', 'application/scim+json')
.end(JSON.stringify(response))
} finally {
dataLoader.dispose()
}
})

export const registerSCIMHandlers = (app: TemplatedApp, pathPrefix: string = '/scim') => {
const route = makeAppURL(appOrigin, pathPrefix)
SCIMMY.Resources.Schema.basepath(route)
SCIMMY.Resources.ResourceType.basepath(route)
SCIMMY.Resources.ServiceProviderConfig.basepath(route)
for (let Resource of Object.values(SCIMMY.Resources.declared())) {
Resource.basepath(route)
}

const addHandler = (
path: string,
method: 'get' | 'post' | 'put' | 'patch' | 'del',
handler: Handler
) => {
app[method](`${pathPrefix}${path}`, scimHandler(handler))
}

addHandler('/ServiceProviderConfig', 'get', async (res, _, ctx) => {
res
.writeHeader('Content-Type', 'application/json')
.end(JSON.stringify(await new SCIMMY.Resources.ServiceProviderConfig().read(ctx)))
})

addHandler('/ResourceTypes', 'get', async (res, _, ctx) => {
const resourceTypes = await new SCIMMY.Resources.ResourceType().read(ctx)
res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(resourceTypes))
})
addHandler('/ResourceTypes/:id', 'get', async (res, {query, id}, ctx) => {
const resourceTypes = await new SCIMMY.Resources.ResourceType(id, query).read(ctx)
res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(resourceTypes))
})

addHandler('/Schemas', 'get', async (res, _, ctx) => {
const resourceTypes = await new SCIMMY.Resources.Schema().read(ctx)
res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(resourceTypes))
})
addHandler('/Schemas/:id', 'get', async (res, {id, query}, ctx) => {
const resourceTypes = await new SCIMMY.Resources.Schema(id, query).read(ctx)
res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(resourceTypes))
})

addHandler('/Users', 'get', async (res, {query}, ctx) => {
const users = await new SCIMMY.Resources.User(undefined, query).read(ctx)
res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(users))
})
addHandler('/Users', 'post', async (res, {body}, ctx) => {
if (body === null) {
res.writeStatus('400 Bad Request')
return
}
const user = await new SCIMMY.Resources.User().write(body, ctx)
res
.writeStatus('201 Created')
.writeHeader('Content-Type', 'application/scim+json')
.end(JSON.stringify(user))
})
addHandler('/Users/:id', 'get', async (res, {id, query}, ctx) => {
const user = await new SCIMMY.Resources.User(id, query).read(ctx)
res.writeHeader('Content-Type', 'application/scim+json')
res.end(JSON.stringify(user))
})
addHandler('/Users/:id', 'put', async (res, {id, query, body}, ctx) => {
if (body === null) {
res.writeStatus('400 Bad Request')
return
}
const updatedUser = await new SCIMMY.Resources.User(id, query).write(body as any, ctx)
res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(updatedUser))
})
addHandler('/Users/:id', 'patch', async (res, {id, query, body}, ctx) => {
if (body === null) {
res.writeStatus('400 Bad Request')
return
}
const updatedUser = await new SCIMMY.Resources.User(id, query).patch(body as any, ctx)
res.writeHeader('Content-Type', 'application/scim+json').end(JSON.stringify(updatedUser))
})
addHandler('/Users/:id', 'del', async (res, {id, query}, ctx) => {
await new SCIMMY.Resources.User(id, query).dispose(ctx)
res.writeStatus('204 No Content').end()
})

app.any(`${pathPrefix}/*`, (res) => {
res.writeStatus('404 Not Found').end()
})
}
Loading