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

gergo/web 1926 test app api #3166

Merged
merged 7 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 2 additions & 2 deletions docker-compose-deps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ services:
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_DB_USERNAME: speckle
KC_DB_PASSWORD: speckle

KC_HOSTNAME: 127.0.0.1
KC_HOSTNAME_PORT: 9000
Expand Down
21 changes: 14 additions & 7 deletions packages/server/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Attach",
"port": 9229,
"request": "attach",
"skipFiles": ["<node_internals>/**"],
"type": "node"
},
{
"name": "Launch node",
"program": "${workspaceFolder}/bin/www",
"request": "launch",
"skipFiles": ["<node_internals>/**"],
"type": "node"
},
{
"name": "Attach by Process ID",
"processId": "${command:PickProcess}",
Expand Down Expand Up @@ -66,13 +80,6 @@
"runtimeExecutable": "npm",
"skipFiles": ["<node_internals>/**"],
"type": "node"
},
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/dist/bin/www"
}
]
}
1 change: 1 addition & 0 deletions packages/server/modules/auth/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type AuthSessionData = {
// More specific params used in OpenID based strategies
tokenSet?: TokenSet
userinfo?: UserinfoResponse
codeVerifier?: string
}

export type AuthRequestData = {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/modules/core/rest/defaultErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@ export const defaultErrorHandler: ErrorRequestHandler = (err, req, res, next) =>
res.status(resolveStatusCode(e)).json({
error: resolveErrorInfo(e)
})
next()
next(err)
}
81 changes: 81 additions & 0 deletions packages/server/modules/workspaces/clients/oidcProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/* eslint-disable camelcase */
import { BaseError } from '@/modules/shared/errors'
import { OIDCProvider, OIDCProviderAttributes } from '@/modules/workspaces/domain/sso'
import { generators, Issuer, type Client } from 'openid-client'

export const getProviderAuthorizationUrl = async ({
provider,
redirectUrl,
codeVerifier
}: {
provider: OIDCProvider
redirectUrl: URL
codeVerifier: string
}): Promise<URL> => {
const { client } = await initializeIssuerAndClient({ provider, redirectUrl })
const code_challenge = generators.codeChallenge(codeVerifier)
return new URL(
client.authorizationUrl({
scope: 'openid email profile',
redirect_uri: redirectUrl.toString(),
code_challenge,
code_challenge_method: 'S256'
})
)
}

export const initializeIssuerAndClient = async ({
provider,
redirectUrl
}: {
provider: OIDCProvider
redirectUrl?: URL
}): Promise<{ issuer: Issuer; client: Client }> => {
const issuer = await Issuer.discover(provider.issuerUrl)
const client = new issuer.Client({
client_id: provider.clientId,
client_secret: provider.clientSecret,
redirect_uris: redirectUrl ? [redirectUrl.toString()] : [],
response_types: ['code']
})
return { issuer, client }
}

export const getOIDCProviderAttributes = async ({
provider
}: {
provider: OIDCProvider
}): Promise<OIDCProviderAttributes> => {
try {
const { issuer, client } = await initializeIssuerAndClient({ provider })
return {
issuer: {
claimsSupported: (issuer.claims_supported as string[] | undefined) ?? [],
grantTypesSupported:
(issuer.grant_types_supported as string[] | undefined) ?? [],
responseTypesSupported:
(issuer.response_types_supported as string[] | undefined) ?? []
},
client: {
grantTypes: (client.grant_types as string[] | undefined) ?? []
}
}
} catch (err) {
if (err instanceof Error) {
if ('code' in err) {
if (err.code === 'ECONNREFUSED')
throw new BaseError(
'cannot connect to the provider, pls check the connection url',
err
)
} else if ('error' in err) {
if (err.error === 'Realm does not exist')
throw new BaseError(
"The realm doesn't exist, please check your url and OIDC config",
err
)
}
}
throw err
}
}
85 changes: 85 additions & 0 deletions packages/server/modules/workspaces/domain/sso.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { z } from 'zod'

export const oidcProvider = z.object({
providerName: z.string().min(1),
clientId: z.string().min(5),
clientSecret: z.string().min(1),
issuerUrl: z.string().min(1).url()
})

export type OIDCProvider = z.infer<typeof oidcProvider>

type ProviderBaseRecord = {
id: string
createdAt: Date
updatedAt: Date
}

export type OIDCProviderRecord = {
providerType: 'oidc'
provider: OIDCProvider
} & ProviderBaseRecord

// 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 StoreProviderRecord = (args: {
providerRecord: ProviderRecord
}) => Promise<void>

export type WorkspaceSsoProvider = {
workspaceId: string
providerId: string
} & ProviderRecord

export type GetWorkspaceSsoProvider = (args: {
workspaceId: string
}) => Promise<WorkspaceSsoProvider | null>

export type UserSsoSession = {
userId: string
providerId: string
createdAt: Date
lifespan: number
}

export type StoreUserSsoSession = (args: {
userSsoSession: UserSsoSession
}) => Promise<void>

export const oidcProviderValidationRequest = z.object({
token: z.string(),
provider: oidcProvider
})
export type OIDCProviderValidationRequest = z.infer<
typeof oidcProviderValidationRequest
>

export type OIDCProviderAttributes = {
issuer: {
claimsSupported: string[]
grantTypesSupported: string[]
responseTypesSupported: string[]
}
client: {
grantTypes: string[]
}
}

export type GetOIDCProviderAttributes = (args: {
provider: OIDCProvider
}) => Promise<OIDCProviderAttributes>

export type StoreOIDCProviderValidationRequest = (
args: OIDCProviderValidationRequest
) => Promise<void>

export type GetOIDCProviderData = (args: {
validationToken: string
}) => Promise<OIDCProvider | null>

export type AssociateSsoProviderWithWorkspace = (args: {
workspaceId: string
providerId: string
}) => Promise<void>
8 changes: 6 additions & 2 deletions packages/server/modules/workspaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { workspaceScopes } from '@/modules/workspaces/scopes'
import { registerOrUpdateRole } from '@/modules/shared/repositories/roles'
import { initializeEventListenersFactory } from '@/modules/workspaces/events/eventListener'
import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense'
import ssoRouter from '@/modules/workspaces/rest/sso'

const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
const { FF_WORKSPACES_MODULE_ENABLED, FF_WORKSPACES_SSO_ENABLED } = getFeatureFlags()

let quitListeners: Optional<() => void> = undefined

Expand All @@ -24,7 +25,7 @@ const initRoles = async () => {
}

const workspacesModule: SpeckleModule = {
async init(_, isInitial) {
async init(app, isInitial) {
if (!FF_WORKSPACES_MODULE_ENABLED) return
const isWorkspaceLicenseValid = await validateModuleLicense({
requiredModules: ['workspaces']
Expand All @@ -36,7 +37,10 @@ const workspacesModule: SpeckleModule = {
)
moduleLogger.info('⚒️ Init workspaces module')

if (FF_WORKSPACES_SSO_ENABLED) app.use(ssoRouter)

if (isInitial) {
// register the SSO endpoints
quitListeners = initializeEventListenersFactory({ db })()
}
await Promise.all([initScopes(), initRoles()])
Expand Down
95 changes: 95 additions & 0 deletions packages/server/modules/workspaces/repositories/sso.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
oidcProvider,
GetOIDCProviderData,
StoreOIDCProviderValidationRequest,
StoreProviderRecord,
ProviderRecord,
AssociateSsoProviderWithWorkspace,
StoreUserSsoSession,
UserSsoSession,
GetWorkspaceSsoProvider
} from '@/modules/workspaces/domain/sso'
import Redis from 'ioredis'
import { Knex } from 'knex'
import { omit } from 'lodash'

type Crypt = (input: string) => Promise<string>

type StoredSsoProvider = Omit<ProviderRecord, 'provider'> & {
encryptedProviderData: string
}
type WorkspaceSsoProvider = { workspaceId: string; providerId: string }

const tables = {
ssoProviders: (db: Knex) => db<StoredSsoProvider>('sso_providers'),
userSsoSessions: (db: Knex) => db<UserSsoSession>('user_sso_sessions'),
workspaceSsoProviders: (db: Knex) =>
db<WorkspaceSsoProvider>('workspace_sso_providers')
}

export const storeOIDCProviderValidationRequestFactory =
({
redis,
encrypt
}: {
redis: Redis
encrypt: Crypt
}): StoreOIDCProviderValidationRequest =>
async ({ provider, token }) => {
const providerData = await encrypt(JSON.stringify(provider))
await redis.set(token, providerData)
}

export const getOIDCProviderFactory =
({ redis, decrypt }: { redis: Redis; decrypt: Crypt }): GetOIDCProviderData =>
async ({ validationToken }: { validationToken: string }) => {
const encryptedProviderData = await redis.get(validationToken)
if (!encryptedProviderData) return null
const provider = oidcProvider.parse(
JSON.parse(await decrypt(encryptedProviderData))
)
return provider
}

export const getWorkspaceSsoProviderFactory =
({ db, decrypt }: { db: Knex; decrypt: Crypt }): GetWorkspaceSsoProvider =>
async ({ workspaceId }) => {
const maybeProvider = await db<WorkspaceSsoProvider & StoredSsoProvider>(
'workspace_sso_providers'
)
.where({ workspaceId })
.first()
if (!maybeProvider) return null
const decryptedProviderData = await decrypt(maybeProvider.encryptedProviderData)
switch (maybeProvider.providerType) {
case 'oidc':
return {
...omit(maybeProvider),
provider: oidcProvider.parse(decryptedProviderData)
}
default:
// this is an internal error
throw new Error('Provider type not supported')
}
}

export const storeProviderRecordFactory =
({ db, encrypt }: { db: Knex; encrypt: Crypt }): StoreProviderRecord =>
async ({ providerRecord }) => {
const encryptedProviderData = await encrypt(JSON.stringify(providerRecord.provider))
const insertModel = { ...omit(providerRecord, 'provider'), encryptedProviderData }
await tables.ssoProviders(db).insert(insertModel)
}

export const associateSsoProviderWithWorkspaceFactory =
({ db }: { db: Knex }): AssociateSsoProviderWithWorkspace =>
async ({ providerId, workspaceId }) => {
await tables.workspaceSsoProviders(db).insert({ providerId, workspaceId })
}

// this should be an upsert, if the session exists, we just update the createdAt and lifespan
export const storeUserSsoSessionFactory =
({ db }: { db: Knex }): StoreUserSsoSession =>
async ({ userSsoSession }) => {
await tables.userSsoSessions(db).insert(userSsoSession)
}
Loading
Loading