From b38133eeb95f5ae06b4516f6780220662208cd8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Mon, 23 Sep 2024 17:44:30 +0200 Subject: [PATCH 01/47] feat(workspaces): add workspace sso feature flag --- .circleci/config.yml | 1 + packages/shared/src/environment/index.ts | 5 +++++ utils/helm/speckle-server/templates/_helpers.tpl | 3 +++ .../helm/speckle-server/templates/frontend_2/deployment.yml | 2 ++ utils/helm/speckle-server/values.schema.json | 5 +++++ utils/helm/speckle-server/values.yaml | 2 ++ 6 files changed, 18 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index e0e39997e6..29d7191e75 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -554,6 +554,7 @@ jobs: AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json' FF_AUTOMATE_MODULE_ENABLED: 'false' # Disable all FFs FF_WORKSPACES_MODULE_ENABLED: 'false' + FF_WORKSPACES_SSO_ENABLED: 'false' FF_MULTIPLE_EMAILS_MODULE_ENABLED: 'false' FF_GENDOAI_MODULE_ENABLED: 'false' diff --git a/packages/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index 8c42e16c81..968206e08d 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -20,6 +20,11 @@ function parseFeatureFlags() { schema: z.boolean(), defaults: { production: false, _: true } }, + // Enables using dynamic SSO on a per workspace basis + FF_WORKSPACES_SSO_ENABLED: { + schema: z.boolean(), + defaults: { production: false, _: true } + }, // Enables the multiple emails module FF_MULTIPLE_EMAILS_MODULE_ENABLED: { schema: z.boolean(), diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index c89e59e6d9..0c6516cf96 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -566,6 +566,9 @@ Generate the environment variables for Speckle server and Speckle objects deploy - name: FF_WORKSPACES_MODULE_ENABLED value: {{ .Values.featureFlags.workspaceModuleEnabled | quote }} +- name: FF_WORKSPACES_SSO_ENABLED + value: {{ .Values.featureFlags.workspaceSsoEnabled | quote }} + {{- if .Values.featureFlags.workspaceModuleEnabled }} - name: LICENSE_TOKEN valueFrom: diff --git a/utils/helm/speckle-server/templates/frontend_2/deployment.yml b/utils/helm/speckle-server/templates/frontend_2/deployment.yml index e40a57823e..68db0c528f 100644 --- a/utils/helm/speckle-server/templates/frontend_2/deployment.yml +++ b/utils/helm/speckle-server/templates/frontend_2/deployment.yml @@ -119,6 +119,8 @@ spec: value: {{ .Values.featureFlags.automateModuleEnabled | quote }} - name: NUXT_PUBLIC_FF_WORKSPACES_MODULE_ENABLED value: {{ .Values.featureFlags.workspaceModuleEnabled | quote }} + - name: NUXT_PUBLIC_FF_WORKSPACES_SSO_ENABLED + value: {{ .Values.featureFlags.workspaceSsoEnabled | quote }} - name: NUXT_PUBLIC_FF_MULTIPLE_EMAILS_MODULE_ENABLED value: {{ .Values.featureFlags.multipleEmailsModuleEnabled | quote }} {{- if .Values.analytics.survicate_workspace_key }} diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index d365141069..a8f0909295 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -55,6 +55,11 @@ "description": "High level flag fully toggles the workspaces module", "default": false }, + "workspaceSsoEnabled": { + "type": "boolean", + "description": "High level flag fully toggles the workspaces dynamic sso", + "default": false + }, "multipleEmailsModuleEnabled": { "type": "boolean", "description": "High level flag fully toggles multiple emails", diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 8763ec7873..438abc12c0 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -43,6 +43,8 @@ featureFlags: noClosureWrites: false ## @param featureFlags.workspaceModuleEnabled High level flag fully toggles the workspaces module workspaceModuleEnabled: false + ## @param featureFlags.workspaceSsoEnabled High level flag fully toggles the workspaces dynamic sso + workspaceSsoEnabled: false ## @param featureFlags.multipleEmailsModuleEnabled High level flag fully toggles multiple emails multipleEmailsModuleEnabled: false From 29d6bdc62fb0403da16168c016fb209b2d23971c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Thu, 26 Sep 2024 13:29:20 +0200 Subject: [PATCH 02/47] feat(workspaceSso): wip validate sso --- docker-compose-deps.yml | 4 +- .../modules/core/rest/defaultErrorHandler.ts | 2 +- .../workspaces/clients/oidcProvider.ts | 98 ++++++++++++++++ .../server/modules/workspaces/domain/sso.ts | 51 ++++++++ .../server/modules/workspaces/domain/types.ts | 1 + packages/server/modules/workspaces/index.ts | 8 +- .../modules/workspaces/repositories/sso.ts | 36 ++++++ .../server/modules/workspaces/rest/sso.ts | 109 ++++++++++++++++++ .../server/modules/workspaces/services/sso.ts | 78 +++++++++++++ packages/server/package.json | 1 + packages/server/validateOidc.js | 31 +++++ packages/shared/src/environment/index.ts | 1 + workspace.code-workspace | 10 +- yarn.lock | 12 ++ 14 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 packages/server/modules/workspaces/clients/oidcProvider.ts create mode 100644 packages/server/modules/workspaces/domain/sso.ts create mode 100644 packages/server/modules/workspaces/repositories/sso.ts create mode 100644 packages/server/modules/workspaces/rest/sso.ts create mode 100644 packages/server/modules/workspaces/services/sso.ts create mode 100644 packages/server/validateOidc.js diff --git a/docker-compose-deps.yml b/docker-compose-deps.yml index cf556f7bed..97f036ec1a 100644 --- a/docker-compose-deps.yml +++ b/docker-compose-deps.yml @@ -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 diff --git a/packages/server/modules/core/rest/defaultErrorHandler.ts b/packages/server/modules/core/rest/defaultErrorHandler.ts index 155a8a30ae..1c31c8b60c 100644 --- a/packages/server/modules/core/rest/defaultErrorHandler.ts +++ b/packages/server/modules/core/rest/defaultErrorHandler.ts @@ -59,5 +59,5 @@ export const defaultErrorHandler: ErrorRequestHandler = (err, req, res, next) => res.status(resolveStatusCode(e)).json({ error: resolveErrorInfo(e) }) - next() + next(err) } diff --git a/packages/server/modules/workspaces/clients/oidcProvider.ts b/packages/server/modules/workspaces/clients/oidcProvider.ts new file mode 100644 index 0000000000..ad4412d496 --- /dev/null +++ b/packages/server/modules/workspaces/clients/oidcProvider.ts @@ -0,0 +1,98 @@ +/* eslint-disable camelcase */ +import { BaseError } from '@/modules/shared/errors' +import { + GetOIDCUserData, + 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 => { + 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 => { + 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 + } +} + +export const getOIDCUserData: GetOIDCUserData = async ({ + provider, + codeVerifier, + callbackParams +}) => { + try { + const { client } = await initializeIssuerAndClient({ provider }) + console.log(userInfo) + } catch (err) { + throw err + } +} diff --git a/packages/server/modules/workspaces/domain/sso.ts b/packages/server/modules/workspaces/domain/sso.ts new file mode 100644 index 0000000000..de0f0094eb --- /dev/null +++ b/packages/server/modules/workspaces/domain/sso.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' + +export const oidcProvider = z.object({ + clientId: z.string(), + clientSecret: z.string(), + issuerUrl: z.string() +}) + +export type OIDCProvider = z.infer + +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 + +export type StoreOIDCProviderValidationRequest = ( + args: OIDCProviderValidationRequest +) => Promise + +export type GetOIDCProviderData = (args: { + validationToken: string +}) => Promise + +export type OIDCCallbackParams = { + code: string + session_state: string +} + +export type GetOIDCUserData = (args: { + codeVerifier: string + provider: OIDCProvider + callbackParams: OIDCCallbackParams +}) => Promise diff --git a/packages/server/modules/workspaces/domain/types.ts b/packages/server/modules/workspaces/domain/types.ts index 3661b246f6..56792ae297 100644 --- a/packages/server/modules/workspaces/domain/types.ts +++ b/packages/server/modules/workspaces/domain/types.ts @@ -25,3 +25,4 @@ export type WorkspaceTeam = WorkspaceTeamMember[] export type WorkspaceRoleToDefaultProjectRoleMapping = { [key in WorkspaceRoles]: StreamRoles | null } + diff --git a/packages/server/modules/workspaces/index.ts b/packages/server/modules/workspaces/index.ts index 8a361f1879..de561b7ce5 100644 --- a/packages/server/modules/workspaces/index.ts +++ b/packages/server/modules/workspaces/index.ts @@ -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 @@ -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'] @@ -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()]) diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts new file mode 100644 index 0000000000..73f3fe3ae5 --- /dev/null +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -0,0 +1,36 @@ +import { + oidcProvider, + GetOIDCProviderData, + StoreOIDCProviderValidationRequest +} from '@/modules/workspaces/domain/sso' +import Redis from 'ioredis' + +export const storeOIDCProviderValidationRequestFactory = + ({ + redis, + encrypt + }: { + redis: Redis + encrypt: (input: string) => Promise + }): StoreOIDCProviderValidationRequest => + async ({ provider, token }) => { + const providerData = await encrypt(JSON.stringify(provider)) + await redis.set(token, providerData) + } + +export const getOIDCProviderFactory = + ({ + redis, + decrypt + }: { + redis: Redis + decrypt: (input: string) => Promise + }): 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 + } diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts new file mode 100644 index 0000000000..368f7afede --- /dev/null +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -0,0 +1,109 @@ +import { validateRequest } from 'zod-express' +import { Router } from 'express' +import { z } from 'zod' +import { + finishOIDCSsoProviderValidationFactory, + startOIDCSsoProviderValidationFactory +} from '@/modules/workspaces/services/sso' +import { + getOIDCProviderAttributes, + getOIDCUserData, + getProviderAuthorizationUrl, + initializeIssuerAndClient +} from '@/modules/workspaces/clients/oidcProvider' +import { getFrontendOrigin, getServerOrigin } from '@/modules/shared/helpers/envHelper' +import { + storeOIDCProviderValidationRequestFactory, + getOIDCProviderFactory +} from '@/modules/workspaces/repositories/sso' +import { buildDecryptor, buildEncryptor } from '@/modules/shared/utils/libsodium' +import { getEncryptionKeyPair } from '@/modules/automate/services/encryption' +import { getGenericRedis } from '@/modules/core' +import { generators } from 'openid-client' + +const router = Router() + +const cookieName = 'validationToken' + +router.get( + '/api/v1/sso/oidc/validate', + validateRequest({ + query: z.object({ + clientId: z.string().min(5), + clientSecret: z.string().min(1), + issuerUrl: z.string().min(1).url(), + finalizePage: z.string().url() + }) + }), + async ({ query, res }) => { + const provider = query + const encryptionKeyPair = await getEncryptionKeyPair() + const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) + const codeVerifier = await startOIDCSsoProviderValidationFactory({ + getOIDCProviderAttributes, + storeOIDCProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ + redis: getGenericRedis(), + encrypt: encryptor.encrypt + }), + generateCodeVerifier: generators.codeVerifier + })({ + provider + }) + const redirectUrl = new URL('/api/v1/sso/oidc/validate/callback', getServerOrigin()) + const authorizationUrl = await getProviderAuthorizationUrl({ + provider, + redirectUrl, + codeVerifier + }) + res?.cookie(cookieName, await encryptor.encrypt(codeVerifier)) + // maybe not needed + // encryptor.dispose() + res?.redirect(authorizationUrl.toString()) + } +) + +router.get( + '/api/v1/workspaces/{workspaceSlug}/sso/oidc/validate/callback', + async (req) => { + const frontendOrigin = getFrontendOrigin() + const redirectUrl = new URL(frontendOrigin) + const successKey = 'success' + redirectUrl.searchParams.set(successKey, 'false') + try { + const encryptionKeyPair = await getEncryptionKeyPair() + let decryptor = await buildDecryptor(encryptionKeyPair) + const encryptedValidationToken = req.cookies[cookieName] as string | undefined + if (!encryptedValidationToken) throw new Error('cannot find token') + + const codeVerifier = await decryptor.decrypt(encryptedValidationToken) + decryptor = await buildDecryptor(encryptionKeyPair) + + const provider = await getOIDCProviderFactory({ + redis: getGenericRedis(), + decrypt: decryptor.decrypt + })({ + validationToken: codeVerifier + }) + if (!provider) throw new Error('validation request not found, please retry') + + const { client } = await initializeIssuerAndClient({ provider }) + const callbackParams = client.callbackParams(req) + const tokenset = await client.callback( + `http://speckle.internal:3000/api/v1/sso/oidc/validate/callback`, + callbackParams, + // eslint-disable-next-line camelcase + { code_verifier: codeVerifier } + ) + const userInfo = await client.userinfo(tokenset) + + console.log(req.query) + } catch (err) { + } finally { + req.res?.clearCookie(cookieName) + // redirectUrl. + req.res?.redirect(redirectUrl.toString()) + } + } +) + +export default router diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts new file mode 100644 index 0000000000..3f86e1ec96 --- /dev/null +++ b/packages/server/modules/workspaces/services/sso.ts @@ -0,0 +1,78 @@ +import { + GetOIDCProviderAttributes, + OIDCProviderAttributes, + OIDCProvider, + StoreOIDCProviderValidationRequest, + GetOIDCProviderData, + GetOIDCUserData, + OIDCCallbackParams +} from '@/modules/workspaces/domain/sso' +import { BaseError } from '@/modules/shared/errors/base' +import { generators } from 'openid-client' + +export class MissingOIDCProviderGrantType extends BaseError { + static defaultMessage = 'OIDC issuer does not support authorization_code grant type' + static code = 'OIDC_SSO_MISSING_GRANT_TYPE' + static statusCode = 400 +} + +const validateOIDCProviderAttributes = ({ + // client, + issuer +}: OIDCProviderAttributes): void => { + if (!issuer.grantTypesSupported.includes('authorization_code')) + throw new MissingOIDCProviderGrantType() + /* +validate issuer: +authorization_signing_alg_values_supported +claims_supported: ['email', 'name', 'given_name', 'family_name'] +grant_types_supported: ['authorization_code'] +response_types_supported: //TODO figure out which + +validate client: +grant_types: ['authorization_code'], + */ +} + +export const startOIDCSsoProviderValidationFactory = + ({ + getOIDCProviderAttributes, + storeOIDCProviderValidationRequest, + generateCodeVerifier + }: { + getOIDCProviderAttributes: GetOIDCProviderAttributes + storeOIDCProviderValidationRequest: StoreOIDCProviderValidationRequest + generateCodeVerifier: () => string + }) => + async ({ provider }: { provider: OIDCProvider }): Promise => { + // get client information + const providerAttributes = await getOIDCProviderAttributes({ provider }) + // validate issuer and client data + validateOIDCProviderAttributes(providerAttributes) + // store provider validation with an id token + const codeVerifier = generateCodeVerifier() + await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) + return codeVerifier + } + +export const finishOIDCSsoProviderValidationFactory = + ({ + getOIDCProviderData, + getOIDCUserData + }: { + getOIDCProviderData: GetOIDCProviderData + getOIDCUserData: GetOIDCUserData + }) => + async ({ + issuer, + validationToken, + callbackParams + }: { + issuer: string + validationToken: string + callbackParams: OIDCCallbackParams + }): Promise => { + //get stored provider validation request + // throw error if not found + return true + } diff --git a/packages/server/package.json b/packages/server/package.json index a70f921ac4..7a1814ba4a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -109,6 +109,7 @@ "xml-escape": "^1.1.0", "znv": "^0.4.0", "zod": "^3.22.4", + "zod-express": "^0.0.8", "zod-validation-error": "^1.5.0", "zxcvbn": "^4.4.2" }, diff --git a/packages/server/validateOidc.js b/packages/server/validateOidc.js new file mode 100644 index 0000000000..f20e112dd6 --- /dev/null +++ b/packages/server/validateOidc.js @@ -0,0 +1,31 @@ +const { Issuer } = require('openid-client') + +;(async () => { + const issuer = await Issuer.discover( + 'http://127.0.0.1:8090/realms/speckle/.well-known/openid-configuration' + ) + /* + to validate from issuer: + authorization_signing_alg_values_supported + claims_supported: ['email', 'name', 'given_name', 'family_name'] + grant_types_supported: ['authorization_code'] + response_types_supported: //TODO figure out which + + */ + console.log(issuer) + const client = new issuer.Client({ + client_id: 'speckle', + client_secret: 'OZ6zj7H1G7jQw6qUDif1aoQVxTOGPkJK1', + redirect_uris: ['http://localghost:3000/cb'], + response_types: ['code'] + // id_token_signed_response_alg (default "RS256") + // token_endpoint_auth_method (default "client_secret_basic") + }) + client.authorizationUrl({request_uri: }) + console.log(await client) + + /* + validate from client: + grant_types: ['authorization_code'], + */ +})() diff --git a/packages/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index 968206e08d..f191596bfa 100644 --- a/packages/shared/src/environment/index.ts +++ b/packages/shared/src/environment/index.ts @@ -45,6 +45,7 @@ export function getFeatureFlags(): { FF_GENDOAI_MODULE_ENABLED: boolean FF_NO_CLOSURE_WRITES: boolean FF_WORKSPACES_MODULE_ENABLED: boolean + FF_WORKSPACES_SSO_ENABLED: boolean } { if (!parsedFlags) parsedFlags = parseFeatureFlags() return parsedFlags diff --git a/workspace.code-workspace b/workspace.code-workspace index efd2ceebd4..75d24d3828 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -90,7 +90,15 @@ }, "files.eol": "\n", "volar.vueserver.maxOldSpaceSize": 4000, - "cSpell.words": ["Automations", "Bursty", "discoverability", "Insertable", "mjml"], + "cSpell.words": [ + "Automations", + "Bursty", + "discoverability", + "Encryptor", + "Insertable", + "mjml", + "OIDC" + ], "tailwindCSS.experimental.configFile": { "packages/frontend-2/tailwind.config.mjs": "packages/frontend-2/**" }, diff --git a/yarn.lock b/yarn.lock index cacd093f46..77f6d72bf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16733,6 +16733,7 @@ __metadata: yargs: "npm:^17.3.1" znv: "npm:^0.4.0" zod: "npm:^3.22.4" + zod-express: "npm:^0.0.8" zod-validation-error: "npm:^1.5.0" zxcvbn: "npm:^4.4.2" languageName: unknown @@ -53807,6 +53808,17 @@ __metadata: languageName: node linkType: hard +"zod-express@npm:^0.0.8": + version: 0.0.8 + resolution: "zod-express@npm:0.0.8" + peerDependencies: + "@types/express": ^4.17.12 + express: ^4.18.2 + zod: ^3.21.4 + checksum: 10/1cc7cc36cc57f8a26a1ad82e18785ce8c5206a6c68e52188a62ef0c272207332d3a2d227f20b091045eb03aab6af5b34f84d539097db778307e369d44ee56e66 + languageName: node + linkType: hard + "zod-validation-error@npm:^1.5.0": version: 1.5.0 resolution: "zod-validation-error@npm:1.5.0" From 7da225b92921069e5c9d48c6b26e3bf26e6b7feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 1 Oct 2024 12:02:45 +0100 Subject: [PATCH 03/47] feat(workspaces): validate and add sso provider to the workspace with user sso sessions --- packages/server/.vscode/launch.json | 21 +- packages/server/modules/auth/helpers/types.ts | 1 + .../workspaces/clients/oidcProvider.ts | 19 +- .../server/modules/workspaces/domain/sso.ts | 58 +++- .../server/modules/workspaces/domain/types.ts | 1 - .../modules/workspaces/repositories/sso.ts | 77 ++++- .../server/modules/workspaces/rest/sso.ts | 264 +++++++++++++----- .../server/modules/workspaces/services/sso.ts | 83 ++++-- .../20240930141322_workspace_sso.ts | 49 ++++ 9 files changed, 434 insertions(+), 139 deletions(-) create mode 100644 packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts diff --git a/packages/server/.vscode/launch.json b/packages/server/.vscode/launch.json index f31af9b0f6..19791fef48 100644 --- a/packages/server/.vscode/launch.json +++ b/packages/server/.vscode/launch.json @@ -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": ["/**"], + "type": "node" + }, + { + "name": "Launch node", + "program": "${workspaceFolder}/bin/www", + "request": "launch", + "skipFiles": ["/**"], + "type": "node" + }, { "name": "Attach by Process ID", "processId": "${command:PickProcess}", @@ -66,13 +80,6 @@ "runtimeExecutable": "npm", "skipFiles": ["/**"], "type": "node" - }, - { - "type": "node", - "request": "launch", - "name": "Launch Program", - "skipFiles": ["/**"], - "program": "${workspaceFolder}/dist/bin/www" } ] } diff --git a/packages/server/modules/auth/helpers/types.ts b/packages/server/modules/auth/helpers/types.ts index e346d033e4..7896258acc 100644 --- a/packages/server/modules/auth/helpers/types.ts +++ b/packages/server/modules/auth/helpers/types.ts @@ -41,6 +41,7 @@ export type AuthSessionData = { // More specific params used in OpenID based strategies tokenSet?: TokenSet userinfo?: UserinfoResponse + codeVerifier?: string } export type AuthRequestData = { diff --git a/packages/server/modules/workspaces/clients/oidcProvider.ts b/packages/server/modules/workspaces/clients/oidcProvider.ts index ad4412d496..652ebf1242 100644 --- a/packages/server/modules/workspaces/clients/oidcProvider.ts +++ b/packages/server/modules/workspaces/clients/oidcProvider.ts @@ -1,10 +1,6 @@ /* eslint-disable camelcase */ import { BaseError } from '@/modules/shared/errors' -import { - GetOIDCUserData, - OIDCProvider, - OIDCProviderAttributes -} from '@/modules/workspaces/domain/sso' +import { OIDCProvider, OIDCProviderAttributes } from '@/modules/workspaces/domain/sso' import { generators, Issuer, type Client } from 'openid-client' export const getProviderAuthorizationUrl = async ({ @@ -83,16 +79,3 @@ export const getOIDCProviderAttributes = async ({ throw err } } - -export const getOIDCUserData: GetOIDCUserData = async ({ - provider, - codeVerifier, - callbackParams -}) => { - try { - const { client } = await initializeIssuerAndClient({ provider }) - console.log(userInfo) - } catch (err) { - throw err - } -} diff --git a/packages/server/modules/workspaces/domain/sso.ts b/packages/server/modules/workspaces/domain/sso.ts index de0f0094eb..92c7fcec54 100644 --- a/packages/server/modules/workspaces/domain/sso.ts +++ b/packages/server/modules/workspaces/domain/sso.ts @@ -1,13 +1,53 @@ import { z } from 'zod' export const oidcProvider = z.object({ - clientId: z.string(), - clientSecret: z.string(), - issuerUrl: z.string() + 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 +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 + +export type WorkspaceSsoProvider = { + workspaceId: string + providerId: string +} & ProviderRecord + +export type GetWorkspaceSsoProvider = (args: { + workspaceId: string +}) => Promise + +export type UserSsoSession = { + userId: string + providerId: string + createdAt: Date + lifespan: number +} + +export type StoreUserSsoSession = (args: { + userSsoSession: UserSsoSession +}) => Promise + export const oidcProviderValidationRequest = z.object({ token: z.string(), provider: oidcProvider @@ -39,13 +79,7 @@ export type GetOIDCProviderData = (args: { validationToken: string }) => Promise -export type OIDCCallbackParams = { - code: string - session_state: string -} - -export type GetOIDCUserData = (args: { - codeVerifier: string - provider: OIDCProvider - callbackParams: OIDCCallbackParams +export type AssociateSsoProviderWithWorkspace = (args: { + workspaceId: string + providerId: string }) => Promise diff --git a/packages/server/modules/workspaces/domain/types.ts b/packages/server/modules/workspaces/domain/types.ts index 56792ae297..3661b246f6 100644 --- a/packages/server/modules/workspaces/domain/types.ts +++ b/packages/server/modules/workspaces/domain/types.ts @@ -25,4 +25,3 @@ export type WorkspaceTeam = WorkspaceTeamMember[] export type WorkspaceRoleToDefaultProjectRoleMapping = { [key in WorkspaceRoles]: StreamRoles | null } - diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index 73f3fe3ae5..8511e4a210 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -1,9 +1,31 @@ import { oidcProvider, GetOIDCProviderData, - StoreOIDCProviderValidationRequest + 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 + +type StoredSsoProvider = Omit & { + encryptedProviderData: string +} +type WorkspaceSsoProvider = { workspaceId: string; providerId: string } + +const tables = { + ssoProviders: (db: Knex) => db('sso_providers'), + userSsoSessions: (db: Knex) => db('user_sso_sessions'), + workspaceSsoProviders: (db: Knex) => + db('workspace_sso_providers') +} export const storeOIDCProviderValidationRequestFactory = ({ @@ -11,7 +33,7 @@ export const storeOIDCProviderValidationRequestFactory = encrypt }: { redis: Redis - encrypt: (input: string) => Promise + encrypt: Crypt }): StoreOIDCProviderValidationRequest => async ({ provider, token }) => { const providerData = await encrypt(JSON.stringify(provider)) @@ -19,13 +41,7 @@ export const storeOIDCProviderValidationRequestFactory = } export const getOIDCProviderFactory = - ({ - redis, - decrypt - }: { - redis: Redis - decrypt: (input: string) => Promise - }): GetOIDCProviderData => + ({ redis, decrypt }: { redis: Redis; decrypt: Crypt }): GetOIDCProviderData => async ({ validationToken }: { validationToken: string }) => { const encryptedProviderData = await redis.get(validationToken) if (!encryptedProviderData) return null @@ -34,3 +50,46 @@ export const getOIDCProviderFactory = ) return provider } + +export const getWorkspaceSsoProviderFactory = + ({ db, decrypt }: { db: Knex; decrypt: Crypt }): GetWorkspaceSsoProvider => + async ({ workspaceId }) => { + const maybeProvider = await db( + '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) + } diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 368f7afede..6eace59a1c 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -1,107 +1,231 @@ +import { db } from '@/db/knex' import { validateRequest } from 'zod-express' import { Router } from 'express' import { z } from 'zod' import { - finishOIDCSsoProviderValidationFactory, + saveSsoProviderRegistrationFactory, startOIDCSsoProviderValidationFactory } from '@/modules/workspaces/services/sso' import { getOIDCProviderAttributes, - getOIDCUserData, getProviderAuthorizationUrl, initializeIssuerAndClient } from '@/modules/workspaces/clients/oidcProvider' -import { getFrontendOrigin, getServerOrigin } from '@/modules/shared/helpers/envHelper' +import { + getFrontendOrigin, + getRedisUrl, + getServerOrigin, + getSessionSecret, + isSSLServer +} from '@/modules/shared/helpers/envHelper' import { storeOIDCProviderValidationRequestFactory, - getOIDCProviderFactory + getOIDCProviderFactory, + associateSsoProviderWithWorkspaceFactory, + storeProviderRecordFactory, + storeUserSsoSessionFactory, + getWorkspaceSsoProviderFactory } from '@/modules/workspaces/repositories/sso' import { buildDecryptor, buildEncryptor } from '@/modules/shared/utils/libsodium' import { getEncryptionKeyPair } from '@/modules/automate/services/encryption' import { getGenericRedis } from '@/modules/core' import { generators } from 'openid-client' +import { createRedisClient } from '@/modules/shared/redis/redis' +// temp imports +import ConnectRedis from 'connect-redis' +import ExpressSession from 'express-session' +import { noop } from 'lodash' +import { oidcProvider } from '@/modules/workspaces/domain/sso' +import { getWorkspaceBySlugFactory } from '@/modules/workspaces/repositories/workspaces' +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' +import { authorizeResolver } from '@/modules/shared' +import { Roles } from '@speckle/shared' +import { createUserEmailFactory } from '@/modules/core/repositories/userEmails' const router = Router() -const cookieName = 'validationToken' +// todo, this should be using the app wide session middleware +const RedisStore = ConnectRedis(ExpressSession) +const redisClient = createRedisClient(getRedisUrl(), {}) +const sessionMiddleware = ExpressSession({ + store: new RedisStore({ client: redisClient }), + secret: getSessionSecret(), + saveUninitialized: false, + resave: false, + cookie: { + maxAge: 1000 * 60 * 3, // 3 minutes + secure: isSSLServer() + } +}) + +const buildAuthRedirectUrl = (workspaceSlug: string): URL => + new URL( + `/api/v1/workspaces/${workspaceSlug}/sso/oidc/callback?validate=true`, + getServerOrigin() + ) + +const buildFinalizeUrl = (workspaceSlug: string): URL => + new URL(`workspaces/${workspaceSlug}/?settings=server/general`, getFrontendOrigin()) + +const ssoVerificationStatusKey = 'ssoVerificationStatus' + +const buildErrorUrl = ({ err, url }: { err: unknown; url: URL }): URL => { + const settingsSearch = url.searchParams.get('settings') + url.searchParams.forEach((key) => { + url.searchParams.delete(key) + }) + if (settingsSearch) url.searchParams.set('settings', settingsSearch) + url.searchParams.set(ssoVerificationStatusKey, 'failed') + const errorMessage = err instanceof Error ? err.message : `Unknown error ${err}` + url.searchParams.set('ssoVerificationError', errorMessage) + return url +} router.get( - '/api/v1/sso/oidc/validate', + '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', + sessionMiddleware, validateRequest({ - query: z.object({ - clientId: z.string().min(5), - clientSecret: z.string().min(1), - issuerUrl: z.string().min(1).url(), - finalizePage: z.string().url() - }) + params: z.object({ + workspaceSlug: z.string().min(1) + }), + query: oidcProvider }), - async ({ query, res }) => { - const provider = query - const encryptionKeyPair = await getEncryptionKeyPair() - const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - const codeVerifier = await startOIDCSsoProviderValidationFactory({ - getOIDCProviderAttributes, - storeOIDCProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ - redis: getGenericRedis(), - encrypt: encryptor.encrypt - }), - generateCodeVerifier: generators.codeVerifier - })({ - provider - }) - const redirectUrl = new URL('/api/v1/sso/oidc/validate/callback', getServerOrigin()) - const authorizationUrl = await getProviderAuthorizationUrl({ - provider, - redirectUrl, - codeVerifier - }) - res?.cookie(cookieName, await encryptor.encrypt(codeVerifier)) - // maybe not needed - // encryptor.dispose() - res?.redirect(authorizationUrl.toString()) + async ({ session, params, query, res }) => { + try { + const provider = query + const encryptionKeyPair = await getEncryptionKeyPair() + const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) + const codeVerifier = await startOIDCSsoProviderValidationFactory({ + getOIDCProviderAttributes, + storeOIDCProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ + redis: getGenericRedis(), + encrypt: encryptor.encrypt + }), + generateCodeVerifier: generators.codeVerifier + })({ + provider + }) + const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug) + const authorizationUrl = await getProviderAuthorizationUrl({ + provider, + redirectUrl, + codeVerifier + }) + session.codeVerifier = await encryptor.encrypt(codeVerifier) + + // maybe not needed + encryptor.dispose() + res?.redirect(authorizationUrl.toString()) + } catch (err) { + session.destroy(noop) + const url = buildErrorUrl({ err, url: buildFinalizeUrl(params.workspaceSlug) }) + res?.redirect(url.toString()) + } } ) router.get( - '/api/v1/workspaces/{workspaceSlug}/sso/oidc/validate/callback', + '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', + sessionMiddleware, + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }), + query: z.object({ validate: z.string() }) + }), async (req) => { - const frontendOrigin = getFrontendOrigin() - const redirectUrl = new URL(frontendOrigin) - const successKey = 'success' - redirectUrl.searchParams.set(successKey, 'false') - try { - const encryptionKeyPair = await getEncryptionKeyPair() - let decryptor = await buildDecryptor(encryptionKeyPair) - const encryptedValidationToken = req.cookies[cookieName] as string | undefined - if (!encryptedValidationToken) throw new Error('cannot find token') + // this is the verify flow, login will be different + // req.context.userId can be authorized for the workspaceSlug if needed + const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) - const codeVerifier = await decryptor.decrypt(encryptedValidationToken) - decryptor = await buildDecryptor(encryptionKeyPair) - - const provider = await getOIDCProviderFactory({ - redis: getGenericRedis(), - decrypt: decryptor.decrypt - })({ - validationToken: codeVerifier + if (req.query.validate === 'true') { + const workspace = await getWorkspaceBySlugFactory({ db })({ + workspaceSlug: req.params.workspaceSlug }) - if (!provider) throw new Error('validation request not found, please retry') - - const { client } = await initializeIssuerAndClient({ provider }) - const callbackParams = client.callbackParams(req) - const tokenset = await client.callback( - `http://speckle.internal:3000/api/v1/sso/oidc/validate/callback`, - callbackParams, - // eslint-disable-next-line camelcase - { code_verifier: codeVerifier } + if (!workspace) throw new WorkspaceNotFoundError() + await authorizeResolver( + req.context.userId, + workspace.id, + Roles.Workspace.Admin, + req.context.resourceAccessRules ) - const userInfo = await client.userinfo(tokenset) + // once we're authorized for the ws, we must have a userId + const userId = req.context.userId! - console.log(req.query) - } catch (err) { - } finally { - req.res?.clearCookie(cookieName) - // redirectUrl. - req.res?.redirect(redirectUrl.toString()) + // point to the finalize page if there is one + let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug) + try { + const encryptionKeyPair = await getEncryptionKeyPair() + const decryptor = await buildDecryptor(encryptionKeyPair) + + // =================== + const encryptedValidationToken = req.session.codeVerifier + if (!encryptedValidationToken) + throw new Error('cannot find verification token, restart the flow') + + const codeVerifier = await decryptor.decrypt(encryptedValidationToken) + + const provider = await getOIDCProviderFactory({ + redis: getGenericRedis(), + decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt + })({ + validationToken: codeVerifier + }) + if (!provider) throw new Error('validation request not found, please retry') + + const { client } = await initializeIssuerAndClient({ provider }) + const callbackParams = client.callbackParams(req) + const tokenSet = await client.callback( + buildAuthRedirectUrl(req.params.workspaceSlug).toString(), + callbackParams, + // eslint-disable-next-line camelcase + { code_verifier: codeVerifier } + ) + + // now that we have the user's email, we should compare it to the active user's email. + // Ask if they want to add the email to the oidc as a secondary email, if it doesn't match any of the user's emails + const ssoProviderUserInfo = await client.userinfo(tokenSet) + if (!ssoProviderUserInfo.email) + throw new Error('This should never happen, we are asking for an email claim') + + const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) + const trx = await db.transaction() + + await saveSsoProviderRegistrationFactory({ + getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ + db: trx, + decrypt: decryptor.decrypt + }), + associateSsoProviderWithWorkspace: associateSsoProviderWithWorkspaceFactory({ + db: trx + }), + storeProviderRecord: storeProviderRecordFactory({ + db, + encrypt: encryptor.encrypt + }), + storeUserSsoSession: storeUserSsoSessionFactory({ db: trx }), + createUserEmail: createUserEmailFactory({ db: trx }) + })({ + provider, + userId, + workspaceId: workspace.id + // ssoProviderUserInfo + }) + await trx.commit() + redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') + } catch (err) { + logger.warn( + { error: err }, + 'Failed to verify OIDC sso provider for workspace {workspaceSlug}' + ) + redirectUrl = buildErrorUrl({ err, url: redirectUrl }) + } finally { + req.session.destroy(noop) + // redirectUrl. + req.res?.redirect(redirectUrl.toString()) + } + } else { + // this must be using the generic OIDC login flow somehow } } ) diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index 3f86e1ec96..4600206ee4 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -3,12 +3,15 @@ import { OIDCProviderAttributes, OIDCProvider, StoreOIDCProviderValidationRequest, - GetOIDCProviderData, - GetOIDCUserData, - OIDCCallbackParams + StoreProviderRecord, + StoreUserSsoSession, + OIDCProviderRecord, + AssociateSsoProviderWithWorkspace, + GetWorkspaceSsoProvider } from '@/modules/workspaces/domain/sso' import { BaseError } from '@/modules/shared/errors/base' -import { generators } from 'openid-client' +import cryptoRandomString from 'crypto-random-string' +import { CreateUserEmail } from '@/modules/core/domain/userEmails/operations' export class MissingOIDCProviderGrantType extends BaseError { static defaultMessage = 'OIDC issuer does not support authorization_code grant type' @@ -16,6 +19,7 @@ export class MissingOIDCProviderGrantType extends BaseError { static statusCode = 400 } +// this probably should go a lean validation endpoint too const validateOIDCProviderAttributes = ({ // client, issuer @@ -26,11 +30,13 @@ const validateOIDCProviderAttributes = ({ validate issuer: authorization_signing_alg_values_supported claims_supported: ['email', 'name', 'given_name', 'family_name'] +scopes_supported: ['openid', 'profile', 'email'] grant_types_supported: ['authorization_code'] response_types_supported: //TODO figure out which validate client: grant_types: ['authorization_code'], + */ } @@ -50,29 +56,62 @@ export const startOIDCSsoProviderValidationFactory = // validate issuer and client data validateOIDCProviderAttributes(providerAttributes) // store provider validation with an id token - const codeVerifier = generateCodeVerifier() + const codeVerifier = generateCodeVerifier() await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) return codeVerifier } -export const finishOIDCSsoProviderValidationFactory = +export const saveSsoProviderRegistrationFactory = ({ - getOIDCProviderData, - getOIDCUserData - }: { - getOIDCProviderData: GetOIDCProviderData - getOIDCUserData: GetOIDCUserData + getWorkspaceSsoProvider, + storeProviderRecord, + associateSsoProviderWithWorkspace, + storeUserSsoSession + }: // createUserEmail + { + getWorkspaceSsoProvider: GetWorkspaceSsoProvider + storeProviderRecord: StoreProviderRecord + associateSsoProviderWithWorkspace: AssociateSsoProviderWithWorkspace + storeUserSsoSession: StoreUserSsoSession + createUserEmail: CreateUserEmail }) => async ({ - issuer, - validationToken, - callbackParams - }: { - issuer: string - validationToken: string - callbackParams: OIDCCallbackParams - }): Promise => { - //get stored provider validation request - // throw error if not found - return true + provider, + workspaceId, + userId + }: // ssoProviderUserInfo + { + provider: OIDCProvider + userId: string + workspaceId: string + // ssoProviderUserInfo: { email: string } + }) => { + // create OIDC provider record with ID + const providerId = cryptoRandomString({ length: 10 }) + const providerRecord: OIDCProviderRecord = { + provider, + providerType: 'oidc', + createdAt: new Date(), + updatedAt: new Date(), + id: providerId + } + const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) + // replace with a proper error + if (maybeExistingSsoProvider) + throw new Error('Workspace already has an SSO provider') + await storeProviderRecord({ providerRecord }) + // associate provider with workspace + await associateSsoProviderWithWorkspace({ workspaceId, providerId }) + // create and associate userSso session (how long is the default validity?) + // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work + const lifespan = 6.048e8 // 1 week + await storeUserSsoSession({ + userSsoSession: { createdAt: new Date(), userId, providerId, lifespan } + }) + // 1. get userId's emails + + // 2. if the ssoUserInfoEmail is not in the user's emails, add it as verified + // 3. if its in the emails, but not verify, verify it + // 4. if its verified, do nothing + // await createUserEmail() } diff --git a/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts b/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts new file mode 100644 index 0000000000..062cf99992 --- /dev/null +++ b/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts @@ -0,0 +1,49 @@ +import { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('sso_providers', (table) => { + table.text('id').primary() + table.text('providerType').notNullable() + table.text('encryptedProviderData').notNullable() + table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable() + table.timestamp('updatedAt', { precision: 3, useTz: true }).notNullable() + }) + await knex.schema.createTable('user_sso_sessions', (table) => { + table + .string('userId') + .references('id') + .inTable('users') + .notNullable() + .onDelete('cascade') + table + .string('providerId') + .references('id') + .inTable('sso_providers') + .notNullable() + .onDelete('cascade') + table.primary(['userId', 'providerId']) + table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable() + table.bigint('lifespan').notNullable() + }) + await knex.schema.createTable('workspace_sso_providers', (table) => { + table + .string('workspaceId') + .references('id') + .inTable('workspaces') + .notNullable() + .onDelete('cascade') + table + .string('providerId') + .references('id') + .inTable('sso_providers') + .notNullable() + .onDelete('cascade') + table.primary(['workspaceId']) + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable('user_sso_sessions') + await knex.schema.dropTable('workspace_sso_providers') + await knex.schema.dropTable('sso_providers') +} From 6149b9ec053c4a5e2d66eaa94b40b5a8e701cf95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Tue, 1 Oct 2024 16:26:26 +0100 Subject: [PATCH 04/47] feat(workspaces): validate and add sso provider to the workspace with user sso sessions --- .../server/modules/workspaces/rest/sso.ts | 32 ++++++++++++++++--- packages/server/validateOidc.js | 31 ------------------ 2 files changed, 27 insertions(+), 36 deletions(-) delete mode 100644 packages/server/validateOidc.js diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 6eace59a1c..bce244e335 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -35,7 +35,7 @@ import { createRedisClient } from '@/modules/shared/redis/redis' import ConnectRedis from 'connect-redis' import ExpressSession from 'express-session' import { noop } from 'lodash' -import { oidcProvider } from '@/modules/workspaces/domain/sso' +import { OIDCProvider, oidcProvider } from '@/modules/workspaces/domain/sso' import { getWorkspaceBySlugFactory } from '@/modules/workspaces/repositories/workspaces' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { authorizeResolver } from '@/modules/shared' @@ -69,7 +69,15 @@ const buildFinalizeUrl = (workspaceSlug: string): URL => const ssoVerificationStatusKey = 'ssoVerificationStatus' -const buildErrorUrl = ({ err, url }: { err: unknown; url: URL }): URL => { +const buildErrorUrl = ({ + err, + url, + searchParams +}: { + err: unknown + url: URL + searchParams?: Record +}): URL => { const settingsSearch = url.searchParams.get('settings') url.searchParams.forEach((key) => { url.searchParams.delete(key) @@ -78,6 +86,11 @@ const buildErrorUrl = ({ err, url }: { err: unknown; url: URL }): URL => { url.searchParams.set(ssoVerificationStatusKey, 'failed') const errorMessage = err instanceof Error ? err.message : `Unknown error ${err}` url.searchParams.set('ssoVerificationError', errorMessage) + if (searchParams) { + for (const [name, value] of Object.values(searchParams)) { + url.searchParams.set(name, value) + } + } return url } @@ -118,7 +131,11 @@ router.get( res?.redirect(authorizationUrl.toString()) } catch (err) { session.destroy(noop) - const url = buildErrorUrl({ err, url: buildFinalizeUrl(params.workspaceSlug) }) + const url = buildErrorUrl({ + err, + url: buildFinalizeUrl(params.workspaceSlug), + searchParams: query + }) res?.redirect(url.toString()) } } @@ -138,6 +155,7 @@ router.get( // req.context.userId can be authorized for the workspaceSlug if needed const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) + let provider: OIDCProvider | null = null if (req.query.validate === 'true') { const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug: req.params.workspaceSlug @@ -165,7 +183,7 @@ router.get( const codeVerifier = await decryptor.decrypt(encryptedValidationToken) - const provider = await getOIDCProviderFactory({ + provider = await getOIDCProviderFactory({ redis: getGenericRedis(), decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt })({ @@ -218,7 +236,11 @@ router.get( { error: err }, 'Failed to verify OIDC sso provider for workspace {workspaceSlug}' ) - redirectUrl = buildErrorUrl({ err, url: redirectUrl }) + redirectUrl = buildErrorUrl({ + err, + url: redirectUrl, + searchParams: provider || undefined + }) } finally { req.session.destroy(noop) // redirectUrl. diff --git a/packages/server/validateOidc.js b/packages/server/validateOidc.js deleted file mode 100644 index f20e112dd6..0000000000 --- a/packages/server/validateOidc.js +++ /dev/null @@ -1,31 +0,0 @@ -const { Issuer } = require('openid-client') - -;(async () => { - const issuer = await Issuer.discover( - 'http://127.0.0.1:8090/realms/speckle/.well-known/openid-configuration' - ) - /* - to validate from issuer: - authorization_signing_alg_values_supported - claims_supported: ['email', 'name', 'given_name', 'family_name'] - grant_types_supported: ['authorization_code'] - response_types_supported: //TODO figure out which - - */ - console.log(issuer) - const client = new issuer.Client({ - client_id: 'speckle', - client_secret: 'OZ6zj7H1G7jQw6qUDif1aoQVxTOGPkJK1', - redirect_uris: ['http://localghost:3000/cb'], - response_types: ['code'] - // id_token_signed_response_alg (default "RS256") - // token_endpoint_auth_method (default "client_secret_basic") - }) - client.authorizationUrl({request_uri: }) - console.log(await client) - - /* - validate from client: - grant_types: ['authorization_code'], - */ -})() From 57295b9615fb0458a8fd67670b5f9472944b1ba6 Mon Sep 17 00:00:00 2001 From: Mike Tasset Date: Tue, 1 Oct 2024 17:14:06 +0100 Subject: [PATCH 05/47] WIP --- .../components/header/NavUserMenu.vue | 44 ++++++- .../settings/workspaces/Security.vue | 6 + .../settings/workspaces/security/Sso.vue | 112 ++++++++++++++++++ .../lib/common/generated/gql/gql.ts | 9 +- .../lib/common/generated/gql/graphql.ts | 15 ++- .../lib/settings/graphql/queries.ts | 8 ++ 6 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 packages/frontend-2/components/settings/workspaces/security/Sso.vue diff --git a/packages/frontend-2/components/header/NavUserMenu.vue b/packages/frontend-2/components/header/NavUserMenu.vue index 4e8dec9b6c..f8f5240922 100644 --- a/packages/frontend-2/components/header/NavUserMenu.vue +++ b/packages/frontend-2/components/header/NavUserMenu.vue @@ -129,6 +129,7 @@ @@ -150,6 +151,15 @@ import { SettingMenuKeys, type AvailableSettingsMenuKeys } from '~/lib/settings/helpers/types' +// import { useQuery } from '@vue/apollo-composable' +// import { graphql } from '~~/lib/common/generated/gql' + +// graphql(` +// fragment NavUserMenu_Workspace on Workspace { +// id +// role +// } +// `) defineProps<{ loginUrl?: RouteLocationRaw @@ -162,12 +172,23 @@ const { isDarkTheme, toggleTheme } = useTheme() const router = useRouter() const { triggerNotification } = useGlobalToast() const { serverInfo } = useServerInfo() +const breakpoints = useBreakpoints(TailwindBreakpoints) +// const isWorkspacesEnabled = useIsWorkspacesEnabled() +// const { result: workspaceResult } = useQuery( +// settingsSidebarQuery, +// () => ({ +// workspaceId: route.query?.workspace +// }), +// () => ({ +// enabled: isWorkspacesEnabled.value +// }) +// ) const showInviteDialog = ref(false) const showSettingsDialog = ref(false) const settingsDialogTarget = ref(null) +const workspaceSettingsDialogTarget = ref(null) const menuButtonId = useId() -const breakpoints = useBreakpoints(TailwindBreakpoints) const isMobile = breakpoints.smaller('md') const version = computed(() => serverInfo.value?.version) @@ -187,11 +208,16 @@ const toggleSettingsDialog = (target: AvailableSettingsMenuKeys) => { const deleteSettingsQuery = (): void => { const currentQueryParams = { ...route.query } delete currentQueryParams.settings + delete currentQueryParams.workspace + delete currentQueryParams.error + router.push({ query: currentQueryParams }) } onMounted(() => { const settingsQuery = route.query?.settings + const workspaceQuery = route.query?.workspace + const errorQuery = route.query?.error if (settingsQuery && isString(settingsQuery)) { if (settingsQuery.includes('server') && !isAdmin.value) { @@ -203,6 +229,22 @@ onMounted(() => { return } + if (workspaceQuery && isString(workspaceQuery)) { + workspaceSettingsDialogTarget.value = workspaceQuery + + if (errorQuery && isString(errorQuery)) { + triggerNotification({ + type: ToastNotificationType.Danger, + title: errorQuery + }) + } else { + triggerNotification({ + type: ToastNotificationType.Success, + title: 'Lekker bezig ah niffo' + }) + } + } + showSettingsDialog.value = true settingsDialogTarget.value = settingsQuery deleteSettingsQuery() diff --git a/packages/frontend-2/components/settings/workspaces/Security.vue b/packages/frontend-2/components/settings/workspaces/Security.vue index 2660e30a4c..4883c057bd 100644 --- a/packages/frontend-2/components/settings/workspaces/Security.vue +++ b/packages/frontend-2/components/settings/workspaces/Security.vue @@ -5,6 +5,11 @@ title="Security" text="Manage verified workspace domains and associated features." /> + +
    @@ -137,6 +142,7 @@ graphql(` } domainBasedMembershipProtectionEnabled discoverabilityEnabled + ...SettingsWorkspacesSecuritySso_Workspace } fragment SettingsWorkspacesSecurity_User on User { diff --git a/packages/frontend-2/components/settings/workspaces/security/Sso.vue b/packages/frontend-2/components/settings/workspaces/security/Sso.vue new file mode 100644 index 0000000000..ad21fa675f --- /dev/null +++ b/packages/frontend-2/components/settings/workspaces/security/Sso.vue @@ -0,0 +1,112 @@ + + + diff --git a/packages/frontend-2/lib/common/generated/gql/gql.ts b/packages/frontend-2/lib/common/generated/gql/gql.ts index 962cc0204d..f5e7646b5b 100644 --- a/packages/frontend-2/lib/common/generated/gql/gql.ts +++ b/packages/frontend-2/lib/common/generated/gql/gql.ts @@ -116,7 +116,7 @@ const documents = { "\n fragment SettingsWorkspacesGeneralEditAvatar_Workspace on Workspace {\n id\n logo\n name\n defaultLogoIndex\n }\n": types.SettingsWorkspacesGeneralEditAvatar_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesMembers_Workspace on Workspace {\n id\n role\n }\n": types.SettingsWorkspacesMembers_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsWorkspacesProjects_ProjectCollectionFragmentDoc, - "\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc, + "\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n ...SettingsWorkspacesSecuritySso_Workspace\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator on WorkspaceCollaborator {\n id\n role\n user {\n id\n avatar\n name\n company\n verified\n }\n projectRoles {\n role\n project {\n id\n name\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceCollaboratorFragmentDoc, "\n fragment SettingsWorkspacesMembersGuestsTable_Workspace on Workspace {\n id\n ...SettingsWorkspacesMembersTableHeader_Workspace\n team {\n items {\n id\n ...SettingsWorkspacesMembersGuestsTable_WorkspaceCollaborator\n }\n }\n }\n": types.SettingsWorkspacesMembersGuestsTable_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {\n id\n inviteId\n role\n title\n updatedAt\n user {\n id\n ...LimitedUserAvatar\n }\n invitedBy {\n id\n ...LimitedUserAvatar\n }\n }\n": types.SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaboratorFragmentDoc, @@ -126,6 +126,7 @@ const documents = { "\n fragment SettingsWorkspacesMembersTableHeader_Workspace on Workspace {\n id\n role\n ...WorkspaceInviteDialog_Workspace\n }\n": types.SettingsWorkspacesMembersTableHeader_WorkspaceFragmentDoc, "\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain on WorkspaceDomain {\n id\n domain\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragmentDoc, "\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc, + "\n fragment SettingsWorkspacesSecuritySso_Workspace on Workspace {\n id\n slug\n }\n": types.SettingsWorkspacesSecuritySso_WorkspaceFragmentDoc, "\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n name\n }\n }\n": types.ModelPageProjectFragmentDoc, "\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc, "\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc, @@ -761,7 +762,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesProjects_Project /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n"]; +export function graphql(source: "\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n ...SettingsWorkspacesSecuritySso_Workspace\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n id\n domains {\n id\n domain\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n domainBasedMembershipProtectionEnabled\n discoverabilityEnabled\n ...SettingsWorkspacesSecuritySso_Workspace\n }\n\n fragment SettingsWorkspacesSecurity_User on User {\n id\n emails {\n id\n email\n verified\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -798,6 +799,10 @@ export function graphql(source: "\n fragment SettingsWorkspacesSecurityDomainRe * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n fragment SettingsWorkspacesSecuritySso_Workspace on Workspace {\n id\n slug\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecuritySso_Workspace on Workspace {\n id\n slug\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 334dfb8e2d..e1c8b40b5e 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -4442,7 +4442,7 @@ export type SettingsWorkspacesMembers_WorkspaceFragment = { __typename?: 'Worksp export type SettingsWorkspacesProjects_ProjectCollectionFragment = { __typename?: 'ProjectCollection', totalCount: number, items: Array<{ __typename?: 'Project', id: string, name: string, visibility: ProjectVisibility, createdAt: string, updatedAt: string, models: { __typename?: 'ModelCollection', totalCount: number }, versions: { __typename?: 'VersionCollection', totalCount: number }, team: Array<{ __typename?: 'ProjectCollaborator', id: string, user: { __typename?: 'LimitedUser', name: string, id: string, avatar?: string | null } }> }> }; -export type SettingsWorkspacesSecurity_WorkspaceFragment = { __typename?: 'Workspace', id: string, domainBasedMembershipProtectionEnabled: boolean, discoverabilityEnabled: boolean, domains?: Array<{ __typename?: 'WorkspaceDomain', id: string, domain: string }> | null }; +export type SettingsWorkspacesSecurity_WorkspaceFragment = { __typename?: 'Workspace', id: string, domainBasedMembershipProtectionEnabled: boolean, discoverabilityEnabled: boolean, slug: string, domains?: Array<{ __typename?: 'WorkspaceDomain', id: string, domain: string }> | null }; export type SettingsWorkspacesSecurity_UserFragment = { __typename?: 'User', id: string, emails: Array<{ __typename?: 'UserEmail', id: string, email: string, verified: boolean }> }; @@ -4464,6 +4464,8 @@ export type SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragment export type SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragment = { __typename?: 'Workspace', id: string, domains?: Array<{ __typename?: 'WorkspaceDomain', id: string, domain: string }> | null }; +export type SettingsWorkspacesSecuritySso_WorkspaceFragment = { __typename?: 'Workspace', id: string, slug: string }; + export type ModelPageProjectFragment = { __typename?: 'Project', id: string, createdAt: string, name: string, visibility: ProjectVisibility, workspace?: { __typename?: 'Workspace', id: string, name: string } | null }; export type ThreadCommentAttachmentFragment = { __typename?: 'Comment', text: { __typename?: 'SmartTextEditorValue', attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, fileType: string, fileSize?: number | null }> | null } }; @@ -5373,7 +5375,7 @@ export type AddWorkspaceDomainMutationVariables = Exact<{ }>; -export type AddWorkspaceDomainMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', addDomain: { __typename?: 'Workspace', id: string, domainBasedMembershipProtectionEnabled: boolean, discoverabilityEnabled: boolean, domains?: Array<{ __typename?: 'WorkspaceDomain', id: string, domain: string }> | null } } }; +export type AddWorkspaceDomainMutation = { __typename?: 'Mutation', workspaceMutations: { __typename?: 'WorkspaceMutations', addDomain: { __typename?: 'Workspace', id: string, domainBasedMembershipProtectionEnabled: boolean, discoverabilityEnabled: boolean, slug: string, domains?: Array<{ __typename?: 'WorkspaceDomain', id: string, domain: string }> | null } } }; export type DeleteWorkspaceDomainMutationVariables = Exact<{ input: WorkspaceDomainDeleteInput; @@ -5452,7 +5454,7 @@ export type SettingsWorkspaceSecurityQueryVariables = Exact<{ }>; -export type SettingsWorkspaceSecurityQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, domainBasedMembershipProtectionEnabled: boolean, discoverabilityEnabled: boolean, domains?: Array<{ __typename?: 'WorkspaceDomain', id: string, domain: string }> | null }, activeUser?: { __typename?: 'User', id: string, emails: Array<{ __typename?: 'UserEmail', id: string, email: string, verified: boolean }> } | null }; +export type SettingsWorkspaceSecurityQuery = { __typename?: 'Query', workspace: { __typename?: 'Workspace', id: string, domainBasedMembershipProtectionEnabled: boolean, discoverabilityEnabled: boolean, slug: string, domains?: Array<{ __typename?: 'WorkspaceDomain', id: string, domain: string }> | null }, activeUser?: { __typename?: 'User', id: string, emails: Array<{ __typename?: 'UserEmail', id: string, email: string, verified: boolean }> } | null }; export type AppAuthorAvatarFragment = { __typename?: 'AppAuthor', id: string, name: string, avatar?: string | null }; @@ -5835,7 +5837,8 @@ export const SettingsWorkspacesGeneral_WorkspaceFragmentDoc = {"kind":"Document" export const SettingsWorkspacesMembers_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembers_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesProjects_ProjectCollectionFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesProjects_ProjectCollection"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCollection"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedProjects_Project"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedProjects_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomainFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomain"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}}]} as unknown as DocumentNode; -export const SettingsWorkspacesSecurity_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"}}]}},{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomain"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}}]} as unknown as DocumentNode; +export const SettingsWorkspacesSecuritySso_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecuritySso_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]} as unknown as DocumentNode; +export const SettingsWorkspacesSecurity_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"}}]}},{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecuritySso_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomain"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecuritySso_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesSecurity_UserFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_User"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"emails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}}]}}]} as unknown as DocumentNode; export const WorkspaceInviteDialog_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesMembersTableHeader_WorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -6010,7 +6013,7 @@ export const SettingsUpdateWorkspaceSecurityDocument = {"kind":"Document","defin export const SettingsDeleteWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SettingsDeleteWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"delete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}]}]}}]}}]} as unknown as DocumentNode; export const SettingsResendWorkspaceInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SettingsResendWorkspaceInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceInviteResendInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resend"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode; export const SettingsCancelWorkspaceInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SettingsCancelWorkspaceInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const AddWorkspaceDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddWorkspaceDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddDomainToWorkspaceInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomain"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"}}]}},{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}}]}}]} as unknown as DocumentNode; +export const AddWorkspaceDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddWorkspaceDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddDomainToWorkspaceInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomain"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecuritySso_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"}}]}},{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecuritySso_Workspace"}}]}}]} as unknown as DocumentNode; export const DeleteWorkspaceDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteWorkspaceDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomainDeleteInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_Workspace"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomain"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"}}]}}]}}]} as unknown as DocumentNode; export const SettingsLeaveWorkspaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SettingsLeaveWorkspace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"leaveId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"leave"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"leaveId"}}}]}]}}]}}]} as unknown as DocumentNode; export const SettingsSidebarDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsSidebar"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsDialog_User"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceAvatar_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"defaultLogoIndex"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceAvatar_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsDialog_User"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"workspaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsDialog_Workspace"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -6021,7 +6024,7 @@ export const SettingsWorkspacesMembersSearchDocument = {"kind":"Document","defin export const SettingsWorkspacesInvitesSearchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspacesInvitesSearch"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaboratorsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_Workspace"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceInviteDialog_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersTableHeader_Workspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"invitesFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesMembersInvitesTable_PendingWorkspaceCollaborator"}}]}}]}}]} as unknown as DocumentNode; export const SettingsUserEmailsQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsUserEmailsQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsUserEmails_User"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsUserEmailCards_UserEmail"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserEmail"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"primary"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsUserEmails_User"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"emails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsUserEmailCards_UserEmail"}}]}}]}}]} as unknown as DocumentNode; export const SettingsWorkspacesProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspacesProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceProjectsFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"cursor"},"value":{"kind":"Variable","name":{"kind":"Name","value":"cursor"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesProjects_ProjectCollection"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsSharedProjects_Project"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"team"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesProjects_ProjectCollection"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCollection"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsSharedProjects_Project"}}]}}]}}]} as unknown as DocumentNode; -export const SettingsWorkspaceSecurityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspaceSecurity"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"}}]}},{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_User"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomain"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"}}]}},{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_User"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"emails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}}]}}]} as unknown as DocumentNode; +export const SettingsWorkspaceSecurityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SettingsWorkspaceSecurity"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"}}]}},{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_User"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceDomain"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecuritySso_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_Workspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain"}}]}},{"kind":"Field","name":{"kind":"Name","value":"domainBasedMembershipProtectionEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"discoverabilityEnabled"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"SettingsWorkspacesSecuritySso_Workspace"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SettingsWorkspacesSecurity_User"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"emails"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"verified"}}]}}]}}]} as unknown as DocumentNode; export const UpdateUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UserUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"user"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateNotificationPreferencesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateNotificationPreferences"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSONObject"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userNotificationPreferencesUpdate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"preferences"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const DeleteAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UserDeleteInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userConfirmation"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; diff --git a/packages/frontend-2/lib/settings/graphql/queries.ts b/packages/frontend-2/lib/settings/graphql/queries.ts index 9113e070e8..abeee09fe9 100644 --- a/packages/frontend-2/lib/settings/graphql/queries.ts +++ b/packages/frontend-2/lib/settings/graphql/queries.ts @@ -102,3 +102,11 @@ export const settingsWorkspacesSecurityQuery = graphql(` } } `) + +// export const navUserMenuWorkspaceQuery = graphql(` +// query SettingsWorkspace($workspaceId: String!) { +// workspace(id: $workspaceId) { +// ...NavUserMenu_Workspace +// } +// } +// `) From 36944b55ad8740d4d3cb6672c67b0010e55c2486 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Tue, 1 Oct 2024 19:27:01 +0100 Subject: [PATCH 06/47] fix(sso): restructure to handle all branches at end of flow --- .../server/modules/workspaces/rest/sso.ts | 296 +++++++++++++----- 1 file changed, 218 insertions(+), 78 deletions(-) diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index bce244e335..99d2d11053 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -41,6 +41,7 @@ import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { authorizeResolver } from '@/modules/shared' import { Roles } from '@speckle/shared' import { createUserEmailFactory } from '@/modules/core/repositories/userEmails' +import { withTransaction } from '@/modules/shared/helpers/dbHelper' const router = Router() @@ -58,14 +59,34 @@ const sessionMiddleware = ExpressSession({ } }) -const buildAuthRedirectUrl = (workspaceSlug: string): URL => - new URL( - `/api/v1/workspaces/${workspaceSlug}/sso/oidc/callback?validate=true`, - getServerOrigin() - ) +/** + * Generate redirect url used for final step of OIDC flow + */ +const buildAuthRedirectUrl = ( + workspaceSlug: string, + isValidationFlow: boolean +): URL => { + const urlFragments = [`/api/v1/workspaces/${workspaceSlug}/sso/oidc/callback`] -const buildFinalizeUrl = (workspaceSlug: string): URL => - new URL(`workspaces/${workspaceSlug}/?settings=server/general`, getFrontendOrigin()) + if (isValidationFlow) { + urlFragments.push('?validate=true') + } + + return new URL(urlFragments.join(''), getServerOrigin()) +} + +/** + * Generate default final redirect url if request is successful + */ +const buildFinalizeUrl = (workspaceSlug: string, isValidationFlow: boolean): URL => { + const urlFragments = [`workspaces/${workspaceSlug}/`] + + if (isValidationFlow) { + urlFragments.push('?settings=server/general') + } + + return new URL(urlFragments.join(''), getFrontendOrigin()) +} const ssoVerificationStatusKey = 'ssoVerificationStatus' @@ -118,7 +139,7 @@ router.get( })({ provider }) - const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug) + const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, true) const authorizationUrl = await getProviderAuthorizationUrl({ provider, redirectUrl, @@ -133,7 +154,7 @@ router.get( session.destroy(noop) const url = buildErrorUrl({ err, - url: buildFinalizeUrl(params.workspaceSlug), + url: buildFinalizeUrl(params.workspaceSlug, true), searchParams: query }) res?.redirect(url.toString()) @@ -151,65 +172,70 @@ router.get( query: z.object({ validate: z.string() }) }), async (req) => { - // this is the verify flow, login will be different - // req.context.userId can be authorized for the workspaceSlug if needed const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) + const workspaceSlug = req.params.workspaceSlug + const isValidationFlow = req.query.validate === 'true' + let provider: OIDCProvider | null = null - if (req.query.validate === 'true') { - const workspace = await getWorkspaceBySlugFactory({ db })({ - workspaceSlug: req.params.workspaceSlug + let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug, isValidationFlow) + + try { + // Initialize OIDC client based on provider for current request flow + const encryptionKeyPair = await getEncryptionKeyPair() + const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) + const decryptor = await buildDecryptor(encryptionKeyPair) + const encryptedCodeVerifier = req.session.codeVerifier + + if (!encryptedCodeVerifier) + throw new Error('cannot find verification token, restart the flow') + + const codeVerifier = await decryptor.decrypt(encryptedCodeVerifier) + + provider = await getOIDCProviderFactory({ + redis: getGenericRedis(), + decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt + })({ + validationToken: codeVerifier }) - if (!workspace) throw new WorkspaceNotFoundError() - await authorizeResolver( - req.context.userId, - workspace.id, - Roles.Workspace.Admin, - req.context.resourceAccessRules - ) - // once we're authorized for the ws, we must have a userId - const userId = req.context.userId! - // point to the finalize page if there is one - let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug) - try { - const encryptionKeyPair = await getEncryptionKeyPair() - const decryptor = await buildDecryptor(encryptionKeyPair) + if (!provider) throw new Error('validation request not found, please retry') - // =================== - const encryptedValidationToken = req.session.codeVerifier - if (!encryptedValidationToken) - throw new Error('cannot find verification token, restart the flow') + const { client } = await initializeIssuerAndClient({ provider }) + const callbackParams = client.callbackParams(req) + const tokenSet = await client.callback( + buildAuthRedirectUrl(workspaceSlug, isValidationFlow).toString(), + callbackParams, + /* eslint-disable-next-line camelcase */ + { code_verifier: codeVerifier } + ) - const codeVerifier = await decryptor.decrypt(encryptedValidationToken) + // Get user profile from SSO provider + const ssoProviderUserInfo = await client.userinfo(tokenSet) + if (!ssoProviderUserInfo.email) + throw new Error('This should never happen, we are asking for an email claim') - provider = await getOIDCProviderFactory({ - redis: getGenericRedis(), - decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt - })({ - validationToken: codeVerifier + if (isValidationFlow) { + // OIDC configuration verification flow: the user is attempting to configure SSO for their workspace + const workspace = await getWorkspaceBySlugFactory({ db })({ + workspaceSlug: req.params.workspaceSlug }) - if (!provider) throw new Error('validation request not found, please retry') - - const { client } = await initializeIssuerAndClient({ provider }) - const callbackParams = client.callbackParams(req) - const tokenSet = await client.callback( - buildAuthRedirectUrl(req.params.workspaceSlug).toString(), - callbackParams, - // eslint-disable-next-line camelcase - { code_verifier: codeVerifier } - ) + if (!workspace) throw new WorkspaceNotFoundError() - // now that we have the user's email, we should compare it to the active user's email. - // Ask if they want to add the email to the oidc as a secondary email, if it doesn't match any of the user's emails - const ssoProviderUserInfo = await client.userinfo(tokenSet) - if (!ssoProviderUserInfo.email) - throw new Error('This should never happen, we are asking for an email claim') + // TODO: Assert billing status - const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - const trx = await db.transaction() + // Only workspace admins may configure SSO + await authorizeResolver( + req.context.userId, + workspace.id, + Roles.Workspace.Admin, + req.context.resourceAccessRules + ) + const userId = req.context.userId! - await saveSsoProviderRegistrationFactory({ + // Write SSO configuration + const trx = await db.transaction() + const saveSsoProviderRegistration = saveSsoProviderRegistrationFactory({ getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ db: trx, decrypt: decryptor.decrypt @@ -223,32 +249,146 @@ router.get( }), storeUserSsoSession: storeUserSsoSessionFactory({ db: trx }), createUserEmail: createUserEmailFactory({ db: trx }) - })({ - provider, - userId, - workspaceId: workspace.id - // ssoProviderUserInfo }) - await trx.commit() - redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') - } catch (err) { - logger.warn( - { error: err }, - 'Failed to verify OIDC sso provider for workspace {workspaceSlug}' + await withTransaction( + saveSsoProviderRegistration({ + provider, + userId, + workspaceId: workspace.id + // ssoProviderUserInfo + }), + trx ) - redirectUrl = buildErrorUrl({ - err, - url: redirectUrl, - searchParams: provider || undefined - }) - } finally { - req.session.destroy(noop) - // redirectUrl. - req.res?.redirect(redirectUrl.toString()) + + // Build final redirect url + redirectUrl = buildFinalizeUrl(req.params.workspaceSlug, isValidationFlow) + redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') + } else { + // OIDC auth flow: SSO is already configured and we are attempting to log in or sign up + // Get user by email in `ssoProviderUserInfo` + // if (userExists) { + // // Update timeout for relevant sso session + // // Complete sign in + // // Redirect to workspace + // } else { + // // Create user + // // Add email as primary and verified email + // // Complete sign in + // // Redirect to workspace + // } } - } else { - // this must be using the generic OIDC login flow somehow + } catch (err) { + logger.warn( + { error: err }, + `Failed to verify OIDC sso provider for workspace ${workspaceSlug}` + ) + redirectUrl = buildErrorUrl({ + err, + url: redirectUrl, + searchParams: provider || undefined + }) + } finally { + req.session.destroy(noop) + req.res?.redirect(redirectUrl.toString()) } + + // if (req.query.validate === 'true') { + // OIDC configuration verification flow: the user is attempting to configure SSO for their workspace + // const workspace = await getWorkspaceBySlugFactory({ db })({ + // workspaceSlug: req.params.workspaceSlug + // }) + + // if (!workspace) throw new WorkspaceNotFoundError() + + // await authorizeResolver( + // req.context.userId, + // workspace.id, + // Roles.Workspace.Admin, + // req.context.resourceAccessRules + // ) + // once we're authorized for the ws, we must have a userId + // const userId = req.context.userId! + + // point to the finalize page if there is one + // let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug) + + // try { + // const encryptionKeyPair = await getEncryptionKeyPair() + // const decryptor = await buildDecryptor(encryptionKeyPair) + // const encryptedValidationToken = req.session.codeVerifier + + // if (!encryptedValidationToken) + // throw new Error('cannot find verification token, restart the flow') + + // const codeVerifier = await decryptor.decrypt(encryptedValidationToken) + + // provider = await getOIDCProviderFactory({ + // redis: getGenericRedis(), + // decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt + // })({ + // validationToken: codeVerifier + // }) + + // if (!provider) throw new Error('validation request not found, please retry') + + // const { client } = await initializeIssuerAndClient({ provider }) + // const callbackParams = client.callbackParams(req) + // const tokenSet = await client.callback( + // buildAuthRedirectUrl(req.params.workspaceSlug).toString(), + // callbackParams, + // // eslint-disable-next-line camelcase + // { code_verifier: codeVerifier } + // ) + + // now that we have the user's email, we should compare it to the active user's email. + // Ask if they want to add the email to the oidc as a secondary email, if it doesn't match any of the user's emails + // const ssoProviderUserInfo = await client.userinfo(tokenSet) + // if (!ssoProviderUserInfo.email) + // throw new Error('This should never happen, we are asking for an email claim') + + // const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) + + // const trx = await db.transaction() + // const saveSsoProviderRegistration = saveSsoProviderRegistrationFactory({ + // getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ + // db: trx, + // decrypt: decryptor.decrypt + // }), + // associateSsoProviderWithWorkspace: associateSsoProviderWithWorkspaceFactory({ + // db: trx + // }), + // storeProviderRecord: storeProviderRecordFactory({ + // db, + // encrypt: encryptor.encrypt + // }), + // storeUserSsoSession: storeUserSsoSessionFactory({ db: trx }), + // createUserEmail: createUserEmailFactory({ db: trx }) + // }) + + // await withTransaction(saveSsoProviderRegistration({ + // provider, + // userId, + // workspaceId: workspace.id + // // ssoProviderUserInfo + // }), trx) + + // redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') + // } catch (err) { + // logger.warn( + // { error: err }, + // 'Failed to verify OIDC sso provider for workspace {workspaceSlug}' + // ) + // redirectUrl = buildErrorUrl({ + // err, + // url: redirectUrl, + // searchParams: provider || undefined + // }) + // } finally { + // req.session.destroy(noop) + // // redirectUrl. + // req.res?.redirect(redirectUrl.toString()) + // } + // } } ) From a6e6307a4b968583845ac123f7a6eb134ad842bd Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Tue, 1 Oct 2024 19:49:28 +0100 Subject: [PATCH 07/47] fix(sso): add and validate emails used for sso --- .../server/modules/workspaces/rest/sso.ts | 118 +++--------------- .../server/modules/workspaces/services/sso.ts | 57 +++++++-- 2 files changed, 58 insertions(+), 117 deletions(-) diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 99d2d11053..ab7dfff87c 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -40,7 +40,11 @@ import { getWorkspaceBySlugFactory } from '@/modules/workspaces/repositories/wor import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { authorizeResolver } from '@/modules/shared' import { Roles } from '@speckle/shared' -import { createUserEmailFactory } from '@/modules/core/repositories/userEmails' +import { + createUserEmailFactory, + findEmailsByUserIdFactory, + updateUserEmailFactory +} from '@/modules/core/repositories/userEmails' import { withTransaction } from '@/modules/shared/helpers/dbHelper' const router = Router() @@ -211,8 +215,8 @@ router.get( ) // Get user profile from SSO provider - const ssoProviderUserInfo = await client.userinfo(tokenSet) - if (!ssoProviderUserInfo.email) + const ssoProviderUserInfo = await client.userinfo<{ email: string }>(tokenSet) + if (!ssoProviderUserInfo || !ssoProviderUserInfo.email) throw new Error('This should never happen, we are asking for an email claim') if (isValidationFlow) { @@ -248,14 +252,16 @@ router.get( encrypt: encryptor.encrypt }), storeUserSsoSession: storeUserSsoSessionFactory({ db: trx }), - createUserEmail: createUserEmailFactory({ db: trx }) + createUserEmail: createUserEmailFactory({ db: trx }), + updateUserEmail: updateUserEmailFactory({ db: trx }), + findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }) }) await withTransaction( saveSsoProviderRegistration({ provider, userId, - workspaceId: workspace.id - // ssoProviderUserInfo + workspaceId: workspace.id, + ssoProviderUserInfo }), trx ) @@ -265,7 +271,9 @@ router.get( redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') } else { // OIDC auth flow: SSO is already configured and we are attempting to log in or sign up + // // Get user by email in `ssoProviderUserInfo` + // // if (userExists) { // // Update timeout for relevant sso session // // Complete sign in @@ -291,104 +299,6 @@ router.get( req.session.destroy(noop) req.res?.redirect(redirectUrl.toString()) } - - // if (req.query.validate === 'true') { - // OIDC configuration verification flow: the user is attempting to configure SSO for their workspace - // const workspace = await getWorkspaceBySlugFactory({ db })({ - // workspaceSlug: req.params.workspaceSlug - // }) - - // if (!workspace) throw new WorkspaceNotFoundError() - - // await authorizeResolver( - // req.context.userId, - // workspace.id, - // Roles.Workspace.Admin, - // req.context.resourceAccessRules - // ) - // once we're authorized for the ws, we must have a userId - // const userId = req.context.userId! - - // point to the finalize page if there is one - // let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug) - - // try { - // const encryptionKeyPair = await getEncryptionKeyPair() - // const decryptor = await buildDecryptor(encryptionKeyPair) - // const encryptedValidationToken = req.session.codeVerifier - - // if (!encryptedValidationToken) - // throw new Error('cannot find verification token, restart the flow') - - // const codeVerifier = await decryptor.decrypt(encryptedValidationToken) - - // provider = await getOIDCProviderFactory({ - // redis: getGenericRedis(), - // decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt - // })({ - // validationToken: codeVerifier - // }) - - // if (!provider) throw new Error('validation request not found, please retry') - - // const { client } = await initializeIssuerAndClient({ provider }) - // const callbackParams = client.callbackParams(req) - // const tokenSet = await client.callback( - // buildAuthRedirectUrl(req.params.workspaceSlug).toString(), - // callbackParams, - // // eslint-disable-next-line camelcase - // { code_verifier: codeVerifier } - // ) - - // now that we have the user's email, we should compare it to the active user's email. - // Ask if they want to add the email to the oidc as a secondary email, if it doesn't match any of the user's emails - // const ssoProviderUserInfo = await client.userinfo(tokenSet) - // if (!ssoProviderUserInfo.email) - // throw new Error('This should never happen, we are asking for an email claim') - - // const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - - // const trx = await db.transaction() - // const saveSsoProviderRegistration = saveSsoProviderRegistrationFactory({ - // getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ - // db: trx, - // decrypt: decryptor.decrypt - // }), - // associateSsoProviderWithWorkspace: associateSsoProviderWithWorkspaceFactory({ - // db: trx - // }), - // storeProviderRecord: storeProviderRecordFactory({ - // db, - // encrypt: encryptor.encrypt - // }), - // storeUserSsoSession: storeUserSsoSessionFactory({ db: trx }), - // createUserEmail: createUserEmailFactory({ db: trx }) - // }) - - // await withTransaction(saveSsoProviderRegistration({ - // provider, - // userId, - // workspaceId: workspace.id - // // ssoProviderUserInfo - // }), trx) - - // redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') - // } catch (err) { - // logger.warn( - // { error: err }, - // 'Failed to verify OIDC sso provider for workspace {workspaceSlug}' - // ) - // redirectUrl = buildErrorUrl({ - // err, - // url: redirectUrl, - // searchParams: provider || undefined - // }) - // } finally { - // req.session.destroy(noop) - // // redirectUrl. - // req.res?.redirect(redirectUrl.toString()) - // } - // } } ) diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index 4600206ee4..8f6d9477dc 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -11,7 +11,11 @@ import { } from '@/modules/workspaces/domain/sso' import { BaseError } from '@/modules/shared/errors/base' import cryptoRandomString from 'crypto-random-string' -import { CreateUserEmail } from '@/modules/core/domain/userEmails/operations' +import { + CreateUserEmail, + FindEmailsByUserId, + UpdateUserEmail +} from '@/modules/core/domain/userEmails/operations' export class MissingOIDCProviderGrantType extends BaseError { static defaultMessage = 'OIDC issuer does not support authorization_code grant type' @@ -66,25 +70,29 @@ export const saveSsoProviderRegistrationFactory = getWorkspaceSsoProvider, storeProviderRecord, associateSsoProviderWithWorkspace, - storeUserSsoSession - }: // createUserEmail - { + storeUserSsoSession, + createUserEmail, + updateUserEmail, + findEmailsByUserId + }: { getWorkspaceSsoProvider: GetWorkspaceSsoProvider storeProviderRecord: StoreProviderRecord associateSsoProviderWithWorkspace: AssociateSsoProviderWithWorkspace storeUserSsoSession: StoreUserSsoSession createUserEmail: CreateUserEmail + updateUserEmail: UpdateUserEmail + findEmailsByUserId: FindEmailsByUserId }) => async ({ provider, workspaceId, - userId - }: // ssoProviderUserInfo - { + userId, + ssoProviderUserInfo + }: { provider: OIDCProvider userId: string workspaceId: string - // ssoProviderUserInfo: { email: string } + ssoProviderUserInfo: { email: string } }) => { // create OIDC provider record with ID const providerId = cryptoRandomString({ length: 10 }) @@ -108,10 +116,33 @@ export const saveSsoProviderRegistrationFactory = await storeUserSsoSession({ userSsoSession: { createdAt: new Date(), userId, providerId, lifespan } }) - // 1. get userId's emails - // 2. if the ssoUserInfoEmail is not in the user's emails, add it as verified - // 3. if its in the emails, but not verify, verify it - // 4. if its verified, do nothing - // await createUserEmail() + const currentUserEmails = await findEmailsByUserId({ userId }) + const currentSsoEmailEntry = currentUserEmails.find( + (entry) => entry.email === ssoProviderUserInfo.email + ) + + if (!currentSsoEmailEntry) { + await createUserEmail({ + userEmail: { + userId, + email: ssoProviderUserInfo.email, + verified: true + } + }) + return + } + + if (!currentSsoEmailEntry.verified) { + await updateUserEmail({ + query: { + id: currentSsoEmailEntry.id, + userId + }, + update: { + verified: true + } + }) + return + } } From a78e9d3fc108c5263bc5c0bb984d8922812ccf52 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Wed, 2 Oct 2024 11:15:18 +0100 Subject: [PATCH 08/47] fix(sso): park progress --- .../server/modules/core/services/users.js | 4 ++ .../server/modules/workspaces/rest/sso.ts | 56 ++++++++++++++----- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/packages/server/modules/core/services/users.js b/packages/server/modules/core/services/users.js index 8d269d0cab..49e826e12c 100644 --- a/packages/server/modules/core/services/users.js +++ b/packages/server/modules/core/services/users.js @@ -232,6 +232,10 @@ module.exports = { }, // TODO: this should be moved to repository + /** + * Get the user with a primary email that matches the given email + * @returns `null` if user not found + */ async getUserByEmail({ email }) { const user = await Users() .leftJoin(UserEmails.name, UserEmails.col.userId, UsersSchema.col.id) diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index ab7dfff87c..8bdce99e8d 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -42,10 +42,13 @@ import { authorizeResolver } from '@/modules/shared' import { Roles } from '@speckle/shared' import { createUserEmailFactory, + findEmailFactory, findEmailsByUserIdFactory, updateUserEmailFactory } from '@/modules/core/repositories/userEmails' import { withTransaction } from '@/modules/shared/helpers/dbHelper' +import { createUser, getUser } from '@/modules/core/services/users' +import { UserRecord } from '@/modules/core/helpers/userHelper' const router = Router() @@ -175,7 +178,7 @@ router.get( }), query: z.object({ validate: z.string() }) }), - async (req) => { + async (req, _res, next) => { const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) const workspaceSlug = req.params.workspaceSlug @@ -271,19 +274,42 @@ router.get( redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') } else { // OIDC auth flow: SSO is already configured and we are attempting to log in or sign up - // - // Get user by email in `ssoProviderUserInfo` - // - // if (userExists) { - // // Update timeout for relevant sso session - // // Complete sign in - // // Redirect to workspace - // } else { - // // Create user - // // Add email as primary and verified email - // // Complete sign in - // // Redirect to workspace - // } + + // Get Speckle user by email in SSO provider + const userEmail = await findEmailFactory({ db })({ email: ssoProviderUserInfo.email }) + let user: Pick | null = await getUser(userEmail?.userId) + const isNewUser = !user + + if (!user) { + // Create user + const { name, email } = ssoProviderUserInfo + const newUser = { + name, + email, + // TODO: Do we set email as verified only if provider says it's verified + verified: true + } + const userId = await createUser(newUser) + + user = { + ...newUser, + id: userId + } + + // Set workspace role + // TODO: Based on invite! + } + + // Verify user is a member of the workspace + + // Verify workspace has SSO enabled + + // Update timeout for SSO session + + req.user = { id: user.id, email: user.email, isNewUser } + req.authRedirectPath = `workspaces/${req.params.workspaceSlug}/` + + return next() } } catch (err) { logger.warn( @@ -295,9 +321,9 @@ router.get( url: redirectUrl, searchParams: provider || undefined }) + req.res?.redirect(redirectUrl.toString()) } finally { req.session.destroy(noop) - req.res?.redirect(redirectUrl.toString()) } } ) From a014c10ba4857c9320d05341cb25245c7296e8e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= Date: Wed, 2 Oct 2024 21:09:14 +0100 Subject: [PATCH 09/47] chore(workspaces): review sso login/valdate --- .../server/modules/workspaces/rest/sso.ts | 83 +++++++++++++++++-- .../20240930141322_workspace_sso.ts | 1 + 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 8bdce99e8d..136d90c088 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -122,6 +122,37 @@ const buildErrorUrl = ({ return url } +router.get( + '/api/v1/workspaces/:workspaceSlug/sso/auth', + sessionMiddleware, + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }), + query: oidcProvider + }), + async ({ params }) => { + const { workspaceSlug } = params + const encryptionKeyPair = await getEncryptionKeyPair() + const decryptor = await buildDecryptor(encryptionKeyPair) + try { + const workspace = await getWorkspaceBySlugFactory({ db })({ + workspaceSlug + }) + if (!workspace) throw new Error('No workspace found') + + const provider = await getWorkspaceSsoProviderFactory({ + db, + decrypt: decryptor.decrypt + })({ workspaceId: params.workspaceSlug }) + + if (!provider) throw new Error('No SSO provider registered for the workspace') + } catch (err) { + // if things fail, before sending you to the provider, we need to tell it to the user in a nice way + } + } +) + router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', sessionMiddleware, @@ -132,6 +163,8 @@ router.get( query: oidcProvider }), async ({ session, params, query, res }) => { + // do we need to authorize this?, redirect will stop ppl from doing bad shit + // Verify workspace has SSO enabled try { const provider = query const encryptionKeyPair = await getEncryptionKeyPair() @@ -178,7 +211,7 @@ router.get( }), query: z.object({ validate: z.string() }) }), - async (req, _res, next) => { + async (req, res, next) => { const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) const workspaceSlug = req.params.workspaceSlug @@ -187,6 +220,7 @@ router.get( let provider: OIDCProvider | null = null let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug, isValidationFlow) + // Verify workspace has SSO enabled try { // Initialize OIDC client based on provider for current request flow const encryptionKeyPair = await getEncryptionKeyPair() @@ -199,6 +233,8 @@ router.get( const codeVerifier = await decryptor.decrypt(encryptedCodeVerifier) + // this is only the case for the validation route, + // if we're logging in, the provider must come from the pgDB with a cache infront provider = await getOIDCProviderFactory({ redis: getGenericRedis(), decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt @@ -276,12 +312,37 @@ router.get( // OIDC auth flow: SSO is already configured and we are attempting to log in or sign up // Get Speckle user by email in SSO provider - const userEmail = await findEmailFactory({ db })({ email: ssoProviderUserInfo.email }) - let user: Pick | null = await getUser(userEmail?.userId) + const userEmail = await findEmailFactory({ db })({ + email: ssoProviderUserInfo.email + }) + let user: Pick | null = await getUser( + userEmail?.userId + ) + // if someone already uses this email in an sso flow, GO AWAY!!!!! + // req.context.userId + const isNewUser = !user if (!user) { + // let invite + // if (!req.inviteToken) { + // try to get an invite from the db, based on the oidc user info email + // -> invite + // } else { + // get the invite from the db based on the invite token + // -> invite + //} + // if (invite) { + // make sure, the invite is an invite to the current workspace and it doesn't target a user, + // the target must be, the same email, + // that comes back from the oidc provider + // use invite if its not part of the finalize flow?! + //} else { + // GO AWAY!!!! + //} + // Create user + // if the ssoProvderUserInfo comes back with an unverified email, GO AWAY!!!! const { name, email } = ssoProviderUserInfo const newUser = { name, @@ -296,17 +357,19 @@ router.get( id: userId } + // what happens if there is already a req.user ?! + // this is only needed if you are creating a new user + req.user = { id: user.id, email: user.email, isNewUser } + // Set workspace role // TODO: Based on invite! + } else { + // Verify user is a member of the workspace } - // Verify user is a member of the workspace - - // Verify workspace has SSO enabled - // Update timeout for SSO session - req.user = { id: user.id, email: user.email, isNewUser } + // this is not valid in the case of validate, that needs to go to /workspace settings, make sure, that is true req.authRedirectPath = `workspaces/${req.params.workspaceSlug}/` return next() @@ -314,14 +377,16 @@ router.get( } catch (err) { logger.warn( { error: err }, + // this is only valid for the validate errors, not really for login !!!! `Failed to verify OIDC sso provider for workspace ${workspaceSlug}` ) + // in case of this is a login error, we need to redirect to the login page with the error redirectUrl = buildErrorUrl({ err, url: redirectUrl, searchParams: provider || undefined }) - req.res?.redirect(redirectUrl.toString()) + res.redirect(redirectUrl.toString()) } finally { req.session.destroy(noop) } diff --git a/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts b/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts index 062cf99992..6143501a51 100644 --- a/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts +++ b/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts @@ -23,6 +23,7 @@ export async function up(knex: Knex): Promise { .onDelete('cascade') table.primary(['userId', 'providerId']) table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable() + // this should be removed, a valid until field is easier to work with table.bigint('lifespan').notNullable() }) await knex.schema.createTable('workspace_sso_providers', (table) => { From b8726bf61811dc7b538eafdc5886bd6e5107adc2 Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Mon, 7 Oct 2024 14:35:12 +0100 Subject: [PATCH 10/47] fix(sso): adjust validate url --- .../components/header/NavUserMenu.vue | 19 ------------------- .../settings/workspaces/security/Sso.vue | 3 +-- .../lib/settings/graphql/queries.ts | 8 -------- 3 files changed, 1 insertion(+), 29 deletions(-) diff --git a/packages/frontend-2/components/header/NavUserMenu.vue b/packages/frontend-2/components/header/NavUserMenu.vue index f8f5240922..ec7e3f63e2 100644 --- a/packages/frontend-2/components/header/NavUserMenu.vue +++ b/packages/frontend-2/components/header/NavUserMenu.vue @@ -151,15 +151,6 @@ import { SettingMenuKeys, type AvailableSettingsMenuKeys } from '~/lib/settings/helpers/types' -// import { useQuery } from '@vue/apollo-composable' -// import { graphql } from '~~/lib/common/generated/gql' - -// graphql(` -// fragment NavUserMenu_Workspace on Workspace { -// id -// role -// } -// `) defineProps<{ loginUrl?: RouteLocationRaw @@ -173,16 +164,6 @@ const router = useRouter() const { triggerNotification } = useGlobalToast() const { serverInfo } = useServerInfo() const breakpoints = useBreakpoints(TailwindBreakpoints) -// const isWorkspacesEnabled = useIsWorkspacesEnabled() -// const { result: workspaceResult } = useQuery( -// settingsSidebarQuery, -// () => ({ -// workspaceId: route.query?.workspace -// }), -// () => ({ -// enabled: isWorkspacesEnabled.value -// }) -// ) const showInviteDialog = ref(false) const showSettingsDialog = ref(false) diff --git a/packages/frontend-2/components/settings/workspaces/security/Sso.vue b/packages/frontend-2/components/settings/workspaces/security/Sso.vue index ad21fa675f..a7bbd048b2 100644 --- a/packages/frontend-2/components/settings/workspaces/security/Sso.vue +++ b/packages/frontend-2/components/settings/workspaces/security/Sso.vue @@ -92,9 +92,8 @@ const issuerUrl = ref('') const onSubmit = handleSubmit(async () => { const token = useAuthCookie() - const baseUrl = '/api/v1/workspaces/' + const baseUrl = `/api/v1/workspaces/${props.workspace.slug}/sso/oidc/validate` const params = [ - `workspaceSlug=${props.workspace.slug}`, `providerName=${providerName.value}`, `clientId=${clientId.value}`, `clientSecret=${clientSecret.value}`, diff --git a/packages/frontend-2/lib/settings/graphql/queries.ts b/packages/frontend-2/lib/settings/graphql/queries.ts index abeee09fe9..9113e070e8 100644 --- a/packages/frontend-2/lib/settings/graphql/queries.ts +++ b/packages/frontend-2/lib/settings/graphql/queries.ts @@ -102,11 +102,3 @@ export const settingsWorkspacesSecurityQuery = graphql(` } } `) - -// export const navUserMenuWorkspaceQuery = graphql(` -// query SettingsWorkspace($workspaceId: String!) { -// workspace(id: $workspaceId) { -// ...NavUserMenu_Workspace -// } -// } -// `) From b0d2bbf5f8f225264ff519eb3643e577588da614 Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Mon, 7 Oct 2024 23:22:22 +0100 Subject: [PATCH 11/47] chore(sso): auth header puzzle --- .../settings/workspaces/security/Sso.vue | 18 +++++++++++++----- .../server/modules/shared/middleware/index.ts | 16 +++++++++------- packages/server/modules/workspaces/rest/sso.ts | 11 ++++++++++- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/frontend-2/components/settings/workspaces/security/Sso.vue b/packages/frontend-2/components/settings/workspaces/security/Sso.vue index a7bbd048b2..78ab3d38c1 100644 --- a/packages/frontend-2/components/settings/workspaces/security/Sso.vue +++ b/packages/frontend-2/components/settings/workspaces/security/Sso.vue @@ -83,6 +83,7 @@ const props = defineProps<{ workspace: SettingsWorkspacesSecuritySso_WorkspaceFragment }>() +const apiOrigin = useApiOrigin() const { handleSubmit } = useForm() const providerName = ref('') @@ -92,20 +93,27 @@ const issuerUrl = ref('') const onSubmit = handleSubmit(async () => { const token = useAuthCookie() - const baseUrl = `/api/v1/workspaces/${props.workspace.slug}/sso/oidc/validate` + const baseUrl = `${apiOrigin}/api/v1/workspaces/${props.workspace.slug}/sso/oidc/validate` const params = [ `providerName=${providerName.value}`, `clientId=${clientId.value}`, `clientSecret=${clientSecret.value}`, `issuerUrl=${issuerUrl.value}` ] - const route = `${baseUrl}${params.join('&')}` + const route = `${baseUrl}?${params.join('&')}` - await fetch(route, { + // navigateTo(route, { + // external: true + // }) + + const x = await fetch(route, { + mode: 'cors', headers: { Authorization: `Bearer ${token.value}` - }, - redirect: 'follow' + } + // redirect: 'follow' }) + + console.log(x) }) diff --git a/packages/server/modules/shared/middleware/index.ts b/packages/server/modules/shared/middleware/index.ts index f344477732..61f32a74be 100644 --- a/packages/server/modules/shared/middleware/index.ts +++ b/packages/server/modules/shared/middleware/index.ts @@ -55,6 +55,8 @@ export const authMiddlewareCreator = (steps: AuthPipelineFunction[]) => { export const getTokenFromRequest = (req: Request | null | undefined): string | null => { const removeBearerPrefix = (token: string) => token.replace('Bearer ', '') + console.log(req?.headers) + const fromHeader = req?.headers?.authorization || null if (fromHeader?.length) return removeBearerPrefix(fromHeader) @@ -190,14 +192,14 @@ export async function buildContext({ */ export const mixpanelTrackerHelperMiddlewareFactory = (deps: { getUser: typeof getUser }): Handler => - async (req: Request, _res: Response, next: NextFunction) => { - const ctx = req.context - const user = ctx.userId ? await deps.getUser(ctx.userId) : null - const mp = mixpanel({ userEmail: user?.email, req }) + async (req: Request, _res: Response, next: NextFunction) => { + const ctx = req.context + const user = ctx.userId ? await deps.getUser(ctx.userId) : null + const mp = mixpanel({ userEmail: user?.email, req }) - req.mixpanel = mp - next() - } + req.mixpanel = mp + next() + } const X_SPECKLE_CLIENT_IP_HEADER = 'x-speckle-client-ip' /** diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 9df78eb7a7..44bd9789cd 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -91,7 +91,11 @@ router.get( }), query: oidcProvider }), - async ({ session, params, query, res }) => { + async ({ context, session, params, query, res }) => { + console.log('/validate') + console.log(context) + console.log(context.userId) + try { const provider = query const encryptionKeyPair = await getEncryptionKeyPair() @@ -144,6 +148,11 @@ router.get( const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) let provider: OIDCProvider | null = null + + console.log('/callback') + console.log(req.context) + console.log(req.context.userId) + if (req.query.validate === 'true') { const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug: req.params.workspaceSlug From 49bc0a4113ee32a384782d2cec4518542a1ab643 Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Tue, 8 Oct 2024 17:07:39 +0100 Subject: [PATCH 12/47] fix(sso): happy-path config --- .../settings/workspaces/Security.vue | 13 ++++++++----- .../settings/workspaces/security/Sso.vue | 18 +++--------------- packages/frontend-2/composables/globals.ts | 8 ++++++++ .../lib/settings/composables/management.ts | 11 +++++++++++ .../server/modules/shared/middleware/index.ts | 14 +++++++------- packages/server/modules/workspaces/rest/sso.ts | 4 ---- 6 files changed, 37 insertions(+), 31 deletions(-) diff --git a/packages/frontend-2/components/settings/workspaces/Security.vue b/packages/frontend-2/components/settings/workspaces/Security.vue index 4883c057bd..f1a36e7498 100644 --- a/packages/frontend-2/components/settings/workspaces/Security.vue +++ b/packages/frontend-2/components/settings/workspaces/Security.vue @@ -5,11 +5,13 @@ title="Security" text="Manage verified workspace domains and associated features." /> - -
    +
      @@ -161,6 +163,7 @@ const props = defineProps<{ const addWorkspaceDomain = useAddWorkspaceDomain() const { triggerNotification } = useGlobalToast() +const isSsoEnabled = useIsWorkspacesSsoEnabled() const apollo = useApolloClient().client const mixpanel = useMixpanel() diff --git a/packages/frontend-2/components/settings/workspaces/security/Sso.vue b/packages/frontend-2/components/settings/workspaces/security/Sso.vue index 78ab3d38c1..4eba8ebd84 100644 --- a/packages/frontend-2/components/settings/workspaces/security/Sso.vue +++ b/packages/frontend-2/components/settings/workspaces/security/Sso.vue @@ -62,7 +62,6 @@ import { useForm } from 'vee-validate' import { isRequired, isStringOfLength, isUrl } from '~~/lib/common/helpers/validation' import { graphql } from '~~/lib/common/generated/gql' -import { useAuthCookie } from '~~/lib/auth/composables/auth' import type { SettingsWorkspacesSecuritySso_WorkspaceFragment } from '~~/lib/common/generated/gql/graphql' graphql(` @@ -91,8 +90,7 @@ const clientId = ref('') const clientSecret = ref('') const issuerUrl = ref('') -const onSubmit = handleSubmit(async () => { - const token = useAuthCookie() +const onSubmit = handleSubmit(() => { const baseUrl = `${apiOrigin}/api/v1/workspaces/${props.workspace.slug}/sso/oidc/validate` const params = [ `providerName=${providerName.value}`, @@ -102,18 +100,8 @@ const onSubmit = handleSubmit(async () => { ] const route = `${baseUrl}?${params.join('&')}` - // navigateTo(route, { - // external: true - // }) - - const x = await fetch(route, { - mode: 'cors', - headers: { - Authorization: `Bearer ${token.value}` - } - // redirect: 'follow' + navigateTo(route, { + external: true }) - - console.log(x) }) diff --git a/packages/frontend-2/composables/globals.ts b/packages/frontend-2/composables/globals.ts index 344d26d312..60832ae026 100644 --- a/packages/frontend-2/composables/globals.ts +++ b/packages/frontend-2/composables/globals.ts @@ -18,6 +18,14 @@ export const useIsWorkspacesEnabled = () => { return ref(FF_WORKSPACES_MODULE_ENABLED) } +export const useIsWorkspacesSsoEnabled = () => { + const { + public: { FF_WORKSPACES_SSO_ENABLED } + } = useRuntimeConfig() + + return ref(FF_WORKSPACES_SSO_ENABLED) +} + export const useIsMultipleEmailsEnabled = () => { const { public: { FF_MULTIPLE_EMAILS_MODULE_ENABLED } diff --git a/packages/frontend-2/lib/settings/composables/management.ts b/packages/frontend-2/lib/settings/composables/management.ts index 327510e836..365f5eba3a 100644 --- a/packages/frontend-2/lib/settings/composables/management.ts +++ b/packages/frontend-2/lib/settings/composables/management.ts @@ -14,6 +14,7 @@ import type { AddDomainToWorkspaceInput } from '~~/lib/common/generated/gql/graphql' import type { WorkspaceDomain, Workspace } from '~/lib/common/generated/gql/graphql' +import { gql } from '@apollo/client' export function useUpdateWorkspace() { const { mutate, loading } = useMutation(settingsUpdateWorkspaceMutation) @@ -68,6 +69,16 @@ export function useAddWorkspaceDomain() { addDomain: { __typename: 'Workspace', id: input.workspaceId, + slug: ( + apollo.readFragment({ + id: getCacheId('Workspace', input.workspaceId), + fragment: gql` + fragment AddDomainWorkspace on Workspace { + slug + } + ` + }) as Workspace + ).slug, domains: [ ...domains, { diff --git a/packages/server/modules/shared/middleware/index.ts b/packages/server/modules/shared/middleware/index.ts index 61f32a74be..a372fc340f 100644 --- a/packages/server/modules/shared/middleware/index.ts +++ b/packages/server/modules/shared/middleware/index.ts @@ -192,14 +192,14 @@ export async function buildContext({ */ export const mixpanelTrackerHelperMiddlewareFactory = (deps: { getUser: typeof getUser }): Handler => - async (req: Request, _res: Response, next: NextFunction) => { - const ctx = req.context - const user = ctx.userId ? await deps.getUser(ctx.userId) : null - const mp = mixpanel({ userEmail: user?.email, req }) + async (req: Request, _res: Response, next: NextFunction) => { + const ctx = req.context + const user = ctx.userId ? await deps.getUser(ctx.userId) : null + const mp = mixpanel({ userEmail: user?.email, req }) - req.mixpanel = mp - next() - } + req.mixpanel = mp + next() + } const X_SPECKLE_CLIENT_IP_HEADER = 'x-speckle-client-ip' /** diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 44bd9789cd..a93480d843 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -149,10 +149,6 @@ router.get( let provider: OIDCProvider | null = null - console.log('/callback') - console.log(req.context) - console.log(req.context.userId) - if (req.query.validate === 'true') { const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug: req.params.workspaceSlug From 3063d3308e6245ec42dffa403a8e78401cdda1aa Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Wed, 9 Oct 2024 21:54:20 +0100 Subject: [PATCH 13/47] chore(gql): gqlgen --- packages/frontend-2/lib/common/generated/gql/gql.ts | 5 +++++ packages/frontend-2/lib/common/generated/gql/graphql.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/packages/frontend-2/lib/common/generated/gql/gql.ts b/packages/frontend-2/lib/common/generated/gql/gql.ts index 62a9e2eb14..440aa15d48 100644 --- a/packages/frontend-2/lib/common/generated/gql/gql.ts +++ b/packages/frontend-2/lib/common/generated/gql/gql.ts @@ -266,6 +266,7 @@ const documents = { "\n query AdminPanelProjectsList(\n $query: String\n $orderBy: String\n $limit: Int!\n $visibility: String\n $cursor: String\n ) {\n admin {\n projectList(\n query: $query\n orderBy: $orderBy\n limit: $limit\n visibility: $visibility\n cursor: $cursor\n ) {\n cursor\n ...SettingsServerProjects_ProjectCollection\n }\n }\n }\n": types.AdminPanelProjectsListDocument, "\n query AdminPanelInvitesList($limit: Int!, $cursor: String, $query: String) {\n admin {\n inviteList(limit: $limit, cursor: $cursor, query: $query) {\n cursor\n items {\n email\n id\n invitedBy {\n id\n name\n }\n }\n totalCount\n }\n }\n }\n": types.AdminPanelInvitesListDocument, "\n mutation InviteServerUser($input: [ServerInviteCreateInput!]!) {\n serverInviteBatchCreate(input: $input)\n }\n": types.InviteServerUserDocument, + "\n fragment AddDomainWorkspace on Workspace {\n slug\n }\n ": types.AddDomainWorkspaceFragmentDoc, "\n mutation SettingsUpdateWorkspace($input: WorkspaceUpdateInput!) {\n workspaceMutations {\n update(input: $input) {\n ...SettingsWorkspacesGeneral_Workspace\n }\n }\n }\n": types.SettingsUpdateWorkspaceDocument, "\n mutation SettingsCreateUserEmail($input: CreateUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n create(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsCreateUserEmailDocument, "\n mutation SettingsDeleteUserEmail($input: DeleteUserEmailInput!) {\n activeUserMutations {\n emailMutations {\n delete(input: $input) {\n ...SettingsUserEmails_User\n }\n }\n }\n }\n": types.SettingsDeleteUserEmailDocument, @@ -1363,6 +1364,10 @@ export function graphql(source: "\n query AdminPanelInvitesList($limit: Int!, $ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation InviteServerUser($input: [ServerInviteCreateInput!]!) {\n serverInviteBatchCreate(input: $input)\n }\n"): (typeof documents)["\n mutation InviteServerUser($input: [ServerInviteCreateInput!]!) {\n serverInviteBatchCreate(input: $input)\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n fragment AddDomainWorkspace on Workspace {\n slug\n }\n "): (typeof documents)["\n fragment AddDomainWorkspace on Workspace {\n slug\n }\n "]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 58d096f38e..4bfae9d52f 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -5319,6 +5319,8 @@ export type InviteServerUserMutationVariables = Exact<{ export type InviteServerUserMutation = { __typename?: 'Mutation', serverInviteBatchCreate: boolean }; +export type AddDomainWorkspaceFragment = { __typename?: 'Workspace', slug: string }; + export type SettingsUpdateWorkspaceMutationVariables = Exact<{ input: WorkspaceUpdateInput; }>; @@ -5885,6 +5887,7 @@ export const ProjectPageTeamInternals_WorkspaceFragmentDoc = {"kind":"Document", export const ProjectUpdatableMetadataFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectUpdatableMetadata"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"allowPublicComments"}}]}}]} as unknown as DocumentNode; export const ProjectPageLatestItemsCommentsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageLatestItemsComments"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Project"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","alias":{"kind":"Name","value":"commentThreadCount"},"name":{"kind":"Name","value":"commentThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]} as unknown as DocumentNode; export const ProjectPageLatestItemsCommentItemFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ProjectPageLatestItemsCommentItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormUsersSelectItem"}}]}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"archived"}},{"kind":"Field","alias":{"kind":"Name","value":"repliesCount"},"name":{"kind":"Name","value":"replies"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"0"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"replyAuthors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"4"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormUsersSelectItem"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FormUsersSelectItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]} as unknown as DocumentNode; +export const AddDomainWorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AddDomainWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}}]}}]} as unknown as DocumentNode; export const AppAuthorAvatarFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AppAuthorAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AppAuthor"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]} as unknown as DocumentNode; export const ThreadCommentAttachmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ThreadCommentAttachment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"attachments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"fileType"}},{"kind":"Field","name":{"kind":"Name","value":"fileSize"}}]}}]}}]}}]} as unknown as DocumentNode; export const ViewerCommentsReplyItemFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewerCommentsReplyItem"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"archived"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}}]}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedUserAvatar"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ThreadCommentAttachment"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedUserAvatar"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ThreadCommentAttachment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"attachments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fileName"}},{"kind":"Field","name":{"kind":"Name","value":"fileType"}},{"kind":"Field","name":{"kind":"Name","value":"fileSize"}}]}}]}}]}}]} as unknown as DocumentNode; From 3df52df6a468b426dfc1873aa283c40e890f0c3a Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Thu, 10 Oct 2024 17:00:39 +0100 Subject: [PATCH 14/47] fix(sso): almost almost --- .../server/modules/workspaces/rest/sso.ts | 183 +++++++++++------- 1 file changed, 116 insertions(+), 67 deletions(-) diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index a80109df13..12a2af8c6e 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -1,3 +1,5 @@ +/* eslint-disable camelcase */ + import { db } from '@/db/knex' import { validateRequest } from 'zod-express' import { Router } from 'express' @@ -132,7 +134,6 @@ router.get( db, decrypt: decryptor.decrypt })({ workspaceId: params.workspaceSlug }) - if (!provider) throw new Error('No SSO provider registered for the workspace') } catch (err) { // if things fail, before sending you to the provider, we need to tell it to the user in a nice way @@ -149,9 +150,21 @@ router.get( }), query: oidcProvider }), - async ({ session, params, query, res }) => { - // do we need to authorize this?, redirect will stop ppl from doing bad shit - // Verify workspace has SSO enabled + async ({ session, params, query, res, context }) => { + const workspaceSlug = params.workspaceSlug + + const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug }) + if (!workspace) throw new WorkspaceNotFoundError() + + // TODO: Billing check for workspace plan - is SSO allowed + + await authorizeResolver( + context.userId, + workspace.id, + Roles.Workspace.Admin, + context.resourceAccessRules + ) + try { const provider = query const encryptionKeyPair = await getEncryptionKeyPair() @@ -174,7 +187,6 @@ router.get( }) session.codeVerifier = await encryptor.encrypt(codeVerifier) - // maybe not needed encryptor.dispose() res?.redirect(authorizationUrl.toString()) } catch (err) { @@ -207,7 +219,8 @@ router.get( let provider: OIDCProvider | null = null let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug, isValidationFlow) - // Verify workspace has SSO enabled + // TODO: Billing check - verify workspace has SSO enabled + try { // Initialize OIDC client based on provider for current request flow const encryptionKeyPair = await getEncryptionKeyPair() @@ -220,23 +233,33 @@ router.get( const codeVerifier = await decryptor.decrypt(encryptedCodeVerifier) - // this is only the case for the validation route, - // if we're logging in, the provider must come from the pgDB with a cache infront - provider = await getOIDCProviderFactory({ - redis: getGenericRedis(), - decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt - })({ - validationToken: codeVerifier - }) + if (isValidationFlow) { + // Get provider configuration from redis + provider = await getOIDCProviderFactory({ + redis: getGenericRedis(), + decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt + })({ + validationToken: codeVerifier + }) - if (!provider) throw new Error('validation request not found, please retry') + if (!provider) throw new Error('validation request not found, please retry') + } else { + // Get stored provider configuration + const providerMetadata = await getWorkspaceSsoProviderFactory({ + db, + decrypt: decryptor.decrypt + })({ workspaceId: workspaceSlug }) + + if (!providerMetadata?.provider) throw new Error('Could not find SSO provider') + + provider = providerMetadata.provider + } const { client } = await initializeIssuerAndClient({ provider }) const callbackParams = client.callbackParams(req) const tokenSet = await client.callback( buildAuthRedirectUrl(workspaceSlug, isValidationFlow).toString(), callbackParams, - /* eslint-disable-next-line camelcase */ { code_verifier: codeVerifier } ) @@ -252,8 +275,6 @@ router.get( }) if (!workspace) throw new WorkspaceNotFoundError() - // TODO: Assert billing status - // Only workspace admins may configure SSO await authorizeResolver( req.context.userId, @@ -297,67 +318,97 @@ router.get( redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') } else { // OIDC auth flow: SSO is already configured and we are attempting to log in or sign up + const currentSessionUser = req.user - // Get Speckle user by email in SSO provider + // Get Speckle user by email from SSO provider const userEmail = await findEmailFactory({ db })({ email: ssoProviderUserInfo.email }) - let user: Pick | null = await getUser( + const existingSpeckleUser: Pick | null = await getUser( userEmail?.userId ) - // if someone already uses this email in an sso flow, GO AWAY!!!!! - // req.context.userId - - const isNewUser = !user - - if (!user) { - // let invite - // if (!req.inviteToken) { - // try to get an invite from the db, based on the oidc user info email - // -> invite - // } else { - // get the invite from the db based on the invite token - // -> invite - //} - // if (invite) { - // make sure, the invite is an invite to the current workspace and it doesn't target a user, - // the target must be, the same email, - // that comes back from the oidc provider - // use invite if its not part of the finalize flow?! - //} else { - // GO AWAY!!!! - //} - - // Create user - // if the ssoProvderUserInfo comes back with an unverified email, GO AWAY!!!! - const { name, email } = ssoProviderUserInfo - const newUser = { - name, - email, - // TODO: Do we set email as verified only if provider says it's verified - verified: true - } - const userId = await createUser(newUser) - user = { - ...newUser, - id: userId + const isNewUser = !currentSessionUser && !existingSpeckleUser + + if (!currentSessionUser) { + if (!existingSpeckleUser) { + // Sign up flow with SSO: + // No session user, and no existing user with given SSO provider email + + // let invite + // if (!req.inviteToken) { + // try to get an invite from the db, based on the oidc user info email + // -> invite + // } else { + // get the invite from the db based on the invite token + // -> invite + //} + // if (invite) { + // make sure, the invite is an invite to the current workspace and it doesn't target a user, + // the target must be, the same email, + // that comes back from the oidc provider + // use invite if its not part of the finalize flow?! + //} else { + // GO AWAY!!!! + //} + + // Create speckle user + const { name, email, email_verified } = ssoProviderUserInfo + + if (!email_verified) { + throw new Error('Cannot sign in with unverified email') + } + + const newSpeckleUser = { + name, + email, + verified: true + } + const newSpeckleUserId = await createUser(newSpeckleUser) + + // Link SSO provider email with user email + + // Assert sign in + req.user = { id: newSpeckleUserId, email: newSpeckleUser.email, isNewUser: true } + } else { + // Sign in flow with SSO: + // No session user, but existing user with given SSO provider email + + // TODO: Validate link between SSO provider user and existing speckle user + + // Assert sign in + req.user = { id: existingSpeckleUser.id, email: existingSpeckleUser.email } } + } else { + if (!existingSpeckleUser) { + // Sign in flow with SSO: + // Active session user, but no existing user with given SSO provider email - // what happens if there is already a req.user ?! - // this is only needed if you are creating a new user - req.user = { id: user.id, email: user.email, isNewUser } + // Link SSO provider email with speckle user on session + // (1) add to user emails + // (2) store link between speckle user and SSO provider user - // Set workspace role - // TODO: Based on invite! - } else { - // Verify user is a member of the workspace + // Perform sign in + } else { + // Sign in flow with SSO: + // Active session user, and existing user with given SSO provider email + + // Verify session user id matches existing user id + } } + // Verify req.user is part of the workspace + // Update timeout for SSO session - // this is not valid in the case of validate, that needs to go to /workspace settings, make sure, that is true - req.authRedirectPath = `workspaces/${req.params.workspaceSlug}/` + // Construct final redirect + const redirectUrlFragments: string[] = [ + `workspaces/${req.params.workspaceSlug}` + ] + if (isValidationFlow) { + redirectUrlFragments.push(`?settings=workspace/security&workspace=${workspaceSlug}`) + } + req.authRedirectPath = redirectUrlFragments.join() return next() } @@ -374,8 +425,6 @@ router.get( searchParams: provider || undefined }) res.redirect(redirectUrl.toString()) - } finally { - req.session.destroy(noop) } }, finalizeAuthMiddleware From 27ac047905842a6d443c11dc4c5f0e169fa5dea3 Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Thu, 10 Oct 2024 22:49:17 +0100 Subject: [PATCH 15/47] fix(sso): auth endpoint --- .../server/modules/workspaces/rest/sso.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 12a2af8c6e..488085d309 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -120,7 +120,7 @@ router.get( }), query: oidcProvider }), - async ({ params }) => { + async ({ params, session, res }) => { const { workspaceSlug } = params const encryptionKeyPair = await getEncryptionKeyPair() const decryptor = await buildDecryptor(encryptionKeyPair) @@ -130,11 +130,34 @@ router.get( }) if (!workspace) throw new Error('No workspace found') - const provider = await getWorkspaceSsoProviderFactory({ + const providerMetadata = await getWorkspaceSsoProviderFactory({ db, decrypt: decryptor.decrypt })({ workspaceId: params.workspaceSlug }) - if (!provider) throw new Error('No SSO provider registered for the workspace') + if (!providerMetadata) throw new Error('No SSO provider registered for the workspace') + + // Redirect to OIDC provider to continue auth flow + const { provider } = providerMetadata + const encryptionKeyPair = await getEncryptionKeyPair() + const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) + const codeVerifier = await startOIDCSsoProviderValidationFactory({ + getOIDCProviderAttributes, + storeOIDCProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ + redis: getGenericRedis(), + encrypt: encryptor.encrypt + }), + generateCodeVerifier: generators.codeVerifier + })({ + provider + }) + const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, false) + const authorizationUrl = await getProviderAuthorizationUrl({ + provider, + redirectUrl, + codeVerifier + }) + session.codeVerifier = await encryptor.encrypt(codeVerifier) + res?.redirect(authorizationUrl.toString()) } catch (err) { // if things fail, before sending you to the provider, we need to tell it to the user in a nice way } @@ -328,8 +351,6 @@ router.get( userEmail?.userId ) - const isNewUser = !currentSessionUser && !existingSpeckleUser - if (!currentSessionUser) { if (!existingSpeckleUser) { // Sign up flow with SSO: From 7a7424b19a58fb3254a3a081f97f75ad9b66db93 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Sun, 13 Oct 2024 17:50:31 +0100 Subject: [PATCH 16/47] a lil more terse --- .../server/modules/workspaces/rest/sso.ts | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 488085d309..a2ace425a6 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -28,7 +28,10 @@ import { getGenericRedis } from '@/modules/core' import { generators } from 'openid-client' import { noop } from 'lodash' import { OIDCProvider, oidcProvider } from '@/modules/workspaces/domain/sso' -import { getWorkspaceBySlugFactory } from '@/modules/workspaces/repositories/workspaces' +import { + getWorkspaceBySlugFactory, + getWorkspaceCollaboratorsFactory +} from '@/modules/workspaces/repositories/workspaces' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { authorizeResolver } from '@/modules/shared' import { Roles } from '@speckle/shared' @@ -134,7 +137,8 @@ router.get( db, decrypt: decryptor.decrypt })({ workspaceId: params.workspaceSlug }) - if (!providerMetadata) throw new Error('No SSO provider registered for the workspace') + if (!providerMetadata) + throw new Error('No SSO provider registered for the workspace') // Redirect to OIDC provider to continue auth flow const { provider } = providerMetadata @@ -291,12 +295,19 @@ router.get( if (!ssoProviderUserInfo || !ssoProviderUserInfo.email) throw new Error('This should never happen, we are asking for an email claim') + // Get information about the workspace we are signing in to + const workspace = await getWorkspaceBySlugFactory({ db })({ + workspaceSlug: req.params.workspaceSlug + }) + if (!workspace) throw new WorkspaceNotFoundError() + + const workspaceRoles = await getWorkspaceCollaboratorsFactory({ db })({ + workspaceId: workspace.id, + limit: 100 + }) + if (isValidationFlow) { // OIDC configuration verification flow: the user is attempting to configure SSO for their workspace - const workspace = await getWorkspaceBySlugFactory({ db })({ - workspaceSlug: req.params.workspaceSlug - }) - if (!workspace) throw new WorkspaceNotFoundError() // Only workspace admins may configure SSO await authorizeResolver( @@ -347,14 +358,19 @@ router.get( const userEmail = await findEmailFactory({ db })({ email: ssoProviderUserInfo.email }) - const existingSpeckleUser: Pick | null = await getUser( - userEmail?.userId - ) + const existingSpeckleUser: Pick | null = + await getUser(userEmail?.userId) + + // TODO: Validate link between SSO user email and Speckle user + // Link occurs when an already signed-in user signs in with SSO + // Create link here implicitly if conditions are met and no link exists already if (!currentSessionUser) { if (!existingSpeckleUser) { // Sign up flow with SSO: - // No session user, and no existing user with given SSO provider email + // User is not signed in, and no Speckle user is associated with SSO provider user + + // Check if user has email-based invite to given workspace // let invite // if (!req.inviteToken) { @@ -373,7 +389,7 @@ router.get( // GO AWAY!!!! //} - // Create speckle user + // Create Speckle user const { name, email, email_verified } = ssoProviderUserInfo if (!email_verified) { @@ -387,15 +403,17 @@ router.get( } const newSpeckleUserId = await createUser(newSpeckleUser) - // Link SSO provider email with user email + // Add user to workspace with role specified in invite // Assert sign in - req.user = { id: newSpeckleUserId, email: newSpeckleUser.email, isNewUser: true } + req.user = { + id: newSpeckleUserId, + email: newSpeckleUser.email, + isNewUser: true + } } else { // Sign in flow with SSO: - // No session user, but existing user with given SSO provider email - - // TODO: Validate link between SSO provider user and existing speckle user + // User is not signed in, but there is a Speckle user associated with the SSO user // Assert sign in req.user = { id: existingSpeckleUser.id, email: existingSpeckleUser.email } @@ -403,22 +421,16 @@ router.get( } else { if (!existingSpeckleUser) { // Sign in flow with SSO: - // Active session user, but no existing user with given SSO provider email - - // Link SSO provider email with speckle user on session - // (1) add to user emails - // (2) store link between speckle user and SSO provider user - - // Perform sign in + // User is already signed in, but no Speckle user is associated with the SSO user + // Continue to sign in } else { // Sign in flow with SSO: - // Active session user, and existing user with given SSO provider email - + // User is already signed in, and there is already a Speckle user associated with the SSO user // Verify session user id matches existing user id } } - // Verify req.user is part of the workspace + // Confirm that req.user is a member of the given workspace // Update timeout for SSO session @@ -427,7 +439,9 @@ router.get( `workspaces/${req.params.workspaceSlug}` ] if (isValidationFlow) { - redirectUrlFragments.push(`?settings=workspace/security&workspace=${workspaceSlug}`) + redirectUrlFragments.push( + `?settings=workspace/security&workspace=${workspaceSlug}` + ) } req.authRedirectPath = redirectUrlFragments.join() From 4566840ec043ec75a439f543049a725a1a66e4b7 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Sun, 13 Oct 2024 22:36:24 +0100 Subject: [PATCH 17/47] fix(sso): light at the end of the tunnel --- .../server/modules/workspaces/domain/sso.ts | 2 +- .../modules/workspaces/repositories/sso.ts | 93 ++++++------ .../server/modules/workspaces/rest/sso.ts | 54 +++++-- .../server/modules/workspaces/services/sso.ts | 140 +++++++++--------- 4 files changed, 164 insertions(+), 125 deletions(-) diff --git a/packages/server/modules/workspaces/domain/sso.ts b/packages/server/modules/workspaces/domain/sso.ts index 92c7fcec54..d716aff513 100644 --- a/packages/server/modules/workspaces/domain/sso.ts +++ b/packages/server/modules/workspaces/domain/sso.ts @@ -44,7 +44,7 @@ export type UserSsoSession = { lifespan: number } -export type StoreUserSsoSession = (args: { +export type UpsertUserSsoSession = (args: { userSsoSession: UserSsoSession }) => Promise diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index 8511e4a210..da7593938c 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -5,7 +5,7 @@ import { StoreProviderRecord, ProviderRecord, AssociateSsoProviderWithWorkspace, - StoreUserSsoSession, + UpsertUserSsoSession, UserSsoSession, GetWorkspaceSsoProvider } from '@/modules/workspaces/domain/sso' @@ -35,61 +35,64 @@ export const storeOIDCProviderValidationRequestFactory = redis: Redis encrypt: Crypt }): StoreOIDCProviderValidationRequest => - async ({ provider, token }) => { - const providerData = await encrypt(JSON.stringify(provider)) - await redis.set(token, providerData) - } + 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 - } + 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( - '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') + async ({ workspaceId }) => { + const maybeProvider = await db( + '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) - } + 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 }) - } + 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) - } +export const upsertUserSsoSessionFactory = + ({ db }: { db: Knex }): UpsertUserSsoSession => + async ({ userSsoSession }) => { + await tables + .userSsoSessions(db) + .insert(userSsoSession) + .onConflict(['userId', 'providerId']) + .merge(['createdAt', 'lifespan']) + } diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index a2ace425a6..4f48a75266 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -19,7 +19,7 @@ import { getOIDCProviderFactory, associateSsoProviderWithWorkspaceFactory, storeProviderRecordFactory, - storeUserSsoSessionFactory, + upsertUserSsoSessionFactory, getWorkspaceSsoProviderFactory } from '@/modules/workspaces/repositories/sso' import { buildDecryptor, buildEncryptor } from '@/modules/shared/utils/libsodium' @@ -228,6 +228,12 @@ router.get( } ) +// TODO: +// - tryGetWorkspaceInvite +// - add new user to workspace with role +// - return new provider id on create +// - `user_sso_sessions` table `lifespan` => `validUntil` +// - Better catch block error messages router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', sessionMiddleware, @@ -244,6 +250,7 @@ router.get( const isValidationFlow = req.query.validate === 'true' let provider: OIDCProvider | null = null + let providerId: string | null = null let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug, isValidationFlow) // TODO: Billing check - verify workspace has SSO enabled @@ -280,6 +287,7 @@ router.get( if (!providerMetadata?.provider) throw new Error('Could not find SSO provider') provider = providerMetadata.provider + providerId = providerMetadata.providerId } const { client } = await initializeIssuerAndClient({ provider }) @@ -301,11 +309,6 @@ router.get( }) if (!workspace) throw new WorkspaceNotFoundError() - const workspaceRoles = await getWorkspaceCollaboratorsFactory({ db })({ - workspaceId: workspace.id, - limit: 100 - }) - if (isValidationFlow) { // OIDC configuration verification flow: the user is attempting to configure SSO for their workspace @@ -320,6 +323,7 @@ router.get( // Write SSO configuration const trx = await db.transaction() + // TODO: Return new provider record and store id const saveSsoProviderRegistration = saveSsoProviderRegistrationFactory({ getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ db: trx, @@ -332,7 +336,7 @@ router.get( db, encrypt: encryptor.encrypt }), - storeUserSsoSession: storeUserSsoSessionFactory({ db: trx }), + storeUserSsoSession: upsertUserSsoSessionFactory({ db: trx }), createUserEmail: createUserEmailFactory({ db: trx }), updateUserEmail: updateUserEmailFactory({ db: trx }), findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }) @@ -368,7 +372,7 @@ router.get( if (!currentSessionUser) { if (!existingSpeckleUser) { // Sign up flow with SSO: - // User is not signed in, and no Speckle user is associated with SSO provider user + // User is not signed in, and no Speckle user is associated with SSO user // Check if user has email-based invite to given workspace @@ -427,12 +431,44 @@ router.get( // Sign in flow with SSO: // User is already signed in, and there is already a Speckle user associated with the SSO user // Verify session user id matches existing user id + if (currentSessionUser.id !== existingSpeckleUser.id) { + throw new Error('SSO user already associated with another Speckle account') + } } } // Confirm that req.user is a member of the given workspace + const workspaceRoles = await getWorkspaceCollaboratorsFactory({ db })({ + workspaceId: workspace.id, + limit: 100 + }) - // Update timeout for SSO session + if (!req.user || !req.user?.id) { + // This should not happen + throw new Error('Unhandled failure to sign in') + } + + if (!workspaceRoles.some((role) => role.id === req.user?.id)) { + throw new Error('User is not a member of the given workspace and cannot sign in with SSO') + } + + // Update validUntil for SSO session + if (!providerId) { + throw new Error('Unhandled failure to find SSO provider') + } + + const validUntil = new Date() + validUntil.setDate(validUntil.getDate() + 7) + + await upsertUserSsoSessionFactory({ db })({ + userSsoSession: { + userId: req.user.id, + providerId, + createdAt: new Date(), + // TODO: Use `validUntil` + lifespan: 100 + } + }) // Construct final redirect const redirectUrlFragments: string[] = [ diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index 8f6d9477dc..35c75079b3 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -4,7 +4,7 @@ import { OIDCProvider, StoreOIDCProviderValidationRequest, StoreProviderRecord, - StoreUserSsoSession, + UpsertUserSsoSession, OIDCProviderRecord, AssociateSsoProviderWithWorkspace, GetWorkspaceSsoProvider @@ -54,16 +54,16 @@ export const startOIDCSsoProviderValidationFactory = storeOIDCProviderValidationRequest: StoreOIDCProviderValidationRequest generateCodeVerifier: () => string }) => - async ({ provider }: { provider: OIDCProvider }): Promise => { - // get client information - const providerAttributes = await getOIDCProviderAttributes({ provider }) - // validate issuer and client data - validateOIDCProviderAttributes(providerAttributes) - // store provider validation with an id token - const codeVerifier = generateCodeVerifier() - await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) - return codeVerifier - } + async ({ provider }: { provider: OIDCProvider }): Promise => { + // get client information + const providerAttributes = await getOIDCProviderAttributes({ provider }) + // validate issuer and client data + validateOIDCProviderAttributes(providerAttributes) + // store provider validation with an id token + const codeVerifier = generateCodeVerifier() + await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) + return codeVerifier + } export const saveSsoProviderRegistrationFactory = ({ @@ -78,71 +78,71 @@ export const saveSsoProviderRegistrationFactory = getWorkspaceSsoProvider: GetWorkspaceSsoProvider storeProviderRecord: StoreProviderRecord associateSsoProviderWithWorkspace: AssociateSsoProviderWithWorkspace - storeUserSsoSession: StoreUserSsoSession + storeUserSsoSession: UpsertUserSsoSession createUserEmail: CreateUserEmail updateUserEmail: UpdateUserEmail findEmailsByUserId: FindEmailsByUserId }) => - async ({ - provider, - workspaceId, - userId, - ssoProviderUserInfo - }: { - provider: OIDCProvider - userId: string - workspaceId: string - ssoProviderUserInfo: { email: string } - }) => { - // create OIDC provider record with ID - const providerId = cryptoRandomString({ length: 10 }) - const providerRecord: OIDCProviderRecord = { + async ({ provider, - providerType: 'oidc', - createdAt: new Date(), - updatedAt: new Date(), - id: providerId - } - const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) - // replace with a proper error - if (maybeExistingSsoProvider) - throw new Error('Workspace already has an SSO provider') - await storeProviderRecord({ providerRecord }) - // associate provider with workspace - await associateSsoProviderWithWorkspace({ workspaceId, providerId }) - // create and associate userSso session (how long is the default validity?) - // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work - const lifespan = 6.048e8 // 1 week - await storeUserSsoSession({ - userSsoSession: { createdAt: new Date(), userId, providerId, lifespan } - }) + workspaceId, + userId, + ssoProviderUserInfo + }: { + provider: OIDCProvider + userId: string + workspaceId: string + ssoProviderUserInfo: { email: string } + }) => { + // create OIDC provider record with ID + const providerId = cryptoRandomString({ length: 10 }) + const providerRecord: OIDCProviderRecord = { + provider, + providerType: 'oidc', + createdAt: new Date(), + updatedAt: new Date(), + id: providerId + } + const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) + // replace with a proper error + if (maybeExistingSsoProvider) + throw new Error('Workspace already has an SSO provider') + await storeProviderRecord({ providerRecord }) + // associate provider with workspace + await associateSsoProviderWithWorkspace({ workspaceId, providerId }) + // create and associate userSso session (how long is the default validity?) + // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work + const lifespan = 6.048e8 // 1 week + await storeUserSsoSession({ + userSsoSession: { createdAt: new Date(), userId, providerId, lifespan } + }) - const currentUserEmails = await findEmailsByUserId({ userId }) - const currentSsoEmailEntry = currentUserEmails.find( - (entry) => entry.email === ssoProviderUserInfo.email - ) + const currentUserEmails = await findEmailsByUserId({ userId }) + const currentSsoEmailEntry = currentUserEmails.find( + (entry) => entry.email === ssoProviderUserInfo.email + ) - if (!currentSsoEmailEntry) { - await createUserEmail({ - userEmail: { - userId, - email: ssoProviderUserInfo.email, - verified: true - } - }) - return - } + if (!currentSsoEmailEntry) { + await createUserEmail({ + userEmail: { + userId, + email: ssoProviderUserInfo.email, + verified: true + } + }) + return + } - if (!currentSsoEmailEntry.verified) { - await updateUserEmail({ - query: { - id: currentSsoEmailEntry.id, - userId - }, - update: { - verified: true - } - }) - return + if (!currentSsoEmailEntry.verified) { + await updateUserEmail({ + query: { + id: currentSsoEmailEntry.id, + userId + }, + update: { + verified: true + } + }) + return + } } - } From 7dae4a79d2c9fd12eb2177bebf636be13490a318 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Sun, 13 Oct 2024 22:46:06 +0100 Subject: [PATCH 18/47] fix(sso): improve catch block error messages --- .../server/modules/workspaces/rest/sso.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 4f48a75266..6f7322af09 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -92,12 +92,20 @@ const ssoVerificationStatusKey = 'ssoVerificationStatus' const buildErrorUrl = ({ err, url, - searchParams + searchParams, + isValidationFlow }: { err: unknown url: URL - searchParams?: Record + searchParams?: Record, + isValidationFlow: boolean }): URL => { + // TODO: Redirect to workspace-specific sign in page + if (!isValidationFlow) { + url.pathname = '/authn/login' + return url + } + const settingsSearch = url.searchParams.get('settings') url.searchParams.forEach((key) => { url.searchParams.delete(key) @@ -221,7 +229,8 @@ router.get( const url = buildErrorUrl({ err, url: buildFinalizeUrl(params.workspaceSlug, true), - searchParams: query + searchParams: query, + isValidationFlow: true }) res?.redirect(url.toString()) } @@ -233,7 +242,6 @@ router.get( // - add new user to workspace with role // - return new provider id on create // - `user_sso_sessions` table `lifespan` => `validUntil` -// - Better catch block error messages router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', sessionMiddleware, @@ -484,16 +492,18 @@ router.get( return next() } } catch (err) { + const warnMessage = isValidationFlow ? + `Failed to verify OIDC sso provider for workspace ${workspaceSlug}` + : `Failed to sign in to ${workspaceSlug}` logger.warn( { error: err }, - // this is only valid for the validate errors, not really for login !!!! - `Failed to verify OIDC sso provider for workspace ${workspaceSlug}` + warnMessage ) - // in case of this is a login error, we need to redirect to the login page with the error redirectUrl = buildErrorUrl({ err, url: redirectUrl, - searchParams: provider || undefined + searchParams: provider || undefined, + isValidationFlow }) res.redirect(redirectUrl.toString()) } From 355aaa7cd026e2c25fb9de607d253f13be395304 Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Mon, 14 Oct 2024 11:06:03 +0100 Subject: [PATCH 19/47] fix(sso): session lifespan => validUntil --- .../server/modules/workspaces/domain/sso.ts | 9 ++++++++- .../modules/workspaces/repositories/sso.ts | 2 +- .../server/modules/workspaces/rest/sso.ts | 11 +++-------- .../server/modules/workspaces/services/sso.ts | 19 ++++++++++++------- .../20240930141322_workspace_sso.ts | 1 - ...20241014092507_workspace_sso_expiration.ts | 18 ++++++++++++++++++ 6 files changed, 42 insertions(+), 18 deletions(-) create mode 100644 packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts diff --git a/packages/server/modules/workspaces/domain/sso.ts b/packages/server/modules/workspaces/domain/sso.ts index d716aff513..cb0f353ffa 100644 --- a/packages/server/modules/workspaces/domain/sso.ts +++ b/packages/server/modules/workspaces/domain/sso.ts @@ -41,7 +41,7 @@ export type UserSsoSession = { userId: string providerId: string createdAt: Date - lifespan: number + validUntil: Date } export type UpsertUserSsoSession = (args: { @@ -83,3 +83,10 @@ export type AssociateSsoProviderWithWorkspace = (args: { workspaceId: string providerId: string }) => Promise + +// TODO: Is one week good? +export const getDefaultSsoSessionExpirationDate = (): Date => { + const now = new Date() + now.setDate(now.getDate() + 7) + return now +} \ No newline at end of file diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index da7593938c..96d15e86ca 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -94,5 +94,5 @@ export const upsertUserSsoSessionFactory = .userSsoSessions(db) .insert(userSsoSession) .onConflict(['userId', 'providerId']) - .merge(['createdAt', 'lifespan']) + .merge(['createdAt', 'validUntil']) } diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 6f7322af09..4193e5a863 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -27,7 +27,7 @@ import { getEncryptionKeyPair } from '@/modules/automate/services/encryption' import { getGenericRedis } from '@/modules/core' import { generators } from 'openid-client' import { noop } from 'lodash' -import { OIDCProvider, oidcProvider } from '@/modules/workspaces/domain/sso' +import { getDefaultSsoSessionExpirationDate, OIDCProvider, oidcProvider } from '@/modules/workspaces/domain/sso' import { getWorkspaceBySlugFactory, getWorkspaceCollaboratorsFactory @@ -241,7 +241,6 @@ router.get( // - tryGetWorkspaceInvite // - add new user to workspace with role // - return new provider id on create -// - `user_sso_sessions` table `lifespan` => `validUntil` router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', sessionMiddleware, @@ -344,7 +343,7 @@ router.get( db, encrypt: encryptor.encrypt }), - storeUserSsoSession: upsertUserSsoSessionFactory({ db: trx }), + upsertUserSsoSession: upsertUserSsoSessionFactory({ db: trx }), createUserEmail: createUserEmailFactory({ db: trx }), updateUserEmail: updateUserEmailFactory({ db: trx }), findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }) @@ -465,16 +464,12 @@ router.get( throw new Error('Unhandled failure to find SSO provider') } - const validUntil = new Date() - validUntil.setDate(validUntil.getDate() + 7) - await upsertUserSsoSessionFactory({ db })({ userSsoSession: { userId: req.user.id, providerId, createdAt: new Date(), - // TODO: Use `validUntil` - lifespan: 100 + validUntil: getDefaultSsoSessionExpirationDate() } }) diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index 35c75079b3..dd924e5267 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -7,7 +7,8 @@ import { UpsertUserSsoSession, OIDCProviderRecord, AssociateSsoProviderWithWorkspace, - GetWorkspaceSsoProvider + GetWorkspaceSsoProvider, + getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso' import { BaseError } from '@/modules/shared/errors/base' import cryptoRandomString from 'crypto-random-string' @@ -70,7 +71,7 @@ export const saveSsoProviderRegistrationFactory = getWorkspaceSsoProvider, storeProviderRecord, associateSsoProviderWithWorkspace, - storeUserSsoSession, + upsertUserSsoSession, createUserEmail, updateUserEmail, findEmailsByUserId @@ -78,7 +79,7 @@ export const saveSsoProviderRegistrationFactory = getWorkspaceSsoProvider: GetWorkspaceSsoProvider storeProviderRecord: StoreProviderRecord associateSsoProviderWithWorkspace: AssociateSsoProviderWithWorkspace - storeUserSsoSession: UpsertUserSsoSession + upsertUserSsoSession: UpsertUserSsoSession createUserEmail: CreateUserEmail updateUserEmail: UpdateUserEmail findEmailsByUserId: FindEmailsByUserId @@ -110,11 +111,15 @@ export const saveSsoProviderRegistrationFactory = await storeProviderRecord({ providerRecord }) // associate provider with workspace await associateSsoProviderWithWorkspace({ workspaceId, providerId }) - // create and associate userSso session (how long is the default validity?) + // create and associate userSso session // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work - const lifespan = 6.048e8 // 1 week - await storeUserSsoSession({ - userSsoSession: { createdAt: new Date(), userId, providerId, lifespan } + await upsertUserSsoSession({ + userSsoSession: { + userId, + providerId, + createdAt: new Date(), + validUntil: getDefaultSsoSessionExpirationDate() + } }) const currentUserEmails = await findEmailsByUserId({ userId }) diff --git a/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts b/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts index 6143501a51..062cf99992 100644 --- a/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts +++ b/packages/server/modules/workspacesCore/migrations/20240930141322_workspace_sso.ts @@ -23,7 +23,6 @@ export async function up(knex: Knex): Promise { .onDelete('cascade') table.primary(['userId', 'providerId']) table.timestamp('createdAt', { precision: 3, useTz: true }).notNullable() - // this should be removed, a valid until field is easier to work with table.bigint('lifespan').notNullable() }) await knex.schema.createTable('workspace_sso_providers', (table) => { diff --git a/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts b/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts new file mode 100644 index 0000000000..7de48a6d55 --- /dev/null +++ b/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts @@ -0,0 +1,18 @@ +import { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('user_sso_sessions', (table) => { + table.dropColumn('lifespan') + table.timestamp('validUntil', { precision: 3, useTz: true }).defaultTo(knex.fn.now()).notNullable() + }) +} + + +export async function down(knex: Knex): Promise { + const lifespan = 6.048e8 // 1 week + await knex.schema.alterTable('user_sso_sessions', (table) => { + table.dropColumn('createdAt') + table.bigint('lifespan').defaultTo(lifespan).notNullable() + }) +} + From 0abc08402469b4690b207130594893c2b6ffff2d Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Mon, 14 Oct 2024 23:48:28 +0100 Subject: [PATCH 20/47] fix(sso): I think we've got it --- .../server/modules/workspaces/domain/logic.ts | 7 +- .../server/modules/workspaces/domain/sso.ts | 2 +- .../modules/workspaces/repositories/sso.ts | 90 +++++------ .../server/modules/workspaces/rest/sso.ts | 77 +++++----- .../server/modules/workspaces/services/sso.ts | 140 +++++++++--------- ...20241014092507_workspace_sso_expiration.ts | 9 +- 6 files changed, 172 insertions(+), 153 deletions(-) diff --git a/packages/server/modules/workspaces/domain/logic.ts b/packages/server/modules/workspaces/domain/logic.ts index 10f6abe91f..0d1032aff7 100644 --- a/packages/server/modules/workspaces/domain/logic.ts +++ b/packages/server/modules/workspaces/domain/logic.ts @@ -7,7 +7,7 @@ import { WorkspaceDefaultProjectRole, WorkspaceDomain } from '@/modules/workspacesCore/domain/types' -import { Roles } from '@speckle/shared' +import { Roles, WorkspaceRoles } from '@speckle/shared' export const userEmailsCompliantWithWorkspaceDomains = ({ userEmails, @@ -59,3 +59,8 @@ export const parseDefaultProjectRole = ( return role } + +export const isWorkspaceRole = (role: string): role is WorkspaceRoles => { + const validRoles: string[] = Object.values(Roles.Workspace) + return validRoles.includes(role) +} diff --git a/packages/server/modules/workspaces/domain/sso.ts b/packages/server/modules/workspaces/domain/sso.ts index cb0f353ffa..51b918b442 100644 --- a/packages/server/modules/workspaces/domain/sso.ts +++ b/packages/server/modules/workspaces/domain/sso.ts @@ -89,4 +89,4 @@ export const getDefaultSsoSessionExpirationDate = (): Date => { const now = new Date() now.setDate(now.getDate() + 7) return now -} \ No newline at end of file +} diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index 96d15e86ca..260c6da48f 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -35,64 +35,64 @@ export const storeOIDCProviderValidationRequestFactory = redis: Redis encrypt: Crypt }): StoreOIDCProviderValidationRequest => - async ({ provider, token }) => { - const providerData = await encrypt(JSON.stringify(provider)) - await redis.set(token, providerData) - } + 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 - } + 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( - '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') - } + async ({ workspaceId }) => { + const maybeProvider = await db( + '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) - } + 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 }) - } + async ({ providerId, workspaceId }) => { + await tables.workspaceSsoProviders(db).insert({ providerId, workspaceId }) + } export const upsertUserSsoSessionFactory = ({ db }: { db: Knex }): UpsertUserSsoSession => - async ({ userSsoSession }) => { - await tables - .userSsoSessions(db) - .insert(userSsoSession) - .onConflict(['userId', 'providerId']) - .merge(['createdAt', 'validUntil']) - } + async ({ userSsoSession }) => { + await tables + .userSsoSessions(db) + .insert(userSsoSession) + .onConflict(['userId', 'providerId']) + .merge(['createdAt', 'validUntil']) + } diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 4193e5a863..6863e1557b 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -27,10 +27,15 @@ import { getEncryptionKeyPair } from '@/modules/automate/services/encryption' import { getGenericRedis } from '@/modules/core' import { generators } from 'openid-client' import { noop } from 'lodash' -import { getDefaultSsoSessionExpirationDate, OIDCProvider, oidcProvider } from '@/modules/workspaces/domain/sso' +import { + getDefaultSsoSessionExpirationDate, + OIDCProvider, + oidcProvider +} from '@/modules/workspaces/domain/sso' import { getWorkspaceBySlugFactory, - getWorkspaceCollaboratorsFactory + getWorkspaceCollaboratorsFactory, + upsertWorkspaceRoleFactory } from '@/modules/workspaces/repositories/workspaces' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' import { authorizeResolver } from '@/modules/shared' @@ -49,6 +54,8 @@ import { sessionMiddlewareFactory } from '@/modules/auth/middleware' import { createAuthorizationCodeFactory } from '@/modules/auth/repositories/apps' +import { findInviteFactory } from '@/modules/serverinvites/repositories/serverInvites' +import { isWorkspaceRole } from '@/modules/workspaces/helpers/roles' const router = Router() @@ -97,7 +104,7 @@ const buildErrorUrl = ({ }: { err: unknown url: URL - searchParams?: Record, + searchParams?: Record isValidationFlow: boolean }): URL => { // TODO: Redirect to workspace-specific sign in page @@ -237,10 +244,6 @@ router.get( } ) -// TODO: -// - tryGetWorkspaceInvite -// - add new user to workspace with role -// - return new provider id on create router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', sessionMiddleware, @@ -348,7 +351,7 @@ router.get( updateUserEmail: updateUserEmailFactory({ db: trx }), findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }) }) - await withTransaction( + providerId = await withTransaction( saveSsoProviderRegistration({ provider, userId, @@ -382,23 +385,20 @@ router.get( // User is not signed in, and no Speckle user is associated with SSO user // Check if user has email-based invite to given workspace - - // let invite - // if (!req.inviteToken) { - // try to get an invite from the db, based on the oidc user info email - // -> invite - // } else { - // get the invite from the db based on the invite token - // -> invite - //} - // if (invite) { - // make sure, the invite is an invite to the current workspace and it doesn't target a user, - // the target must be, the same email, - // that comes back from the oidc provider - // use invite if its not part of the finalize flow?! - //} else { - // GO AWAY!!!! - //} + const invite = await findInviteFactory({ db })({ + token: req.context.token, // TODO: Is this the invite token? + target: ssoProviderUserInfo.email, + resourceFilter: { + resourceId: workspace.id, // TODO: Are invites still id-based? + resourceType: 'workspace' + } + }) + + if (!invite) { + throw new Error( + 'Cannot sign up with SSO without a valid workspace invite.' + ) + } // Create Speckle user const { name, email, email_verified } = ssoProviderUserInfo @@ -415,6 +415,16 @@ router.get( const newSpeckleUserId = await createUser(newSpeckleUser) // Add user to workspace with role specified in invite + const { role: workspaceRole } = invite.resource + + if (!isWorkspaceRole(workspaceRole)) throw new Error('Invalid role') + + await upsertWorkspaceRoleFactory({ db })({ + userId: newSpeckleUserId, + workspaceId: workspace.id, + role: workspaceRole, + createdAt: new Date() + }) // Assert sign in req.user = { @@ -439,7 +449,9 @@ router.get( // User is already signed in, and there is already a Speckle user associated with the SSO user // Verify session user id matches existing user id if (currentSessionUser.id !== existingSpeckleUser.id) { - throw new Error('SSO user already associated with another Speckle account') + throw new Error( + 'SSO user already associated with another Speckle account' + ) } } } @@ -456,7 +468,9 @@ router.get( } if (!workspaceRoles.some((role) => role.id === req.user?.id)) { - throw new Error('User is not a member of the given workspace and cannot sign in with SSO') + throw new Error( + 'User is not a member of the given workspace and cannot sign in with SSO' + ) } // Update validUntil for SSO session @@ -487,13 +501,10 @@ router.get( return next() } } catch (err) { - const warnMessage = isValidationFlow ? - `Failed to verify OIDC sso provider for workspace ${workspaceSlug}` + const warnMessage = isValidationFlow + ? `Failed to verify OIDC sso provider for workspace ${workspaceSlug}` : `Failed to sign in to ${workspaceSlug}` - logger.warn( - { error: err }, - warnMessage - ) + logger.warn({ error: err }, warnMessage) redirectUrl = buildErrorUrl({ err, url: redirectUrl, diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index dd924e5267..04808d1c13 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -55,16 +55,16 @@ export const startOIDCSsoProviderValidationFactory = storeOIDCProviderValidationRequest: StoreOIDCProviderValidationRequest generateCodeVerifier: () => string }) => - async ({ provider }: { provider: OIDCProvider }): Promise => { - // get client information - const providerAttributes = await getOIDCProviderAttributes({ provider }) - // validate issuer and client data - validateOIDCProviderAttributes(providerAttributes) - // store provider validation with an id token - const codeVerifier = generateCodeVerifier() - await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) - return codeVerifier - } + async ({ provider }: { provider: OIDCProvider }): Promise => { + // get client information + const providerAttributes = await getOIDCProviderAttributes({ provider }) + // validate issuer and client data + validateOIDCProviderAttributes(providerAttributes) + // store provider validation with an id token + const codeVerifier = generateCodeVerifier() + await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) + return codeVerifier + } export const saveSsoProviderRegistrationFactory = ({ @@ -84,70 +84,72 @@ export const saveSsoProviderRegistrationFactory = updateUserEmail: UpdateUserEmail findEmailsByUserId: FindEmailsByUserId }) => - async ({ + async ({ + provider, + workspaceId, + userId, + ssoProviderUserInfo + }: { + provider: OIDCProvider + userId: string + workspaceId: string + ssoProviderUserInfo: { email: string } + }): Promise => { + // create OIDC provider record with ID + const providerId = cryptoRandomString({ length: 10 }) + const providerRecord: OIDCProviderRecord = { provider, - workspaceId, - userId, - ssoProviderUserInfo - }: { - provider: OIDCProvider - userId: string - workspaceId: string - ssoProviderUserInfo: { email: string } - }) => { - // create OIDC provider record with ID - const providerId = cryptoRandomString({ length: 10 }) - const providerRecord: OIDCProviderRecord = { - provider, - providerType: 'oidc', + providerType: 'oidc', + createdAt: new Date(), + updatedAt: new Date(), + id: providerId + } + const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) + // replace with a proper error + if (maybeExistingSsoProvider) + throw new Error('Workspace already has an SSO provider') + await storeProviderRecord({ providerRecord }) + // associate provider with workspace + await associateSsoProviderWithWorkspace({ workspaceId, providerId }) + // create and associate userSso session + // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work + await upsertUserSsoSession({ + userSsoSession: { + userId, + providerId, createdAt: new Date(), - updatedAt: new Date(), - id: providerId + validUntil: getDefaultSsoSessionExpirationDate() } - const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) - // replace with a proper error - if (maybeExistingSsoProvider) - throw new Error('Workspace already has an SSO provider') - await storeProviderRecord({ providerRecord }) - // associate provider with workspace - await associateSsoProviderWithWorkspace({ workspaceId, providerId }) - // create and associate userSso session - // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work - await upsertUserSsoSession({ - userSsoSession: { + }) + + const currentUserEmails = await findEmailsByUserId({ userId }) + const currentSsoEmailEntry = currentUserEmails.find( + (entry) => entry.email === ssoProviderUserInfo.email + ) + + if (!currentSsoEmailEntry) { + await createUserEmail({ + userEmail: { userId, - providerId, - createdAt: new Date(), - validUntil: getDefaultSsoSessionExpirationDate() + email: ssoProviderUserInfo.email, + verified: true } }) + return providerId + } - const currentUserEmails = await findEmailsByUserId({ userId }) - const currentSsoEmailEntry = currentUserEmails.find( - (entry) => entry.email === ssoProviderUserInfo.email - ) - - if (!currentSsoEmailEntry) { - await createUserEmail({ - userEmail: { - userId, - email: ssoProviderUserInfo.email, - verified: true - } - }) - return - } - - if (!currentSsoEmailEntry.verified) { - await updateUserEmail({ - query: { - id: currentSsoEmailEntry.id, - userId - }, - update: { - verified: true - } - }) - return - } + if (!currentSsoEmailEntry.verified) { + await updateUserEmail({ + query: { + id: currentSsoEmailEntry.id, + userId + }, + update: { + verified: true + } + }) + return providerId } + + return providerId + } diff --git a/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts b/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts index 7de48a6d55..fa0a6352a3 100644 --- a/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts +++ b/packages/server/modules/workspacesCore/migrations/20241014092507_workspace_sso_expiration.ts @@ -1,13 +1,15 @@ -import { Knex } from "knex"; +import { Knex } from 'knex' export async function up(knex: Knex): Promise { await knex.schema.alterTable('user_sso_sessions', (table) => { table.dropColumn('lifespan') - table.timestamp('validUntil', { precision: 3, useTz: true }).defaultTo(knex.fn.now()).notNullable() + table + .timestamp('validUntil', { precision: 3, useTz: true }) + .defaultTo(knex.fn.now()) + .notNullable() }) } - export async function down(knex: Knex): Promise { const lifespan = 6.048e8 // 1 week await knex.schema.alterTable('user_sso_sessions', (table) => { @@ -15,4 +17,3 @@ export async function down(knex: Knex): Promise { table.bigint('lifespan').defaultTo(lifespan).notNullable() }) } - From 601128021319362185c5c1f63baa8d0a57591324 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Thu, 17 Oct 2024 00:51:18 +0200 Subject: [PATCH 21/47] feat(sso): limited workspace values for public sso login --- .../server/modules/workspaces/rest/sso.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 3288a9d76e..ecd823f8de 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -82,6 +82,50 @@ const buildErrorUrl = ({ return url } +router.get( + '/api/v1/workspaces/:workspaceSlug/sso', + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }) + }), + async (req) => { + const { workspaceSlug } = req.params + + const workspace = await getWorkspaceBySlugFactory({ db })({ + workspaceSlug + }) + + if (!workspace) { + throw new Error() + } + + const { encryptedProviderData } = await db('workspace_sso_providers') + .select('*') + .where('workspaceId', workspace.id) + .join('sso_providers', 'id', 'providerId') + .first() + + if (!encryptedProviderData) { + throw new Error() + } + + const encryptionKeyPair = await getEncryptionKeyPair() + const decryptor = await buildDecryptor(encryptionKeyPair) + const providerJson = await decryptor.decrypt(encryptedProviderData) + const { providerName } = JSON.parse(providerJson) + + const limitedWorkspace = { + name: workspace.name, + logo: workspace.logo, + defaultLogoIndex: workspace.defaultLogoIndex, + ssoProviderName: providerName + } + + req.res?.json(limitedWorkspace) + } +) + router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', sessionMiddleware, From 2fc11a7918dfc24776e30c5cfc6f4d9f100867cf Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Sun, 20 Oct 2024 19:24:44 +0100 Subject: [PATCH 22/47] fix(sso): use factory functions --- .../modules/workspaces/repositories/sso.ts | 16 ++++++----- .../server/modules/workspaces/rest/sso.ts | 27 +++++++------------ 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index 8511e4a210..f9b06dc324 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -54,18 +54,22 @@ export const getOIDCProviderFactory = export const getWorkspaceSsoProviderFactory = ({ db, decrypt }: { db: Knex; decrypt: Crypt }): GetWorkspaceSsoProvider => async ({ workspaceId }) => { - const maybeProvider = await db( - 'workspace_sso_providers' - ) + const maybeProvider = await tables + .workspaceSsoProviders(db) + .select('*') .where({ workspaceId }) + .join('sso_providers', 'id', 'providerId') .first() if (!maybeProvider) return null - const decryptedProviderData = await decrypt(maybeProvider.encryptedProviderData) + + const providerDataString = await decrypt(maybeProvider.encryptedProviderData) + const providerData = JSON.parse(providerDataString) + switch (maybeProvider.providerType) { case 'oidc': return { - ...omit(maybeProvider), - provider: oidcProvider.parse(decryptedProviderData) + ...omit(maybeProvider, ['encryptedProviderData']), + provider: oidcProvider.parse(providerData) } default: // this is an internal error diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index ecd823f8de..c0bb11b3d7 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -89,8 +89,8 @@ router.get( workspaceSlug: z.string().min(1) }) }), - async (req) => { - const { workspaceSlug } = req.params + async ({ params, res }) => { + const { workspaceSlug } = params const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug @@ -100,29 +100,22 @@ router.get( throw new Error() } - const { encryptedProviderData } = await db('workspace_sso_providers') - .select('*') - .where('workspaceId', workspace.id) - .join('sso_providers', 'id', 'providerId') - .first() - - if (!encryptedProviderData) { - throw new Error() - } - const encryptionKeyPair = await getEncryptionKeyPair() - const decryptor = await buildDecryptor(encryptionKeyPair) - const providerJson = await decryptor.decrypt(encryptedProviderData) - const { providerName } = JSON.parse(providerJson) + const { decrypt, dispose } = await buildDecryptor(encryptionKeyPair) + + const providerData = await getWorkspaceSsoProviderFactory({ db, decrypt })({ + workspaceId: workspace.id + }) const limitedWorkspace = { name: workspace.name, logo: workspace.logo, defaultLogoIndex: workspace.defaultLogoIndex, - ssoProviderName: providerName + ssoProviderName: providerData?.provider?.providerName } - req.res?.json(limitedWorkspace) + dispose() + res?.json(limitedWorkspace) } ) From 48814f910e78e65bbd9da179396fc2dcd68aa8f9 Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Sun, 20 Oct 2024 23:45:28 +0100 Subject: [PATCH 23/47] fix(sso): til decrypt is single-use --- .../pages/workspaces/[slug]/authn/index.vue | 36 ++++++++++ .../modules/workspaces/repositories/sso.ts | 5 +- .../server/modules/workspaces/rest/sso.ts | 69 ++++++++++--------- 3 files changed, 73 insertions(+), 37 deletions(-) create mode 100644 packages/frontend-2/pages/workspaces/[slug]/authn/index.vue diff --git a/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue b/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue new file mode 100644 index 0000000000..46ed22ab58 --- /dev/null +++ b/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index b089f67d57..fc25846881 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -45,9 +45,8 @@ export const getOIDCProviderFactory = async ({ validationToken }: { validationToken: string }) => { const encryptedProviderData = await redis.get(validationToken) if (!encryptedProviderData) return null - const provider = oidcProvider.parse( - JSON.parse(await decrypt(encryptedProviderData)) - ) + const providerDataString = await decrypt(encryptedProviderData) + const provider = oidcProvider.parse(JSON.parse(providerDataString)) return provider } diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index c39d7abfaa..ef777b03ec 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -56,17 +56,15 @@ import { } from '@/modules/core/repositories/users' import { UserRecord } from '@/modules/core/helpers/userHelper' import { - finalizeAuthMiddlewareFactory, + moveAuthParamsToSessionMiddlewareFactory, sessionMiddlewareFactory } from '@/modules/auth/middleware' -import { createAuthorizationCodeFactory } from '@/modules/auth/repositories/apps' import { deleteServerOnlyInvitesFactory, findInviteFactory, updateAllInviteTargetsFactory } from '@/modules/serverinvites/repositories/serverInvites' import { isWorkspaceRole } from '@/modules/workspaces/helpers/roles' -import { legacyGetUserFactory } from '@/modules/core/repositories/users' import { createUserFactory } from '@/modules/core/services/users/management' import { getServerInfoFactory } from '@/modules/core/repositories/server' import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails' @@ -79,11 +77,12 @@ import { renderEmail } from '@/modules/emails/services/emailRendering' const router = Router() +const moveAuthParamsToSessionMiddleware = moveAuthParamsToSessionMiddlewareFactory() const sessionMiddleware = sessionMiddlewareFactory() -const finalizeAuthMiddleware = finalizeAuthMiddlewareFactory({ - createAuthorizationCode: createAuthorizationCodeFactory({ db }), - getUser: legacyGetUserFactory({ db }) -}) +// const finalizeAuthMiddleware = finalizeAuthMiddlewareFactory({ +// createAuthorizationCode: createAuthorizationCodeFactory({ db }), +// getUser: legacyGetUserFactory({ db }) +// }) /** * Generate redirect url used for final step of OIDC flow @@ -169,7 +168,7 @@ router.get( } const encryptionKeyPair = await getEncryptionKeyPair() - const { decrypt, dispose } = await buildDecryptor(encryptionKeyPair) + const { decrypt } = await buildDecryptor(encryptionKeyPair) const providerData = await getWorkspaceSsoProviderFactory({ db, decrypt })({ workspaceId: workspace.id @@ -182,7 +181,6 @@ router.get( ssoProviderName: providerData?.provider?.providerName } - dispose() res?.json(limitedWorkspace) } ) @@ -191,16 +189,16 @@ router.get( router.get( '/api/v1/workspaces/:workspaceSlug/sso/auth', sessionMiddleware, + moveAuthParamsToSessionMiddleware, validateRequest({ params: z.object({ workspaceSlug: z.string().min(1) - }), - query: oidcProvider + }) }), async ({ params, session, res }) => { const { workspaceSlug } = params const encryptionKeyPair = await getEncryptionKeyPair() - const decryptor = await buildDecryptor(encryptionKeyPair) + const { decrypt } = await buildDecryptor(encryptionKeyPair) try { const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug @@ -209,14 +207,13 @@ router.get( const providerMetadata = await getWorkspaceSsoProviderFactory({ db, - decrypt: decryptor.decrypt - })({ workspaceId: params.workspaceSlug }) + decrypt + })({ workspaceId: workspace.id }) if (!providerMetadata) throw new Error('No SSO provider registered for the workspace') // Redirect to OIDC provider to continue auth flow const { provider } = providerMetadata - const encryptionKeyPair = await getEncryptionKeyPair() const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) const codeVerifier = await startOIDCSsoProviderValidationFactory({ getOIDCProviderAttributes, @@ -237,6 +234,7 @@ router.get( session.codeVerifier = await encryptor.encrypt(codeVerifier) res?.redirect(authorizationUrl.toString()) } catch (err) { + console.error(err) // if things fail, before sending you to the provider, we need to tell it to the user in a nice way } } @@ -308,13 +306,14 @@ router.get( router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', sessionMiddleware, + moveAuthParamsToSessionMiddleware, validateRequest({ params: z.object({ workspaceSlug: z.string().min(1) }), - query: z.object({ validate: z.string() }) + query: z.object({ validate: z.string().optional() }) }), - async (req, res, next) => { + async (req, res) => { const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) const workspaceSlug = req.params.workspaceSlug @@ -325,24 +324,30 @@ router.get( let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug, isValidationFlow) // TODO: Billing check - verify workspace has SSO enabled + const workspace = await getWorkspaceBySlugFactory({ db })({ + workspaceSlug: req.params.workspaceSlug + }) + if (!workspace) throw new WorkspaceNotFoundError() try { // Initialize OIDC client based on provider for current request flow const encryptionKeyPair = await getEncryptionKeyPair() const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - const decryptor = await buildDecryptor(encryptionKeyPair) + const { decrypt: decryptCodeVerifier } = await buildDecryptor(encryptionKeyPair) const encryptedCodeVerifier = req.session.codeVerifier if (!encryptedCodeVerifier) throw new Error('cannot find verification token, restart the flow') - const codeVerifier = await decryptor.decrypt(encryptedCodeVerifier) + const codeVerifier = await decryptCodeVerifier(encryptedCodeVerifier) if (isValidationFlow) { // Get provider configuration from redis + const { decrypt: decryptOIDCProvider } = await buildDecryptor(encryptionKeyPair) + provider = await getOIDCProviderFactory({ redis: getGenericRedis(), - decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt + decrypt: decryptOIDCProvider })({ validationToken: codeVerifier }) @@ -350,10 +355,12 @@ router.get( if (!provider) throw new Error('validation request not found, please retry') } else { // Get stored provider configuration + const { decrypt: decryptSsoProvider } = await buildDecryptor(encryptionKeyPair) + const providerMetadata = await getWorkspaceSsoProviderFactory({ db, - decrypt: decryptor.decrypt - })({ workspaceId: workspaceSlug }) + decrypt: decryptSsoProvider + })({ workspaceId: workspace.id }) if (!providerMetadata?.provider) throw new Error('Could not find SSO provider') @@ -374,12 +381,6 @@ router.get( if (!ssoProviderUserInfo || !ssoProviderUserInfo.email) throw new Error('This should never happen, we are asking for an email claim') - // Get information about the workspace we are signing in to - const workspace = await getWorkspaceBySlugFactory({ db })({ - workspaceSlug: req.params.workspaceSlug - }) - if (!workspace) throw new WorkspaceNotFoundError() - if (isValidationFlow) { // OIDC configuration verification flow: the user is attempting to configure SSO for their workspace @@ -394,11 +395,13 @@ router.get( // Write SSO configuration const trx = await db.transaction() - // TODO: Return new provider record and store id + const { decrypt: decryptExistingSsoProvider } = await buildDecryptor( + encryptionKeyPair + ) const saveSsoProviderRegistration = saveSsoProviderRegistrationFactory({ getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ db: trx, - decrypt: decryptor.decrypt + decrypt: decryptExistingSsoProvider }), associateSsoProviderWithWorkspace: associateSsoProviderWithWorkspaceFactory({ db: trx @@ -586,9 +589,8 @@ router.get( `?settings=workspace/security&workspace=${workspaceSlug}` ) } - req.authRedirectPath = redirectUrlFragments.join() - return next() + res.redirect(buildFinalizeUrl(workspaceSlug, false).toString()) } } catch (err) { const warnMessage = isValidationFlow @@ -603,8 +605,7 @@ router.get( }) res.redirect(redirectUrl.toString()) } - }, - finalizeAuthMiddleware + } ) export default router From 55c505d4de5df61b4570791a425ccdc5bf44a229 Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Mon, 21 Oct 2024 12:12:53 +0100 Subject: [PATCH 24/47] fix(sso): correct usage of access codes --- .../frontend-2/lib/auth/composables/auth.ts | 19 ++++++++++ .../pages/workspaces/[slug]/authn/index.vue | 26 +++++++------ .../server/modules/workspaces/rest/sso.ts | 37 ++++++++++++++----- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/packages/frontend-2/lib/auth/composables/auth.ts b/packages/frontend-2/lib/auth/composables/auth.ts index f6aeac7a64..91ac778e2b 100644 --- a/packages/frontend-2/lib/auth/composables/auth.ts +++ b/packages/frontend-2/lib/auth/composables/auth.ts @@ -382,6 +382,24 @@ export const useAuthManager = ( goHome({ query: { access_code: accessCode } }) } + /** + * Initiate SSO flow. Will create a user if one does not already exist. + */ + const signInOrSignUpWithSso = (params: { + challenge: string + workspaceSlug: string + }) => { + postAuthRedirect.set(`/workspaces/${params.workspaceSlug}`) + + const authUrl = new URL( + `/api/v1/workspaces/${params.workspaceSlug}/sso/auth`, + apiOrigin + ) + authUrl.searchParams.set('challenge', params.challenge) + + navigateTo(authUrl.toString(), { external: true }) + } + /** * Log out */ @@ -425,6 +443,7 @@ export const useAuthManager = ( authToken, loginWithEmail, signUpWithEmail, + signInOrSignUpWithSso, logout, watchAuthQueryString, inviteToken diff --git a/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue b/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue index 46ed22ab58..9271687045 100644 --- a/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue +++ b/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue @@ -1,28 +1,30 @@ diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index 01ca3123be..eed1e8ef00 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -57,7 +57,7 @@ export const getWorkspaceSsoProviderFactory = async ({ workspaceId }) => { const maybeProvider = await tables .workspaceSsoProviders(db) - .select(['workspaceId', 'providerId', 'encryptedProviderData']) + .select(['workspaceId', 'providerId', 'providerType', 'encryptedProviderData']) .where({ workspaceId }) .join('sso_providers', 'id', 'providerId') .first() diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index 144f51f968..fc25a9df3f 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -2,7 +2,7 @@ import { db } from '@/db/knex' import { validateRequest } from 'zod-express' -import { Request, Router } from 'express' +import { Request, RequestHandler, Router } from 'express' import { z } from 'zod' import { saveSsoProviderRegistrationFactory, @@ -77,6 +77,8 @@ import { renderEmail } from '@/modules/emails/services/emailRendering' import { createAuthorizationCodeFactory } from '@/modules/auth/repositories/apps' import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic' import { WorkspaceWithOptionalRole } from '@/modules/workspacesCore/domain/types' +import { GetWorkspaceBySlug } from '@/modules/workspaces/domain/operations' +import { GetWorkspaceSsoProvider } from '@/modules/workspaces/domain/sso/operations' const router = Router() @@ -147,102 +149,48 @@ const buildErrorUrl = ({ return url } -type AuthAction = { - action: 'validate' | 'sign-in' - user: UserWithOptionalRole - workspace: WorkspaceWithOptionalRole -} | { - action: 'sign-up' - user: Pick - workspace: WorkspaceWithOptionalRole -} - -type AuthRequest = Request<{ workspaceSlug: string }, never, never, { validate?: string }> - -const parseAuthAction = async (req: AuthRequest): Promise => { - const isValidationFlow = req.query.validate === 'true' - - const workspace = await getWorkspaceBySlugFactory({ - db - })({ - workspaceSlug: req.params.workspaceSlug - }) - if (!workspace) throw new WorkspaceNotFoundError() - - // Initialize OIDC client - // TODO: Initialize SSO client by type when multiple types are supported - const encryptionKeyPair = await getEncryptionKeyPair() - - const decryptor = await buildDecryptor(encryptionKeyPair) - const encryptedCodeVerifier = req.session.codeVerifier +const decryptorFactory = () => + async (data: string) => { + const encryptionKeyPair = await getEncryptionKeyPair() + const decryptor = await buildDecryptor(encryptionKeyPair) + const decryptedData = await decryptor.decrypt(data) - if (!encryptedCodeVerifier) { - throw new Error('Cannot find verification token. Restart SSO flow.') + return decryptedData } - const codeVerifier = await decryptor.decrypt(encryptedCodeVerifier) - - // Fetch OIDC provider information - const decryptedProvider: (Partial & Pick) | null = isValidationFlow - // If validating a new configuration, this is stored temporarily in redis - ? { - id: '', - provider: await getOIDCProviderValidationRequestFactory({ - redis: getGenericRedis(), - decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt - })({ - validationToken: codeVerifier - }) ?? undefined - } - // Otherwise, use the provider information stored in the db - : await getWorkspaceSsoProviderFactory({ - db, - decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt - })({ - workspaceId: workspace.id - }) +const workspaceSsoAuthRequestParams = z.object({ + workspaceSlug: z.string().min(1) +}) - if (!decryptedProvider || !decryptedProvider?.provider) { - throw new Error('Failed to find SSO provider. Restart flow.') - } +type WorkspaceSsoAuthRequestParams = z.infer - // Get user profile from SSO provider - const { client } = await initializeIssuerAndClient({ provider: decryptedProvider.provider }) - const callbackParams = client.callbackParams(req) - const tokenSet = await client.callback( - buildAuthRedirectUrl(workspace.slug, isValidationFlow).toString(), - callbackParams, - { code_verifier: codeVerifier } - ) - const ssoUserProfile = await client.userinfo(tokenSet) - - if (!ssoUserProfile || !ssoUserProfile.name || !ssoUserProfile.email) { - throw new Error('SSO user profile does not conform to Speckle requirements.') - } +/** + * Fetch public information about the workspace, including SSO provider metadata + */ +const handleGetLimitedWorkspaceRequestFactory = + ({ + getWorkspaceBySlug, + getWorkspaceSsoProvider + }: { + getWorkspaceBySlug: GetWorkspaceBySlug, + getWorkspaceSsoProvider: GetWorkspaceSsoProvider + }): RequestHandler => + async ({ params, res }) => { + const workspace = await getWorkspaceBySlug({ workspaceSlug: params.workspaceSlug }) + if (!workspace) throw new WorkspaceNotFoundError() + + const ssoProviderData = await getWorkspaceSsoProvider({ workspaceId: workspace.id }) + + const limitedWorkspace = { + name: workspace.name, + logo: workspace.logo, + defaultLogoIndex: workspace.defaultLogoIndex, + ssoProviderName: ssoProviderData?.provider?.providerName + } - // Find Speckle user profile with email that matches SSO user profile - const existingSpeckleUserEmail = await findEmailFactory({ - db - })({ - email: ssoUserProfile.email - }) - const existingSpeckleUser = await getUserFactory({ - db - })( - existingSpeckleUserEmail?.userId ?? '' - ) - - // Find Speckle user profile for signed in user that initiated this SSO flow - const currentSessionUser = await getUserFactory({ - db - })( - req.context.userId ?? '' - ) - - // Determine auth action and validate conditions for each action type -} + res?.json(limitedWorkspace) + } -/** GET Public information about the workspace, including SSO provider metadata */ router.get( '/api/v1/workspaces/:workspaceSlug/sso', validateRequest({ @@ -250,33 +198,13 @@ router.get( workspaceSlug: z.string().min(1) }) }), - async ({ params, res }) => { - const { workspaceSlug } = params - - const workspace = await getWorkspaceBySlugFactory({ db })({ - workspaceSlug - }) - - if (!workspace) { - throw new Error() - } - - const encryptionKeyPair = await getEncryptionKeyPair() - const { decrypt } = await buildDecryptor(encryptionKeyPair) - - const providerData = await getWorkspaceSsoProviderFactory({ db, decrypt })({ - workspaceId: workspace.id + handleGetLimitedWorkspaceRequestFactory({ + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }), + getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ + db, + decrypt: decryptorFactory() }) - - const limitedWorkspace = { - name: workspace.name, - logo: workspace.logo, - defaultLogoIndex: workspace.defaultLogoIndex, - ssoProviderName: providerData?.provider?.providerName - } - - res?.json(limitedWorkspace) - } + }) ) /** Begin SSO sign-in or sign-up flow */ @@ -346,43 +274,6 @@ router.get( ) /** Begin SSO configuration flow */ -router.get( - '/api/v1/workspaces/:workspaceSlug/sso', - validateRequest({ - params: z.object({ - workspaceSlug: z.string().min(1) - }) - }), - async ({ params, res }) => { - const { workspaceSlug } = params - - const workspace = await getWorkspaceBySlugFactory({ db })({ - workspaceSlug - }) - - if (!workspace) { - throw new Error() - } - - const encryptionKeyPair = await getEncryptionKeyPair() - const { decrypt, dispose } = await buildDecryptor(encryptionKeyPair) - - const providerData = await getWorkspaceSsoProviderFactory({ db, decrypt })({ - workspaceId: workspace.id - }) - - const limitedWorkspace = { - name: workspace.name, - logo: workspace.logo, - defaultLogoIndex: workspace.defaultLogoIndex, - ssoProviderName: providerData?.provider?.providerName - } - - dispose() - res?.json(limitedWorkspace) - } -) - router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', sessionMiddleware, @@ -459,11 +350,11 @@ router.get( // NOTE: If req.context.userId is defined, there is a user signed in // const decryptedOidcProvider = req.query.validate === 'true' - // ? await createOidcProvider(req) + // ? await createOidcProvider(req) // assert signed in // : await getOidcProvider(req) // const oidcProviderUserData = await getOidcProviderUserData(req, decryptedOidcProvider) - // const speckleUserData = await tryGetSpeckleUserData(req, oidcProviderUserData) + // const speckleUserData = await tryGetSpeckleUserData(req, oidcProviderUserData) // assert existing email match is verified, assert ids match if both present // if (!speckleUserData) { // const newSpeckleUser = await createWorkspaceUserFromSsoProfile({ @@ -836,3 +727,99 @@ router.get( ) export default router + +// type AuthAction = { +// action: 'validate' | 'sign-in' +// user: UserWithOptionalRole +// workspace: WorkspaceWithOptionalRole +// } | { +// action: 'sign-up' +// user: Pick +// workspace: WorkspaceWithOptionalRole +// } + +// type AuthRequest = Request<{ workspaceSlug: string }, never, never, { validate?: string }> + +// const parseAuthAction = async (req: AuthRequest): Promise => { +// const isValidationFlow = req.query.validate === 'true' + +// const workspace = await getWorkspaceBySlugFactory({ +// db +// })({ +// workspaceSlug: req.params.workspaceSlug +// }) +// if (!workspace) throw new WorkspaceNotFoundError() + +// // Initialize OIDC client +// // TODO: Initialize SSO client by type when multiple types are supported +// const encryptionKeyPair = await getEncryptionKeyPair() + +// const decryptor = await buildDecryptor(encryptionKeyPair) +// const encryptedCodeVerifier = req.session.codeVerifier + +// if (!encryptedCodeVerifier) { +// throw new Error('Cannot find verification token. Restart SSO flow.') +// } + +// const codeVerifier = await decryptor.decrypt(encryptedCodeVerifier) + +// // Fetch OIDC provider information +// const decryptedProvider: (Partial & Pick) | null = isValidationFlow +// // If validating a new configuration, this is stored temporarily in redis +// ? { +// id: '', +// provider: await getOIDCProviderValidationRequestFactory({ +// redis: getGenericRedis(), +// decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt +// })({ +// validationToken: codeVerifier +// }) ?? undefined +// } +// // Otherwise, use the provider information stored in the db +// : await getWorkspaceSsoProviderFactory({ +// db, +// decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt +// })({ +// workspaceId: workspace.id +// }) + +// if (!decryptedProvider || !decryptedProvider?.provider) { +// throw new Error('Failed to find SSO provider. Restart flow.') +// } + +// // Get user profile from SSO provider +// const { client } = await initializeIssuerAndClient({ provider: decryptedProvider.provider }) +// const callbackParams = client.callbackParams(req) +// const tokenSet = await client.callback( +// buildAuthRedirectUrl(workspace.slug, isValidationFlow).toString(), +// callbackParams, +// { code_verifier: codeVerifier } +// ) +// const ssoUserProfile = await client.userinfo(tokenSet) + +// if (!ssoUserProfile || !ssoUserProfile.name || !ssoUserProfile.email) { +// throw new Error('SSO user profile does not conform to Speckle requirements.') +// } + +// // Find Speckle user profile with email that matches SSO user profile +// const existingSpeckleUserEmail = await findEmailFactory({ +// db +// })({ +// email: ssoUserProfile.email +// }) +// const existingSpeckleUser = await getUserFactory({ +// db +// })( +// existingSpeckleUserEmail?.userId ?? '' +// ) + +// // Find Speckle user profile for signed in user that initiated this SSO flow +// const currentSessionUser = await getUserFactory({ +// db +// })( +// req.context.userId ?? '' +// ) + +// // Determine auth action and validate conditions for each action type +// return {} as any +// } \ No newline at end of file From 5e296fcbd74cf525c90b99ea06703804731c3598 Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Wed, 23 Oct 2024 17:55:55 +0100 Subject: [PATCH 30/47] fix(sso): on to final boss of factories --- .../workspaces/clients/oidcProvider.ts | 4 + .../modules/workspaces/repositories/sso.ts | 4 +- .../server/modules/workspaces/rest/sso.ts | 413 +++++++----------- .../server/modules/workspaces/services/sso.ts | 146 ++++--- 4 files changed, 236 insertions(+), 331 deletions(-) diff --git a/packages/server/modules/workspaces/clients/oidcProvider.ts b/packages/server/modules/workspaces/clients/oidcProvider.ts index 98b9d6311d..9bf9aa36b9 100644 --- a/packages/server/modules/workspaces/clients/oidcProvider.ts +++ b/packages/server/modules/workspaces/clients/oidcProvider.ts @@ -6,6 +6,10 @@ import { } from '@/modules/workspaces/domain/sso/types' import { generators, Issuer, type Client } from 'openid-client' +/** + * Generate the url used to direct users to the SSO provider for authorization. + * (i.e. the sign in form page for the given SSO provider) + */ export const getProviderAuthorizationUrl = async ({ provider, redirectUrl, diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index eed1e8ef00..62a35309ac 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -34,12 +34,12 @@ export const storeOIDCProviderValidationRequestFactory = redis, encrypt }: { - redis: Redis + redis: () => Redis encrypt: Crypt }): StoreOIDCProviderValidationRequest => async ({ provider, token }) => { const providerData = await encrypt(JSON.stringify(provider)) - await redis.set(token, providerData) + await redis().set(token, providerData) } export const getOIDCProviderValidationRequestFactory = diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index fc25a9df3f..b72fa63a5c 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -90,7 +90,8 @@ const finalizeAuthMiddleware = finalizeAuthMiddlewareFactory({ }) /** - * Generate redirect url used for final step of OIDC flow + * Generate Speckle URL to redirect users to after they complete authorization + * with the given SSO provider. */ const buildAuthRedirectUrl = ( workspaceSlug: string, @@ -106,7 +107,9 @@ const buildAuthRedirectUrl = ( } /** - * Generate default final redirect url if request is successful + * Generate Speckle URL to redirect users to after successfully completing the + * SSO authorization flow. + * @remarks Append params to this URL to preserve information about errors */ const buildFinalizeUrl = (workspaceSlug: string): URL => { const urlFragments = [`workspaces/${workspaceSlug}/authn`] @@ -116,45 +119,65 @@ const buildFinalizeUrl = (workspaceSlug: string): URL => { const ssoVerificationStatusKey = 'ssoVerificationStatus' -const buildErrorUrl = ({ - err, - url, - searchParams, - isValidationFlow -}: { - err: unknown - url: URL - searchParams?: Record - isValidationFlow: boolean -}): URL => { - // TODO: Redirect to workspace-specific sign in page - if (!isValidationFlow) { - url.pathname = '/authn/login' - return url - } +// const buildErrorUrl = ({ +// err, +// url, +// searchParams, +// isValidationFlow +// }: { +// err: unknown +// url: URL +// searchParams?: Record +// isValidationFlow: boolean +// }): URL => { +// // TODO: Redirect to workspace-specific sign in page +// if (!isValidationFlow) { +// url.pathname = '/authn/login' +// return url +// } - const settingsSearch = url.searchParams.get('settings') - url.searchParams.forEach((key) => { - url.searchParams.delete(key) - }) - if (settingsSearch) url.searchParams.set('settings', settingsSearch) - url.searchParams.set(ssoVerificationStatusKey, 'failed') - const errorMessage = err instanceof Error ? err.message : `Unknown error ${err}` - url.searchParams.set('ssoVerificationError', errorMessage) - if (searchParams) { - for (const [name, value] of Object.values(searchParams)) { - url.searchParams.set(name, value) - } - } - return url +// const settingsSearch = url.searchParams.get('settings') +// url.searchParams.forEach((key) => { +// url.searchParams.delete(key) +// }) +// if (settingsSearch) url.searchParams.set('settings', settingsSearch) +// url.searchParams.set(ssoVerificationStatusKey, 'failed') +// const errorMessage = err instanceof Error ? err.message : `Unknown error ${err}` +// url.searchParams.set('ssoVerificationError', errorMessage) +// if (searchParams) { +// for (const [name, value] of Object.values(searchParams)) { +// url.searchParams.set(name, value) +// } +// } +// return url +// } + +const buildErrorUrl = (err: unknown, workspaceSlug: string) => { + const errorRedirectUrl = buildFinalizeUrl(workspaceSlug) + const errorMessage = err instanceof Error ? err.message : `Unknown error: ${err}` + errorRedirectUrl.searchParams.set('error', errorMessage) + return errorRedirectUrl.toString() } +const encryptorFactory = () => + async (data: string) => { + const encryptionKeyPair = await getEncryptionKeyPair() + const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) + const encryptedData = await encryptor.encrypt(data) + + encryptor.dispose() + + return encryptedData + } + const decryptorFactory = () => async (data: string) => { const encryptionKeyPair = await getEncryptionKeyPair() const decryptor = await buildDecryptor(encryptionKeyPair) const decryptedData = await decryptor.decrypt(data) + decryptor.dispose() + return decryptedData } @@ -207,7 +230,41 @@ router.get( }) ) -/** Begin SSO sign-in or sign-up flow */ +/** + * Start SSO sign-in or sign-up flow + */ +const handleSsoAuthRequestFactory = + ({ + getWorkspaceBySlug, + getWorkspaceSsoProvider + }: { + getWorkspaceBySlug: GetWorkspaceBySlug, + getWorkspaceSsoProvider: GetWorkspaceSsoProvider, + + }): RequestHandler => + async ({ params, session, res }) => { + try { + const workspace = await getWorkspaceBySlug({ workspaceSlug: params.workspaceSlug }) + if (!workspace) throw new WorkspaceNotFoundError() + + const { provider } = await getWorkspaceSsoProvider({ workspaceId: workspace.id }) ?? {} + if (!provider) throw new Error('No SSO provider registered for the workspace') + + const codeVerifier = generators.codeVerifier() + const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, false) + const authorizationUrl = await getProviderAuthorizationUrl({ + provider, + redirectUrl, + codeVerifier + }) + + session.codeVerifier = await encryptorFactory()(codeVerifier) + res?.redirect(authorizationUrl.toString()) + } catch (e) { + res?.redirect(buildErrorUrl(e, params.workspaceSlug)) + } + } + router.get( '/api/v1/workspaces/:workspaceSlug/sso/auth', sessionMiddleware, @@ -217,63 +274,55 @@ router.get( workspaceSlug: z.string().min(1) }) }), - async ({ params, session, res }) => { - const { workspaceSlug } = params - const encryptionKeyPair = await getEncryptionKeyPair() - const { decrypt } = await buildDecryptor(encryptionKeyPair) - try { - const workspace = await getWorkspaceBySlugFactory({ db })({ - workspaceSlug - }) - if (!workspace) throw new Error('No workspace found') - - const providerMetadata = await getWorkspaceSsoProviderFactory({ - db, - decrypt - })({ workspaceId: workspace.id }) - if (!providerMetadata) - throw new Error('No SSO provider registered for the workspace') - - // Redirect to OIDC provider to continue auth flow - const { provider } = providerMetadata - const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - const codeVerifier = await startOIDCSsoProviderValidationFactory({ - getOIDCProviderAttributes, - storeOIDCProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ - redis: getGenericRedis(), - encrypt: encryptor.encrypt - }), - generateCodeVerifier: generators.codeVerifier - })({ - provider - }) - const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, false) - const authorizationUrl = await getProviderAuthorizationUrl({ - provider, - redirectUrl, - codeVerifier - }) - - // await new Promise((resolve) => { - // sessionStore.get(sessionID, (_err, session) => { - // sessionStore.set(sessionID, { - // ...session, - // challenge: query.challenge!.toString() - // } as any) - // resolve() - // }) - // }) - - session.codeVerifier = await encryptor.encrypt(codeVerifier) - res?.redirect(authorizationUrl.toString()) - } catch (err) { - console.error(err) - // if things fail, before sending you to the provider, we need to tell it to the user in a nice way - } - } + handleSsoAuthRequestFactory({ + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }), + getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ + db, + decrypt: decryptorFactory() + }) + }) ) /** Begin SSO configuration flow */ +type WorkspaceSsoValidationRequestQuery = z.infer + +const handleSsoValidationRequestFactory = + ({ + getWorkspaceBySlug, + startOIDCSsoProviderValidation + }: { + getWorkspaceBySlug: GetWorkspaceBySlug, + startOIDCSsoProviderValidation: ReturnType + }): RequestHandler => + async ({ session, params, query: provider, res, context }) => { + try { + const workspace = await getWorkspaceBySlug({ workspaceSlug: params.workspaceSlug }) + if (!workspace) throw new WorkspaceNotFoundError() + + await authorizeResolver( + context.userId, + workspace.id, + Roles.Workspace.Admin, + context.resourceAccessRules + ) + + const codeVerifier = await startOIDCSsoProviderValidation({ provider }) + + const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, true) + const authorizationUrl = await getProviderAuthorizationUrl({ + provider, + redirectUrl, + codeVerifier + }) + + session.codeVerifier = await encryptorFactory()(codeVerifier) + + res?.redirect(authorizationUrl.toString()) + } catch (e) { + res?.redirect(buildErrorUrl(e, params.workspaceSlug)) + } + } + router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', sessionMiddleware, @@ -284,56 +333,17 @@ router.get( }), query: oidcProvider }), - async ({ session, params, query, res, context }) => { - const workspaceSlug = params.workspaceSlug - - const workspace = await getWorkspaceBySlugFactory({ db })({ workspaceSlug }) - if (!workspace) throw new WorkspaceNotFoundError() - - // TODO: Billing check for workspace plan - is SSO allowed - - await authorizeResolver( - context.userId, - workspace.id, - Roles.Workspace.Admin, - context.resourceAccessRules - ) - - try { - const provider = query - const encryptionKeyPair = await getEncryptionKeyPair() - const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - const codeVerifier = await startOIDCSsoProviderValidationFactory({ - getOIDCProviderAttributes, - storeOIDCProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ - redis: getGenericRedis(), - encrypt: encryptor.encrypt - }), - generateCodeVerifier: generators.codeVerifier - })({ - provider - }) - const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, true) - const authorizationUrl = await getProviderAuthorizationUrl({ - provider, - redirectUrl, - codeVerifier - }) - session.codeVerifier = await encryptor.encrypt(codeVerifier) - - encryptor.dispose() - res?.redirect(authorizationUrl.toString()) - } catch (err) { - session.destroy(noop) - const url = buildErrorUrl({ - err, - url: buildFinalizeUrl(params.workspaceSlug), - searchParams: query, - isValidationFlow: true - }) - res?.redirect(url.toString()) - } - } + handleSsoValidationRequestFactory({ + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db }), + startOIDCSsoProviderValidation: startOIDCSsoProviderValidationFactory({ + getOIDCProviderAttributes, + storeOIDCProviderValidationRequest: storeOIDCProviderValidationRequestFactory({ + redis: getGenericRedis, + encrypt: encryptorFactory() + }), + generateCodeVerifier: generators.codeVerifier + }) + }) ) /** Finalize SSO flow for all paths */ @@ -390,25 +400,6 @@ router.get( // req.authRedirectPath = /workspaces/:workspaceSlug/authn // return next() - //----- - - // const { action, user } = await parseAuthAction(req, decryptedOidcProvider) - - // switch (action) { - // case 'sign-up': { - - // } - // case 'sign-in': { - - // } - // } - - //----- - - // const workspace = await getWorkspaceBySlug({ workspaceSlug }) - // const decryptedProvider = await getOrCreateOidcProvider({ req, workspace }) - - // const { action, user } = await parseAuthAction({ req, }) const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) const workspaceSlug = req.params.workspaceSlug @@ -714,112 +705,16 @@ router.get( ? `Failed to verify OIDC sso provider for workspace ${workspaceSlug}` : `Failed to sign in to ${workspaceSlug}` logger.warn({ error: err }, warnMessage) - redirectUrl = buildErrorUrl({ - err, - url: redirectUrl, - searchParams: provider || undefined, - isValidationFlow - }) - res.redirect(redirectUrl.toString()) + // redirectUrl = buildErrorUrl({ + // err, + // url: redirectUrl, + // searchParams: provider || undefined, + // isValidationFlow + // }) + res.redirect(buildErrorUrl(err, req.params.workspaceSlug)) } }, finalizeAuthMiddleware ) export default router - -// type AuthAction = { -// action: 'validate' | 'sign-in' -// user: UserWithOptionalRole -// workspace: WorkspaceWithOptionalRole -// } | { -// action: 'sign-up' -// user: Pick -// workspace: WorkspaceWithOptionalRole -// } - -// type AuthRequest = Request<{ workspaceSlug: string }, never, never, { validate?: string }> - -// const parseAuthAction = async (req: AuthRequest): Promise => { -// const isValidationFlow = req.query.validate === 'true' - -// const workspace = await getWorkspaceBySlugFactory({ -// db -// })({ -// workspaceSlug: req.params.workspaceSlug -// }) -// if (!workspace) throw new WorkspaceNotFoundError() - -// // Initialize OIDC client -// // TODO: Initialize SSO client by type when multiple types are supported -// const encryptionKeyPair = await getEncryptionKeyPair() - -// const decryptor = await buildDecryptor(encryptionKeyPair) -// const encryptedCodeVerifier = req.session.codeVerifier - -// if (!encryptedCodeVerifier) { -// throw new Error('Cannot find verification token. Restart SSO flow.') -// } - -// const codeVerifier = await decryptor.decrypt(encryptedCodeVerifier) - -// // Fetch OIDC provider information -// const decryptedProvider: (Partial & Pick) | null = isValidationFlow -// // If validating a new configuration, this is stored temporarily in redis -// ? { -// id: '', -// provider: await getOIDCProviderValidationRequestFactory({ -// redis: getGenericRedis(), -// decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt -// })({ -// validationToken: codeVerifier -// }) ?? undefined -// } -// // Otherwise, use the provider information stored in the db -// : await getWorkspaceSsoProviderFactory({ -// db, -// decrypt: (await buildDecryptor(encryptionKeyPair)).decrypt -// })({ -// workspaceId: workspace.id -// }) - -// if (!decryptedProvider || !decryptedProvider?.provider) { -// throw new Error('Failed to find SSO provider. Restart flow.') -// } - -// // Get user profile from SSO provider -// const { client } = await initializeIssuerAndClient({ provider: decryptedProvider.provider }) -// const callbackParams = client.callbackParams(req) -// const tokenSet = await client.callback( -// buildAuthRedirectUrl(workspace.slug, isValidationFlow).toString(), -// callbackParams, -// { code_verifier: codeVerifier } -// ) -// const ssoUserProfile = await client.userinfo(tokenSet) - -// if (!ssoUserProfile || !ssoUserProfile.name || !ssoUserProfile.email) { -// throw new Error('SSO user profile does not conform to Speckle requirements.') -// } - -// // Find Speckle user profile with email that matches SSO user profile -// const existingSpeckleUserEmail = await findEmailFactory({ -// db -// })({ -// email: ssoUserProfile.email -// }) -// const existingSpeckleUser = await getUserFactory({ -// db -// })( -// existingSpeckleUserEmail?.userId ?? '' -// ) - -// // Find Speckle user profile for signed in user that initiated this SSO flow -// const currentSessionUser = await getUserFactory({ -// db -// })( -// req.context.userId ?? '' -// ) - -// // Determine auth action and validate conditions for each action type -// return {} as any -// } \ No newline at end of file diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index 9b12dd7682..31167500e6 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -47,6 +47,12 @@ grant_types: ['authorization_code'], */ } +/** + * Store information about the OIDC provider used for a given SSO auth request. + * Used by validation and auth + * @param param0 + * @returns + */ export const startOIDCSsoProviderValidationFactory = ({ getOIDCProviderAttributes, @@ -57,16 +63,16 @@ export const startOIDCSsoProviderValidationFactory = storeOIDCProviderValidationRequest: StoreOIDCProviderValidationRequest generateCodeVerifier: () => string }) => - async ({ provider }: { provider: OIDCProvider }): Promise => { - // get client information - const providerAttributes = await getOIDCProviderAttributes({ provider }) - // validate issuer and client data - validateOIDCProviderAttributes(providerAttributes) - // store provider validation with an id token - const codeVerifier = generateCodeVerifier() - await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) - return codeVerifier - } + async ({ provider }: { provider: OIDCProvider }): Promise => { + // get client information + const providerAttributes = await getOIDCProviderAttributes({ provider }) + // validate issuer and client data + validateOIDCProviderAttributes(providerAttributes) + // store provider validation with an id token + const codeVerifier = generateCodeVerifier() + await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) + return codeVerifier + } export const saveSsoProviderRegistrationFactory = ({ @@ -86,72 +92,72 @@ export const saveSsoProviderRegistrationFactory = updateUserEmail: UpdateUserEmail findEmailsByUserId: FindEmailsByUserId }) => - async ({ - provider, - workspaceId, - userId, - ssoProviderUserInfo - }: { - provider: OIDCProvider - userId: string - workspaceId: string - ssoProviderUserInfo: { email: string } - }): Promise => { - // create OIDC provider record with ID - const providerId = cryptoRandomString({ length: 10 }) - const providerRecord: OIDCProviderRecord = { + async ({ provider, - providerType: 'oidc', - createdAt: new Date(), - updatedAt: new Date(), - id: providerId - } - const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) - // replace with a proper error - if (maybeExistingSsoProvider) - throw new Error('Workspace already has an SSO provider') - await storeProviderRecord({ providerRecord }) - // associate provider with workspace - await associateSsoProviderWithWorkspace({ workspaceId, providerId }) - // create and associate userSso session - // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work - await upsertUserSsoSession({ - userSsoSession: { - userId, - providerId, + workspaceId, + userId, + ssoProviderUserInfo + }: { + provider: OIDCProvider + userId: string + workspaceId: string + ssoProviderUserInfo: { email: string } + }): Promise => { + // create OIDC provider record with ID + const providerId = cryptoRandomString({ length: 10 }) + const providerRecord: OIDCProviderRecord = { + provider, + providerType: 'oidc', createdAt: new Date(), - validUntil: getDefaultSsoSessionExpirationDate() + updatedAt: new Date(), + id: providerId } - }) - - const currentUserEmails = await findEmailsByUserId({ userId }) - const currentSsoEmailEntry = currentUserEmails.find( - (entry) => entry.email === ssoProviderUserInfo.email - ) - - if (!currentSsoEmailEntry) { - await createUserEmail({ - userEmail: { + const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) + // replace with a proper error + if (maybeExistingSsoProvider) + throw new Error('Workspace already has an SSO provider') + await storeProviderRecord({ providerRecord }) + // associate provider with workspace + await associateSsoProviderWithWorkspace({ workspaceId, providerId }) + // create and associate userSso session + // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work + await upsertUserSsoSession({ + userSsoSession: { userId, - email: ssoProviderUserInfo.email, - verified: true + providerId, + createdAt: new Date(), + validUntil: getDefaultSsoSessionExpirationDate() } }) - return providerId - } - if (!currentSsoEmailEntry.verified) { - await updateUserEmail({ - query: { - id: currentSsoEmailEntry.id, - userId - }, - update: { - verified: true - } - }) + const currentUserEmails = await findEmailsByUserId({ userId }) + const currentSsoEmailEntry = currentUserEmails.find( + (entry) => entry.email === ssoProviderUserInfo.email + ) + + if (!currentSsoEmailEntry) { + await createUserEmail({ + userEmail: { + userId, + email: ssoProviderUserInfo.email, + verified: true + } + }) + return providerId + } + + if (!currentSsoEmailEntry.verified) { + await updateUserEmail({ + query: { + id: currentSsoEmailEntry.id, + userId + }, + update: { + verified: true + } + }) + return providerId + } + return providerId } - - return providerId - } From 52257527a3f443daff58d28dca42794e2f04e62b Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Wed, 23 Oct 2024 21:48:47 +0100 Subject: [PATCH 31/47] fix(sso): needs a haircut but she works --- .../server/modules/shared/helpers/dbHelper.ts | 2 +- .../modules/workspaces/repositories/sso.ts | 92 +- .../server/modules/workspaces/rest/sso.ts | 967 ++++++++++-------- .../server/modules/workspaces/services/sso.ts | 127 +-- 4 files changed, 600 insertions(+), 588 deletions(-) diff --git a/packages/server/modules/shared/helpers/dbHelper.ts b/packages/server/modules/shared/helpers/dbHelper.ts index a519ead052..5fa9e8659c 100644 --- a/packages/server/modules/shared/helpers/dbHelper.ts +++ b/packages/server/modules/shared/helpers/dbHelper.ts @@ -101,7 +101,7 @@ export const numberOfFreeConnections = (knex: Knex) => { } export const withTransaction = async ( - callback: Promise, + callback: Promise | T, trx: Knex.Transaction ): Promise => { try { diff --git a/packages/server/modules/workspaces/repositories/sso.ts b/packages/server/modules/workspaces/repositories/sso.ts index 62a35309ac..b4a16617fb 100644 --- a/packages/server/modules/workspaces/repositories/sso.ts +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -37,67 +37,67 @@ export const storeOIDCProviderValidationRequestFactory = redis: () => Redis encrypt: Crypt }): StoreOIDCProviderValidationRequest => - async ({ provider, token }) => { - const providerData = await encrypt(JSON.stringify(provider)) - await redis().set(token, providerData) - } + async ({ provider, token }) => { + const providerData = await encrypt(JSON.stringify(provider)) + await redis().set(token, providerData) + } export const getOIDCProviderValidationRequestFactory = ({ redis, decrypt }: { redis: Redis; decrypt: Crypt }): GetOIDCProviderData => - async ({ validationToken }: { validationToken: string }) => { - const encryptedProviderData = await redis.get(validationToken) - if (!encryptedProviderData) return null - const providerDataString = await decrypt(encryptedProviderData) - const provider = oidcProvider.parse(JSON.parse(providerDataString)) - return provider - } + async ({ validationToken }: { validationToken: string }) => { + const encryptedProviderData = await redis.get(validationToken) + if (!encryptedProviderData) return null + const providerDataString = await decrypt(encryptedProviderData) + const provider = oidcProvider.parse(JSON.parse(providerDataString)) + return provider + } export const getWorkspaceSsoProviderFactory = ({ db, decrypt }: { db: Knex; decrypt: Crypt }): GetWorkspaceSsoProvider => - async ({ workspaceId }) => { - const maybeProvider = await tables - .workspaceSsoProviders(db) - .select(['workspaceId', 'providerId', 'providerType', 'encryptedProviderData']) - .where({ workspaceId }) - .join('sso_providers', 'id', 'providerId') - .first() - if (!maybeProvider) return null + async ({ workspaceId }) => { + const maybeProvider = await tables + .workspaceSsoProviders(db) + .select('*') + .where({ workspaceId }) + .join('sso_providers', 'id', 'providerId') + .first() + if (!maybeProvider) return null - const providerDataString = await decrypt(maybeProvider.encryptedProviderData) - const providerData = JSON.parse(providerDataString) + const providerDataString = await decrypt(maybeProvider.encryptedProviderData) + const providerData = JSON.parse(providerDataString) - switch (maybeProvider.providerType) { - case 'oidc': - return { - ...omit(maybeProvider, ['encryptedProviderData']), - provider: oidcProvider.parse(providerData) - } - default: - // this is an internal error - throw new Error('Provider type not supported') - } + switch (maybeProvider.providerType) { + case 'oidc': + return { + ...omit(maybeProvider, ['encryptedProviderData']), + provider: oidcProvider.parse(providerData) + } + 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) - } + 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 }) - } + async ({ providerId, workspaceId }) => { + await tables.workspaceSsoProviders(db).insert({ providerId, workspaceId }) + } export const upsertUserSsoSessionFactory = ({ db }: { db: Knex }): UpsertUserSsoSession => - async ({ userSsoSession }) => { - await tables - .userSsoSessions(db) - .insert(userSsoSession) - .onConflict(['userId', 'providerId']) - .merge(['createdAt', 'validUntil']) - } + async ({ userSsoSession }) => { + await tables + .userSsoSessions(db) + .insert(userSsoSession) + .onConflict(['userId', 'providerId']) + .merge(['createdAt', 'validUntil']) + } diff --git a/packages/server/modules/workspaces/rest/sso.ts b/packages/server/modules/workspaces/rest/sso.ts index b72fa63a5c..3325163383 100644 --- a/packages/server/modules/workspaces/rest/sso.ts +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -13,7 +13,11 @@ import { getProviderAuthorizationUrl, initializeIssuerAndClient } from '@/modules/workspaces/clients/oidcProvider' -import { getFrontendOrigin, getServerOrigin } from '@/modules/shared/helpers/envHelper' +import { + adminOverrideEnabled, + getFrontendOrigin, + getServerOrigin +} from '@/modules/shared/helpers/envHelper' import { storeOIDCProviderValidationRequestFactory, getOIDCProviderValidationRequestFactory, @@ -25,13 +29,14 @@ import { import { buildDecryptor, buildEncryptor } from '@/modules/shared/utils/libsodium' import { getEncryptionKeyPair } from '@/modules/automate/services/encryption' import { getGenericRedis } from '@/modules/core' -import { generators } from 'openid-client' -import { noop } from 'lodash' +import { generators, UserinfoResponse } from 'openid-client' import { oidcProvider } from '@/modules/workspaces/domain/sso/models' -import { OIDCProvider, WorkspaceSsoProvider } from '@/modules/workspaces/domain/sso/types' +import { + OIDCProvider, + WorkspaceSsoProvider +} from '@/modules/workspaces/domain/sso/types' import { getWorkspaceBySlugFactory, - getWorkspaceCollaboratorsFactory, upsertWorkspaceRoleFactory } from '@/modules/workspaces/repositories/workspaces' import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' @@ -77,8 +82,30 @@ import { renderEmail } from '@/modules/emails/services/emailRendering' import { createAuthorizationCodeFactory } from '@/modules/auth/repositories/apps' import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic' import { WorkspaceWithOptionalRole } from '@/modules/workspacesCore/domain/types' -import { GetWorkspaceBySlug } from '@/modules/workspaces/domain/operations' -import { GetWorkspaceSsoProvider } from '@/modules/workspaces/domain/sso/operations' +import { + GetWorkspaceBySlug, + UpsertWorkspaceRole +} from '@/modules/workspaces/domain/operations' +import { + GetWorkspaceSsoProvider, + UpsertUserSsoSession +} from '@/modules/workspaces/domain/sso/operations' +import { CreateValidatedUser, GetUser } from '@/modules/core/domain/users/operations' +import { + CreateUserEmail, + FindEmail, + FindEmailsByUserId, + UpdateUserEmail +} from '@/modules/core/domain/userEmails/operations' +import { DeleteInvite, FindInvite } from '@/modules/serverinvites/domain/operations' +import { AuthorizeResolver } from '@/modules/shared/domain/operations' +import { authorizeResolverFactory } from '@/modules/shared/services/auth' +import { getRolesFactory } from '@/modules/shared/repositories/roles' +import { + getUserAclRoleFactory, + getUserServerRoleFactory +} from '@/modules/shared/repositories/acl' +import { getStreamFactory } from '@/modules/core/repositories/streams' const router = Router() @@ -117,41 +144,6 @@ const buildFinalizeUrl = (workspaceSlug: string): URL => { return new URL(urlFragments.join(''), getFrontendOrigin()) } -const ssoVerificationStatusKey = 'ssoVerificationStatus' - -// const buildErrorUrl = ({ -// err, -// url, -// searchParams, -// isValidationFlow -// }: { -// err: unknown -// url: URL -// searchParams?: Record -// isValidationFlow: boolean -// }): URL => { -// // TODO: Redirect to workspace-specific sign in page -// if (!isValidationFlow) { -// url.pathname = '/authn/login' -// return url -// } - -// const settingsSearch = url.searchParams.get('settings') -// url.searchParams.forEach((key) => { -// url.searchParams.delete(key) -// }) -// if (settingsSearch) url.searchParams.set('settings', settingsSearch) -// url.searchParams.set(ssoVerificationStatusKey, 'failed') -// const errorMessage = err instanceof Error ? err.message : `Unknown error ${err}` -// url.searchParams.set('ssoVerificationError', errorMessage) -// if (searchParams) { -// for (const [name, value] of Object.values(searchParams)) { -// url.searchParams.set(name, value) -// } -// } -// return url -// } - const buildErrorUrl = (err: unknown, workspaceSlug: string) => { const errorRedirectUrl = buildFinalizeUrl(workspaceSlug) const errorMessage = err instanceof Error ? err.message : `Unknown error: ${err}` @@ -159,27 +151,33 @@ const buildErrorUrl = (err: unknown, workspaceSlug: string) => { return errorRedirectUrl.toString() } -const encryptorFactory = () => - async (data: string) => { - const encryptionKeyPair = await getEncryptionKeyPair() - const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - const encryptedData = await encryptor.encrypt(data) +const encryptorFactory = () => async (data: string) => { + const encryptionKeyPair = await getEncryptionKeyPair() + const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) + const encryptedData = await encryptor.encrypt(data) - encryptor.dispose() + encryptor.dispose() - return encryptedData - } + return encryptedData +} -const decryptorFactory = () => - async (data: string) => { - const encryptionKeyPair = await getEncryptionKeyPair() - const decryptor = await buildDecryptor(encryptionKeyPair) - const decryptedData = await decryptor.decrypt(data) +const decryptorFactory = () => async (data: string) => { + const encryptionKeyPair = await getEncryptionKeyPair() + const decryptor = await buildDecryptor(encryptionKeyPair) + const decryptedData = await decryptor.decrypt(data) - decryptor.dispose() + decryptor.dispose() - return decryptedData - } + return decryptedData +} + +const parseCodeVerifier = async (req: Request): Promise => { + const encryptedCodeVerifier = req.session.codeVerifier + if (!encryptedCodeVerifier) + throw new Error('Cannot find verification token. Restart flow.') + const codeVerifier = await decryptorFactory()(encryptedCodeVerifier) + return codeVerifier +} const workspaceSsoAuthRequestParams = z.object({ workspaceSlug: z.string().min(1) @@ -195,25 +193,25 @@ const handleGetLimitedWorkspaceRequestFactory = getWorkspaceBySlug, getWorkspaceSsoProvider }: { - getWorkspaceBySlug: GetWorkspaceBySlug, + getWorkspaceBySlug: GetWorkspaceBySlug getWorkspaceSsoProvider: GetWorkspaceSsoProvider }): RequestHandler => - async ({ params, res }) => { - const workspace = await getWorkspaceBySlug({ workspaceSlug: params.workspaceSlug }) - if (!workspace) throw new WorkspaceNotFoundError() - - const ssoProviderData = await getWorkspaceSsoProvider({ workspaceId: workspace.id }) + async ({ params, res }) => { + const workspace = await getWorkspaceBySlug({ workspaceSlug: params.workspaceSlug }) + if (!workspace) throw new WorkspaceNotFoundError() - const limitedWorkspace = { - name: workspace.name, - logo: workspace.logo, - defaultLogoIndex: workspace.defaultLogoIndex, - ssoProviderName: ssoProviderData?.provider?.providerName - } + const ssoProviderData = await getWorkspaceSsoProvider({ workspaceId: workspace.id }) - res?.json(limitedWorkspace) + const limitedWorkspace = { + name: workspace.name, + logo: workspace.logo, + defaultLogoIndex: workspace.defaultLogoIndex, + ssoProviderName: ssoProviderData?.provider?.providerName } + res?.json(limitedWorkspace) + } + router.get( '/api/v1/workspaces/:workspaceSlug/sso', validateRequest({ @@ -238,32 +236,34 @@ const handleSsoAuthRequestFactory = getWorkspaceBySlug, getWorkspaceSsoProvider }: { - getWorkspaceBySlug: GetWorkspaceBySlug, - getWorkspaceSsoProvider: GetWorkspaceSsoProvider, - + getWorkspaceBySlug: GetWorkspaceBySlug + getWorkspaceSsoProvider: GetWorkspaceSsoProvider }): RequestHandler => - async ({ params, session, res }) => { - try { - const workspace = await getWorkspaceBySlug({ workspaceSlug: params.workspaceSlug }) - if (!workspace) throw new WorkspaceNotFoundError() - - const { provider } = await getWorkspaceSsoProvider({ workspaceId: workspace.id }) ?? {} - if (!provider) throw new Error('No SSO provider registered for the workspace') - - const codeVerifier = generators.codeVerifier() - const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, false) - const authorizationUrl = await getProviderAuthorizationUrl({ - provider, - redirectUrl, - codeVerifier - }) + async ({ params, session, res }) => { + try { + const workspace = await getWorkspaceBySlug({ + workspaceSlug: params.workspaceSlug + }) + if (!workspace) throw new WorkspaceNotFoundError() - session.codeVerifier = await encryptorFactory()(codeVerifier) - res?.redirect(authorizationUrl.toString()) - } catch (e) { - res?.redirect(buildErrorUrl(e, params.workspaceSlug)) - } + const { provider } = + (await getWorkspaceSsoProvider({ workspaceId: workspace.id })) ?? {} + if (!provider) throw new Error('No SSO provider registered for the workspace') + + const codeVerifier = generators.codeVerifier() + const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, false) + const authorizationUrl = await getProviderAuthorizationUrl({ + provider, + redirectUrl, + codeVerifier + }) + + session.codeVerifier = await encryptorFactory()(codeVerifier) + res?.redirect(authorizationUrl.toString()) + } catch (e) { + res?.redirect(buildErrorUrl(e, params.workspaceSlug)) } + } router.get( '/api/v1/workspaces/:workspaceSlug/sso/auth', @@ -283,45 +283,56 @@ router.get( }) ) -/** Begin SSO configuration flow */ type WorkspaceSsoValidationRequestQuery = z.infer +/** + * Begin SSO configuration flow + */ const handleSsoValidationRequestFactory = ({ getWorkspaceBySlug, startOIDCSsoProviderValidation }: { - getWorkspaceBySlug: GetWorkspaceBySlug, - startOIDCSsoProviderValidation: ReturnType - }): RequestHandler => - async ({ session, params, query: provider, res, context }) => { - try { - const workspace = await getWorkspaceBySlug({ workspaceSlug: params.workspaceSlug }) - if (!workspace) throw new WorkspaceNotFoundError() - - await authorizeResolver( - context.userId, - workspace.id, - Roles.Workspace.Admin, - context.resourceAccessRules - ) + getWorkspaceBySlug: GetWorkspaceBySlug + startOIDCSsoProviderValidation: ReturnType< + typeof startOIDCSsoProviderValidationFactory + > + }): RequestHandler< + WorkspaceSsoAuthRequestParams, + never, + never, + WorkspaceSsoValidationRequestQuery + > => + async ({ session, params, query: provider, res, context }) => { + try { + const workspace = await getWorkspaceBySlug({ + workspaceSlug: params.workspaceSlug + }) + if (!workspace) throw new WorkspaceNotFoundError() - const codeVerifier = await startOIDCSsoProviderValidation({ provider }) + await authorizeResolver( + context.userId, + workspace.id, + Roles.Workspace.Admin, + context.resourceAccessRules + ) - const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, true) - const authorizationUrl = await getProviderAuthorizationUrl({ - provider, - redirectUrl, - codeVerifier - }) + const codeVerifier = await startOIDCSsoProviderValidation({ provider }) - session.codeVerifier = await encryptorFactory()(codeVerifier) + const redirectUrl = buildAuthRedirectUrl(params.workspaceSlug, true) + const authorizationUrl = await getProviderAuthorizationUrl({ + provider, + redirectUrl, + codeVerifier + }) - res?.redirect(authorizationUrl.toString()) - } catch (e) { - res?.redirect(buildErrorUrl(e, params.workspaceSlug)) - } + session.codeVerifier = await encryptorFactory()(codeVerifier) + + res?.redirect(authorizationUrl.toString()) + } catch (e) { + res?.redirect(buildErrorUrl(e, params.workspaceSlug)) } + } router.get( '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', @@ -346,372 +357,432 @@ router.get( }) ) -/** Finalize SSO flow for all paths */ -router.get( - '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', - sessionMiddleware, - validateRequest({ - params: z.object({ - workspaceSlug: z.string().min(1) - }), - query: z.object({ validate: z.string().optional() }) - }), - async (req, res, next) => { - // NOTE: If req.context.userId is defined, there is a user signed in +const createOidcProviderFactory = + ({ + getOIDCProviderValidationRequest, + saveSsoProviderRegistration + }: { + getOIDCProviderValidationRequest: ReturnType< + typeof getOIDCProviderValidationRequestFactory + > + saveSsoProviderRegistration: ReturnType + }) => + async ( + req: Request, + workspace: WorkspaceWithOptionalRole + ): Promise => { + if (!req.context.userId) throw new Error('Must be signed in to configure SSO') + + const encryptedCodeVerifier = req.session.codeVerifier + if (!encryptedCodeVerifier) + throw new Error('Cannot find verification token. Restart flow.') + + const codeVerifier = await parseCodeVerifier(req) + + const oidcProvider = await getOIDCProviderValidationRequest({ + validationToken: codeVerifier + }) + if (!oidcProvider) throw new Error('Validation request not found. Restart flow.') + + await authorizeResolver( + req.context.userId, + workspace.id, + Roles.Workspace.Admin, + req.context.resourceAccessRules + ) + + const workspaceProviderRecord = await saveSsoProviderRegistration({ + provider: oidcProvider, + workspaceId: workspace.id + }) - // const decryptedOidcProvider = req.query.validate === 'true' - // ? await createOidcProvider(req) // assert signed in - // : await getOidcProvider(req) + return { + ...workspaceProviderRecord, + providerId: workspaceProviderRecord.id, + workspaceId: workspace.id + } + } - // const oidcProviderUserData = await getOidcProviderUserData(req, decryptedOidcProvider) - // const speckleUserData = await tryGetSpeckleUserData(req, oidcProviderUserData) // assert existing email match is verified, assert ids match if both present +const getOidcProviderFactory = + ({ getWorkspaceSsoProvider }: { getWorkspaceSsoProvider: GetWorkspaceSsoProvider }) => + async ( + req: Request, + workspace: WorkspaceWithOptionalRole + ): Promise => { + const provider = await getWorkspaceSsoProvider({ workspaceId: workspace.id }) + if (!provider) throw new Error('Could not find SSO provider') + return provider + } - // if (!speckleUserData) { - // const newSpeckleUser = await createWorkspaceUserFromSsoProfile({ - // ssoProfile: oidcProviderUserData, - // workspaceId: decryptedOidcProvider.workspaceId - // }) - // req.user = newSpeckleUser ({ isNewUser: true, email: newSpeckleUser.email }) - // } +const getOidcProviderUserDataFactory = + () => + async ( + req: Request< + WorkspaceSsoAuthRequestParams, + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + any, + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + any, + WorkspaceSsoOidcCallbackRequestQuery + >, + provider: OIDCProvider + ): Promise> => { + const codeVerifier = await parseCodeVerifier(req) + const { client } = await initializeIssuerAndClient({ provider }) + const callbackParams = client.callbackParams(req) + const tokenSet = await client.callback( + buildAuthRedirectUrl( + req.params.workspaceSlug, + req.query.validate === 'true' + ).toString(), + callbackParams, + { code_verifier: codeVerifier } + ) + + const oidcProviderUserData = await client.userinfo(tokenSet) + if (!oidcProviderUserData || !oidcProviderUserData.email) { + throw new Error('Failed to get user profile from SSO provider.') + } - // req.user ??= { id: speckleUserData.id } + return oidcProviderUserData as UserinfoResponse<{ email: string }> + } - // if (!req.user || !req.user.id) throw new Error('Failed to sign in.') +const tryGetSpeckleUserDataFactory = + ({ findEmail, getUser }: { findEmail: FindEmail; getUser: GetUser }) => + async ( + req: Request, + oidcProviderUserData: UserinfoResponse<{ email: string }> + ): Promise => { + // Get currently signed-in user, if available + const currentSessionUser = await getUser(req.context.userId ?? '') + + // Get user with email that matches OIDC provider user email, if match exists + const userEmail = await findEmail({ email: oidcProviderUserData.email }) + if (!!userEmail && !userEmail.verified) + throw new Error('Cannot sign in with SSO using unverified email.') + const existingSpeckleUser = await getUser(userEmail?.userId ?? '') + + // Confirm existing user matches signed-in user, if both are present + if (!!currentSessionUser && !!existingSpeckleUser) { + if (currentSessionUser.id !== existingSpeckleUser.id) { + throw new Error( + 'OIDC provider user already associated with another Speckle account.' + ) + } + } - // TODO: - // Chuck's soapbox - - // Assert link between req.user.id & { providerId: decryptedOidcProvider.id, email: oidcProviderUserData.email } - // Create link if req.context.userId exists (user performed SSO flow while signed in) + // Return target user of sign in flow + return currentSessionUser ?? existingSpeckleUser + } - // Add oidcProviderUserData.email to req.user.id verified emails, if not already present +const createWorkspaceUserFromSsoProfileFactory = + ({ + createUser, + upsertWorkspaceRole, + findInvite, + deleteInvite + }: { + createUser: CreateValidatedUser + upsertWorkspaceRole: UpsertWorkspaceRole + findInvite: FindInvite + deleteInvite: DeleteInvite + }) => + async (args: { + ssoProfile: UserinfoResponse<{ email: string }> + workspaceId: string + }): Promise> => { + // Check if user has email-based invite to given workspace + const invite = await findInvite({ + target: args.ssoProfile.email, + resourceFilter: { + resourceId: args.workspaceId, + resourceType: 'workspace' + } + }) - // Assert req.user.id is member of workspace + if (!invite) { + throw new Error('Cannot sign up with SSO without a valid workspace invite.') + } - // await upsertUserSsoSessionFactory({ db })({ - // userSsoSession: { - // userId: req.user.id, - // providerId: decryptedOidcProvider.id, - // createdAt: new Date(), - // validUntil: getDefaultSsoSessionExpirationDate() - // } - // }) + // Create Speckle user + const { name, email, email_verified } = args.ssoProfile - // Finalize auth - // req.authRedirectPath = /workspaces/:workspaceSlug/authn - // return next() + if (!name) { + throw new Error('SSO provider user requires a name') + } - const logger = req.log.child({ workspaceSlug: req.params.workspaceSlug }) + if (!email_verified) { + throw new Error('Cannot sign in with unverified email') + } - const workspaceSlug = req.params.workspaceSlug - const isValidationFlow = req.query.validate === 'true' + const newSpeckleUser = { + name, + email, + verified: true, + role: invite.resource.secondaryResourceRoles?.server + } + const newSpeckleUserId = await createUser(newSpeckleUser) - let provider: OIDCProvider | null = null - let providerId: string | null = null - let redirectUrl = buildFinalizeUrl(req.params.workspaceSlug) + // Add user to workspace with role specified in invite + const { role: workspaceRole } = invite.resource - // TODO: Billing check - verify workspace has SSO enabled - const workspace = await getWorkspaceBySlugFactory({ db })({ - workspaceSlug: req.params.workspaceSlug - }) - if (!workspace) throw new WorkspaceNotFoundError() + if (!isWorkspaceRole(workspaceRole)) throw new Error('Invalid role') - try { - // Initialize OIDC client based on provider for current request flow - const encryptionKeyPair = await getEncryptionKeyPair() - const encryptor = await buildEncryptor(encryptionKeyPair.publicKey) - const { decrypt: decryptCodeVerifier } = await buildDecryptor(encryptionKeyPair) - const encryptedCodeVerifier = req.session.codeVerifier + await upsertWorkspaceRole({ + userId: newSpeckleUserId, + workspaceId: args.workspaceId, + role: workspaceRole, + createdAt: new Date() + }) - if (!encryptedCodeVerifier) - throw new Error('cannot find verification token, restart the flow') + // Delete invite (implicitly used during sign up flow) + await deleteInvite(invite.id) - const codeVerifier = await decryptCodeVerifier(encryptedCodeVerifier) + return { + ...newSpeckleUser, + id: newSpeckleUserId + } + } - if (isValidationFlow) { - // Get provider configuration from redis - const { decrypt: decryptOIDCProvider } = await buildDecryptor(encryptionKeyPair) +const linkUserWithSsoProviderFactory = + ({ + findEmailsByUserId, + createUserEmail, + updateUserEmail + }: { + findEmailsByUserId: FindEmailsByUserId + createUserEmail: CreateUserEmail + updateUserEmail: UpdateUserEmail + }) => + async (args: { + userId: string + ssoProfile: UserinfoResponse<{ email: string }> + }): Promise => { + // TODO: Chuck's soapbox - + // + // Assert link between req.user.id & { providerId: decryptedOidcProvider.id, email: oidcProviderUserData.email } + // Create link implicitly if req.context.userId exists (user performed SSO flow while signed in) + // If req.context.userId does not exist, and link does not exist, throw and require user to sign in before SSO - provider = await getOIDCProviderValidationRequestFactory({ - redis: getGenericRedis(), - decrypt: decryptOIDCProvider - })({ - validationToken: codeVerifier - }) + // Add oidcProviderUserData.email to req.user.id verified emails, if not already present + const userEmails = await findEmailsByUserId({ userId: args.userId }) + const maybeSsoEmail = userEmails.find( + (entry) => entry.email === args.ssoProfile.email + ) + + if (!maybeSsoEmail) { + await createUserEmail({ + userEmail: { + userId: args.userId, + email: args.ssoProfile.email, + verified: true + } + }) + } - if (!provider) throw new Error('validation request not found, please retry') - } else { - // Get stored provider configuration - const { decrypt: decryptSsoProvider } = await buildDecryptor(encryptionKeyPair) + if (!!maybeSsoEmail && !maybeSsoEmail.verified) { + await updateUserEmail({ + query: { + id: maybeSsoEmail.id, + userId: args.userId + }, + update: { + verified: true + } + }) + } + } - const providerMetadata = await getWorkspaceSsoProviderFactory({ - db, - decrypt: decryptSsoProvider - })({ workspaceId: workspace.id }) +const oidcCallbackRequestQuery = z.object({ validate: z.string().optional() }) - if (!providerMetadata?.provider) throw new Error('Could not find SSO provider') +type WorkspaceSsoOidcCallbackRequestQuery = z.infer - provider = providerMetadata.provider - providerId = providerMetadata.providerId - } +/** + * Finalize SSO flow for all OIDC paths + */ +const handleOidcCallbackFactory = + ({ + authorizeResolver, + getWorkspaceBySlug, + createOidcProvider, + getOidcProvider, + getOidcProviderUserData, + tryGetSpeckleUserData, + createWorkspaceUserFromSsoProfile, + linkUserWithSsoProvider, + upsertUserSsoSession + }: { + authorizeResolver: AuthorizeResolver + getWorkspaceBySlug: GetWorkspaceBySlug + createOidcProvider: ReturnType + getOidcProvider: ReturnType + getOidcProviderUserData: ReturnType + tryGetSpeckleUserData: ReturnType + createWorkspaceUserFromSsoProfile: ReturnType< + typeof createWorkspaceUserFromSsoProfileFactory + > + linkUserWithSsoProvider: ReturnType + upsertUserSsoSession: UpsertUserSsoSession + }): RequestHandler< + WorkspaceSsoAuthRequestParams, + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + any, + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + any, + WorkspaceSsoOidcCallbackRequestQuery + > => + async (req) => { + const workspace = await getWorkspaceBySlug({ + workspaceSlug: req.params.workspaceSlug + }) + if (!workspace) throw new WorkspaceNotFoundError() - const { client } = await initializeIssuerAndClient({ provider }) - const callbackParams = client.callbackParams(req) - const tokenSet = await client.callback( - buildAuthRedirectUrl(workspaceSlug, isValidationFlow).toString(), - callbackParams, - { code_verifier: codeVerifier } - ) + const decryptedOidcProvider: WorkspaceSsoProvider = + req.query.validate === 'true' + ? await createOidcProvider(req, workspace) + : await getOidcProvider(req, workspace) + + const oidcProviderUserData = await getOidcProviderUserData( + req, + decryptedOidcProvider.provider + ) + const speckleUserData = await tryGetSpeckleUserData(req, oidcProviderUserData) + + if (!speckleUserData) { + const newSpeckleUser = await createWorkspaceUserFromSsoProfile({ + ssoProfile: oidcProviderUserData, + workspaceId: decryptedOidcProvider.workspaceId + }) + req.user = { id: newSpeckleUser.id, email: newSpeckleUser.email, isNewUser: true } + } - // Get user associated with current session - const currentSessionUser = await getUserFactory({ db })(req.context.userId ?? '') + req.user ??= { id: speckleUserData!.id, email: speckleUserData!.email } - // Get user profile from SSO provider - const ssoProviderUserInfo = await client.userinfo<{ email: string }>(tokenSet) - if (!ssoProviderUserInfo || !ssoProviderUserInfo.email) - throw new Error('This should never happen, we are asking for an email claim') + if (!req.user || !req.user.id) + throw new Error('Unhandled failure signing in with SSO.') - if (isValidationFlow) { - // OIDC configuration verification flow: the user is attempting to configure SSO for their workspace + await linkUserWithSsoProvider({ + userId: req.user.id, + ssoProfile: oidcProviderUserData + }) - // Only workspace admins may configure SSO - if (!currentSessionUser) { - throw new Error('Must be signed in to configure SSO') - } + // TODO: Implicitly consume invite here, if one exists + await authorizeResolver( + req.user.id, + workspace.id, + Roles.Workspace.Member, + req.context.resourceAccessRules + ) + + // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work + await upsertUserSsoSession({ + userSsoSession: { + userId: req.user.id, + providerId: decryptedOidcProvider.providerId, + createdAt: new Date(), + validUntil: getDefaultSsoSessionExpirationDate() + } + }) - await authorizeResolver( - req.context.userId, - workspace.id, - Roles.Workspace.Admin, - req.context.resourceAccessRules - ) - const userId = currentSessionUser.id + req.authRedirectPath = buildFinalizeUrl(workspace.slug).toString() + } - // Write SSO configuration - const trx = await db.transaction() - const { decrypt: decryptExistingSsoProvider } = await buildDecryptor( - encryptionKeyPair - ) - const saveSsoProviderRegistration = saveSsoProviderRegistrationFactory({ +router.get( + '/api/v1/workspaces/:workspaceSlug/sso/oidc/callback', + sessionMiddleware, + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }), + query: oidcCallbackRequestQuery + }), + async (req, res, next) => { + const trx = await db.transaction() + const handleOidcCallback = handleOidcCallbackFactory({ + authorizeResolver: authorizeResolverFactory({ + adminOverrideEnabled, + getRoles: getRolesFactory({ db: trx }), + getUserServerRole: getUserServerRoleFactory({ db: trx }), + getStream: getStreamFactory({ db: trx }), + getUserAclRole: getUserAclRoleFactory({ db: trx }) + }), + getWorkspaceBySlug: getWorkspaceBySlugFactory({ db: trx }), + createOidcProvider: createOidcProviderFactory({ + getOIDCProviderValidationRequest: getOIDCProviderValidationRequestFactory({ + redis: getGenericRedis(), + decrypt: decryptorFactory() + }), + saveSsoProviderRegistration: saveSsoProviderRegistrationFactory({ getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ db: trx, - decrypt: decryptExistingSsoProvider - }), - associateSsoProviderWithWorkspace: associateSsoProviderWithWorkspaceFactory({ - db: trx + decrypt: decryptorFactory() }), storeProviderRecord: storeProviderRecordFactory({ - db, - encrypt: encryptor.encrypt + db: trx, + encrypt: encryptorFactory() }), - upsertUserSsoSession: upsertUserSsoSessionFactory({ db: trx }), - createUserEmail: createUserEmailFactory({ db: trx }), - updateUserEmail: updateUserEmailFactory({ db: trx }), - findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }) + associateSsoProviderWithWorkspace: associateSsoProviderWithWorkspaceFactory({ + db: trx + }) }) - providerId = await withTransaction( - saveSsoProviderRegistration({ - provider, - userId, - workspaceId: workspace.id, - ssoProviderUserInfo - }), - trx - ) - - // Build final redirect url - redirectUrl = buildFinalizeUrl(req.params.workspaceSlug) - redirectUrl.searchParams.set(ssoVerificationStatusKey, 'success') - - req.authRedirectPath = redirectUrl.toString() - req.user = { id: currentSessionUser.id, email: currentSessionUser.email } - - return next() - } else { - // OIDC auth flow: SSO is already configured and we are attempting to log in or sign up - - // Get Speckle user by email from SSO provider - const userEmail = await findEmailFactory({ db })({ - email: ssoProviderUserInfo.email + }), + getOidcProvider: getOidcProviderFactory({ + getWorkspaceSsoProvider: getWorkspaceSsoProviderFactory({ + db: trx, + decrypt: decryptorFactory() }) - const existingSpeckleUser = await getUserFactory({ db })( - userEmail?.userId ?? '' - ) - - // TODO: Validate link between SSO user email and Speckle user - // Link occurs when an already signed-in user signs in with SSO - // Create link here implicitly if conditions are met and no link exists already - - if (!currentSessionUser) { - if (!existingSpeckleUser) { - // Sign up flow with SSO: - // User is not signed in, and no Speckle user is associated with SSO user - - // Check if user has email-based invite to given workspace - const invite = await findInviteFactory({ db })({ - token: req.context.token, // TODO: Is this the invite token? - target: ssoProviderUserInfo.email, - resourceFilter: { - resourceId: workspace.id, // TODO: Are invites still id-based? - resourceType: 'workspace' - } - }) - - if (!invite) { - throw new Error( - 'Cannot sign up with SSO without a valid workspace invite.' - ) - } - - // Create Speckle user - const { name, email, email_verified } = ssoProviderUserInfo - - if (!name) { - throw new Error('SSO provider user requires a name') - } - - if (!email_verified) { - throw new Error('Cannot sign in with unverified email') - } - - const newSpeckleUser = { - name, - email, - verified: true - } - const newSpeckleUserId = await createUserFactory({ - getServerInfo: getServerInfoFactory({ db }), - findEmail: findEmailFactory({ db }), - storeUser: storeUserFactory({ db }), - countAdminUsers: countAdminUsersFactory({ db }), - storeUserAcl: storeUserAclFactory({ db }), - validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ - createUserEmail: createUserEmailFactory({ db }), - ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ db }), - findEmail: findEmailFactory({ db }), - updateEmailInvites: finalizeInvitedServerRegistrationFactory({ - deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db }), - updateAllInviteTargets: updateAllInviteTargetsFactory({ db }) - }), - requestNewEmailVerification: requestNewEmailVerificationFactory({ - findEmail: findEmailFactory({ db }), - getUser: getUserFactory({ db }), - getServerInfo: getServerInfoFactory({ db }), - deleteOldAndInsertNewVerification: - deleteOldAndInsertNewVerificationFactory({ db }), - sendEmail, - renderEmail - }) - }), - usersEventsEmitter: UsersEmitter.emit - })(newSpeckleUser) - - // Add user to workspace with role specified in invite - const { role: workspaceRole } = invite.resource - - if (!isWorkspaceRole(workspaceRole)) throw new Error('Invalid role') - - await upsertWorkspaceRoleFactory({ db })({ - userId: newSpeckleUserId, - workspaceId: workspace.id, - role: workspaceRole, - createdAt: new Date() + }), + getOidcProviderUserData: getOidcProviderUserDataFactory(), + tryGetSpeckleUserData: tryGetSpeckleUserDataFactory({ + findEmail: findEmailFactory({ db: trx }), + getUser: getUserFactory({ db: trx }) + }), + createWorkspaceUserFromSsoProfile: createWorkspaceUserFromSsoProfileFactory({ + createUser: createUserFactory({ + getServerInfo: getServerInfoFactory({ db: trx }), + findEmail: findEmailFactory({ db: trx }), + storeUser: storeUserFactory({ db: trx }), + countAdminUsers: countAdminUsersFactory({ db: trx }), + storeUserAcl: storeUserAclFactory({ db: trx }), + validateAndCreateUserEmail: validateAndCreateUserEmailFactory({ + createUserEmail: createUserEmailFactory({ db: trx }), + ensureNoPrimaryEmailForUser: ensureNoPrimaryEmailForUserFactory({ + db: trx + }), + findEmail: findEmailFactory({ db: trx }), + updateEmailInvites: finalizeInvitedServerRegistrationFactory({ + deleteServerOnlyInvites: deleteServerOnlyInvitesFactory({ db: trx }), + updateAllInviteTargets: updateAllInviteTargetsFactory({ db: trx }) + }), + requestNewEmailVerification: requestNewEmailVerificationFactory({ + findEmail: findEmailFactory({ db: trx }), + getUser: getUserFactory({ db: trx }), + getServerInfo: getServerInfoFactory({ db: trx }), + deleteOldAndInsertNewVerification: + deleteOldAndInsertNewVerificationFactory({ db: trx }), + renderEmail, + sendEmail }) + }), + usersEventsEmitter: UsersEmitter.emit + }), + upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db: trx }), + findInvite: findInviteFactory({ db: trx }), + deleteInvite: deleteInviteFactory({ db: trx }) + }), + linkUserWithSsoProvider: linkUserWithSsoProviderFactory({ + findEmailsByUserId: findEmailsByUserIdFactory({ db: trx }), + createUserEmail: createUserEmailFactory({ db: trx }), + updateUserEmail: updateUserEmailFactory({ db: trx }) + }), + upsertUserSsoSession: upsertUserSsoSessionFactory({ db: trx }) + }) - // Delete invite (implicitly used during sign up flow) - await deleteInviteFactory({ db })(invite.id) - - // Assert sign in - req.user = { - id: newSpeckleUserId, - email: newSpeckleUser.email, - isNewUser: true - } - } else { - // Sign in flow with SSO: - // User is not signed in, but there is a Speckle user associated with the SSO user - - // Assert sign in - req.user = { id: existingSpeckleUser.id, email: existingSpeckleUser.email } - } - } else { - if (!existingSpeckleUser) { - // Sign in flow with SSO: - // User is already signed in, but no Speckle user is associated with the SSO user - // Add SSO email to user - - // Continue to sign in - req.user = { id: currentSessionUser.id, email: currentSessionUser.email } - } else { - // Sign in flow with SSO: - // User is already signed in, and there is already a Speckle user associated with the SSO user, with the email verified - // Verify session user id matches existing user id - if (currentSessionUser.id !== existingSpeckleUser.id) { - throw new Error( - 'SSO user already associated with another Speckle account' - ) - } - - // Continue to sign in - req.user = { id: existingSpeckleUser.id, email: existingSpeckleUser.email } - } - } - - // Confirm that req.user is a member of the given workspace - const workspaceRoles = await getWorkspaceCollaboratorsFactory({ db })({ - workspaceId: workspace.id, - limit: 100 - }) - - if (!req.user || !req.user?.id) { - // This should not happen - throw new Error('Unhandled failure to sign in') - } - - if (!workspaceRoles.some((role) => role.id === req.user?.id)) { - throw new Error( - 'User is not a member of the given workspace and cannot sign in with SSO' - ) - } - - // Update validUntil for SSO session - if (!providerId) { - throw new Error('Unhandled failure to find SSO provider') - } - - await upsertUserSsoSessionFactory({ db })({ - userSsoSession: { - userId: req.user.id, - providerId, - createdAt: new Date(), - validUntil: getDefaultSsoSessionExpirationDate() - } - }) - - // Construct final redirect - const redirectUrlFragments: string[] = [ - `workspaces/${req.params.workspaceSlug}` - ] - if (isValidationFlow) { - redirectUrlFragments.push( - `?settings=workspace/security&workspace=${workspaceSlug}` - ) - } - - req.authRedirectPath = buildFinalizeUrl(workspaceSlug).toString() - return next() - } - } catch (err) { - const warnMessage = isValidationFlow - ? `Failed to verify OIDC sso provider for workspace ${workspaceSlug}` - : `Failed to sign in to ${workspaceSlug}` - logger.warn({ error: err }, warnMessage) - // redirectUrl = buildErrorUrl({ - // err, - // url: redirectUrl, - // searchParams: provider || undefined, - // isValidationFlow - // }) - res.redirect(buildErrorUrl(err, req.params.workspaceSlug)) + try { + await withTransaction(handleOidcCallback(req, res, next), trx) + return next() + } catch (e) { + res?.redirect(buildErrorUrl(e, req.params.workspaceSlug)) } }, finalizeAuthMiddleware diff --git a/packages/server/modules/workspaces/services/sso.ts b/packages/server/modules/workspaces/services/sso.ts index 31167500e6..d96764bac0 100644 --- a/packages/server/modules/workspaces/services/sso.ts +++ b/packages/server/modules/workspaces/services/sso.ts @@ -2,7 +2,6 @@ import { GetOIDCProviderAttributes, StoreOIDCProviderValidationRequest, StoreProviderRecord, - UpsertUserSsoSession, AssociateSsoProviderWithWorkspace, GetWorkspaceSsoProvider } from '@/modules/workspaces/domain/sso/operations' @@ -13,12 +12,6 @@ import { } from '@/modules/workspaces/domain/sso/types' import { BaseError } from '@/modules/shared/errors/base' import cryptoRandomString from 'crypto-random-string' -import { - CreateUserEmail, - FindEmailsByUserId, - UpdateUserEmail -} from '@/modules/core/domain/userEmails/operations' -import { getDefaultSsoSessionExpirationDate } from '@/modules/workspaces/domain/sso/logic' export class MissingOIDCProviderGrantType extends BaseError { static defaultMessage = 'OIDC issuer does not support authorization_code grant type' @@ -63,101 +56,49 @@ export const startOIDCSsoProviderValidationFactory = storeOIDCProviderValidationRequest: StoreOIDCProviderValidationRequest generateCodeVerifier: () => string }) => - async ({ provider }: { provider: OIDCProvider }): Promise => { - // get client information - const providerAttributes = await getOIDCProviderAttributes({ provider }) - // validate issuer and client data - validateOIDCProviderAttributes(providerAttributes) - // store provider validation with an id token - const codeVerifier = generateCodeVerifier() - await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) - return codeVerifier - } + async ({ provider }: { provider: OIDCProvider }): Promise => { + // get client information + const providerAttributes = await getOIDCProviderAttributes({ provider }) + // validate issuer and client data + validateOIDCProviderAttributes(providerAttributes) + // store provider validation with an id token + const codeVerifier = generateCodeVerifier() + await storeOIDCProviderValidationRequest({ token: codeVerifier, provider }) + return codeVerifier + } export const saveSsoProviderRegistrationFactory = ({ getWorkspaceSsoProvider, storeProviderRecord, - associateSsoProviderWithWorkspace, - upsertUserSsoSession, - createUserEmail, - updateUserEmail, - findEmailsByUserId + associateSsoProviderWithWorkspace }: { getWorkspaceSsoProvider: GetWorkspaceSsoProvider storeProviderRecord: StoreProviderRecord associateSsoProviderWithWorkspace: AssociateSsoProviderWithWorkspace - upsertUserSsoSession: UpsertUserSsoSession - createUserEmail: CreateUserEmail - updateUserEmail: UpdateUserEmail - findEmailsByUserId: FindEmailsByUserId }) => - async ({ + async ({ + provider, + workspaceId + }: { + provider: OIDCProvider + workspaceId: string + }): Promise => { + // create OIDC provider record with ID + const providerId = cryptoRandomString({ length: 10 }) + const providerRecord: OIDCProviderRecord = { provider, - workspaceId, - userId, - ssoProviderUserInfo - }: { - provider: OIDCProvider - userId: string - workspaceId: string - ssoProviderUserInfo: { email: string } - }): Promise => { - // create OIDC provider record with ID - const providerId = cryptoRandomString({ length: 10 }) - const providerRecord: OIDCProviderRecord = { - provider, - providerType: 'oidc', - createdAt: new Date(), - updatedAt: new Date(), - id: providerId - } - const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) - // replace with a proper error - if (maybeExistingSsoProvider) - throw new Error('Workspace already has an SSO provider') - await storeProviderRecord({ providerRecord }) - // associate provider with workspace - await associateSsoProviderWithWorkspace({ workspaceId, providerId }) - // create and associate userSso session - // BTW there is a bit of an issue with PATs and sso sessions, if the session expires, the PAT fails to work - await upsertUserSsoSession({ - userSsoSession: { - userId, - providerId, - createdAt: new Date(), - validUntil: getDefaultSsoSessionExpirationDate() - } - }) - - const currentUserEmails = await findEmailsByUserId({ userId }) - const currentSsoEmailEntry = currentUserEmails.find( - (entry) => entry.email === ssoProviderUserInfo.email - ) - - if (!currentSsoEmailEntry) { - await createUserEmail({ - userEmail: { - userId, - email: ssoProviderUserInfo.email, - verified: true - } - }) - return providerId - } - - if (!currentSsoEmailEntry.verified) { - await updateUserEmail({ - query: { - id: currentSsoEmailEntry.id, - userId - }, - update: { - verified: true - } - }) - return providerId - } - - return providerId + providerType: 'oidc', + createdAt: new Date(), + updatedAt: new Date(), + id: providerId } + const maybeExistingSsoProvider = await getWorkspaceSsoProvider({ workspaceId }) + // replace with a proper error + if (maybeExistingSsoProvider) + throw new Error('Workspace already has an SSO provider') + await storeProviderRecord({ providerRecord }) + // associate provider with workspace + await associateSsoProviderWithWorkspace({ workspaceId, providerId }) + return providerRecord + } From 786da1666a2200b12126cca415922188f5fa63f0 Mon Sep 17 00:00:00 2001 From: Charles Driesler Date: Thu, 24 Oct 2024 13:11:26 +0100 Subject: [PATCH 32/47] fix(sso): init rest w function, not side-effects --- .../pages/workspaces/[slug]/authn/index.vue | 4 +- .../workspaces/graph/resolvers/workspaces.ts | 6 +- .../modules/workspaces/helpers/roles.ts | 4 - .../server/modules/workspaces/helpers/sso.ts | 70 ++ packages/server/modules/workspaces/index.ts | 2 +- .../server/modules/workspaces/rest/sso.ts | 735 +++++++----------- .../server/modules/workspaces/services/sso.ts | 131 ++++ 7 files changed, 481 insertions(+), 471 deletions(-) create mode 100644 packages/server/modules/workspaces/helpers/sso.ts diff --git a/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue b/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue index 0a4e3f712b..0b7847e905 100644 --- a/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue +++ b/packages/frontend-2/pages/workspaces/[slug]/authn/index.vue @@ -1,9 +1,10 @@