diff --git a/docker-compose-deps.yml b/docker-compose-deps.yml index cf556f7be..97f036ec1 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/.vscode/launch.json b/packages/server/.vscode/launch.json index f31af9b0f..19791fef4 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 e346d033e..7896258ac 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/core/rest/defaultErrorHandler.ts b/packages/server/modules/core/rest/defaultErrorHandler.ts index 155a8a30a..1c31c8b60 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 000000000..652ebf124 --- /dev/null +++ b/packages/server/modules/workspaces/clients/oidcProvider.ts @@ -0,0 +1,81 @@ +/* eslint-disable camelcase */ +import { BaseError } from '@/modules/shared/errors' +import { OIDCProvider, OIDCProviderAttributes } from '@/modules/workspaces/domain/sso' +import { generators, Issuer, type Client } from 'openid-client' + +export const getProviderAuthorizationUrl = async ({ + provider, + redirectUrl, + codeVerifier +}: { + provider: OIDCProvider + redirectUrl: URL + codeVerifier: string +}): Promise => { + 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 + } +} diff --git a/packages/server/modules/workspaces/domain/sso.ts b/packages/server/modules/workspaces/domain/sso.ts new file mode 100644 index 000000000..92c7fcec5 --- /dev/null +++ b/packages/server/modules/workspaces/domain/sso.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' + +export const oidcProvider = z.object({ + providerName: z.string().min(1), + clientId: z.string().min(5), + clientSecret: z.string().min(1), + issuerUrl: z.string().min(1).url() +}) + +export type OIDCProvider = z.infer + +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 +}) +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 AssociateSsoProviderWithWorkspace = (args: { + workspaceId: string + providerId: string +}) => Promise diff --git a/packages/server/modules/workspaces/index.ts b/packages/server/modules/workspaces/index.ts index 8a361f187..de561b7ce 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 000000000..8511e4a21 --- /dev/null +++ b/packages/server/modules/workspaces/repositories/sso.ts @@ -0,0 +1,95 @@ +import { + oidcProvider, + GetOIDCProviderData, + StoreOIDCProviderValidationRequest, + StoreProviderRecord, + ProviderRecord, + AssociateSsoProviderWithWorkspace, + StoreUserSsoSession, + UserSsoSession, + GetWorkspaceSsoProvider +} from '@/modules/workspaces/domain/sso' +import Redis from 'ioredis' +import { Knex } from 'knex' +import { omit } from 'lodash' + +type Crypt = (input: string) => Promise + +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 = + ({ + redis, + encrypt + }: { + redis: Redis + encrypt: Crypt + }): StoreOIDCProviderValidationRequest => + async ({ provider, token }) => { + const providerData = await encrypt(JSON.stringify(provider)) + await redis.set(token, providerData) + } + +export const getOIDCProviderFactory = + ({ redis, decrypt }: { redis: Redis; decrypt: Crypt }): GetOIDCProviderData => + async ({ validationToken }: { validationToken: string }) => { + const encryptedProviderData = await redis.get(validationToken) + if (!encryptedProviderData) return null + const provider = oidcProvider.parse( + JSON.parse(await decrypt(encryptedProviderData)) + ) + return provider + } + +export const getWorkspaceSsoProviderFactory = + ({ db, decrypt }: { db: Knex; decrypt: Crypt }): GetWorkspaceSsoProvider => + async ({ workspaceId }) => { + const maybeProvider = await db( + '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 new file mode 100644 index 000000000..bce244e33 --- /dev/null +++ b/packages/server/modules/workspaces/rest/sso.ts @@ -0,0 +1,255 @@ +import { db } from '@/db/knex' +import { validateRequest } from 'zod-express' +import { Router } from 'express' +import { z } from 'zod' +import { + saveSsoProviderRegistrationFactory, + startOIDCSsoProviderValidationFactory +} from '@/modules/workspaces/services/sso' +import { + getOIDCProviderAttributes, + getProviderAuthorizationUrl, + initializeIssuerAndClient +} from '@/modules/workspaces/clients/oidcProvider' +import { + getFrontendOrigin, + getRedisUrl, + getServerOrigin, + getSessionSecret, + isSSLServer +} from '@/modules/shared/helpers/envHelper' +import { + storeOIDCProviderValidationRequestFactory, + 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, 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() + +// 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, + searchParams +}: { + err: unknown + url: URL + searchParams?: Record +}): 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 +} + +router.get( + '/api/v1/workspaces/:workspaceSlug/sso/oidc/validate', + sessionMiddleware, + validateRequest({ + params: z.object({ + workspaceSlug: z.string().min(1) + }), + query: oidcProvider + }), + 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), + searchParams: query + }) + res?.redirect(url.toString()) + } + } +) + +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() }) + }), + 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 }) + + let provider: OIDCProvider | null = null + if (req.query.validate === 'true') { + 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() + + 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, + searchParams: provider || undefined + }) + } finally { + req.session.destroy(noop) + // redirectUrl. + req.res?.redirect(redirectUrl.toString()) + } + } else { + // this must be using the generic OIDC login flow somehow + } + } +) + +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 000000000..4600206ee --- /dev/null +++ b/packages/server/modules/workspaces/services/sso.ts @@ -0,0 +1,117 @@ +import { + GetOIDCProviderAttributes, + OIDCProviderAttributes, + OIDCProvider, + StoreOIDCProviderValidationRequest, + StoreProviderRecord, + StoreUserSsoSession, + OIDCProviderRecord, + AssociateSsoProviderWithWorkspace, + GetWorkspaceSsoProvider +} 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' + +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 +} + +// this probably should go a lean validation endpoint too +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'] +scopes_supported: ['openid', 'profile', 'email'] +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 saveSsoProviderRegistrationFactory = + ({ + getWorkspaceSsoProvider, + storeProviderRecord, + associateSsoProviderWithWorkspace, + storeUserSsoSession + }: // createUserEmail + { + getWorkspaceSsoProvider: GetWorkspaceSsoProvider + storeProviderRecord: StoreProviderRecord + associateSsoProviderWithWorkspace: AssociateSsoProviderWithWorkspace + storeUserSsoSession: StoreUserSsoSession + createUserEmail: CreateUserEmail + }) => + 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 = { + 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 000000000..062cf9999 --- /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') +} diff --git a/packages/server/package.json b/packages/server/package.json index d734223a8..f77c5fd25 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/shared/src/environment/index.ts b/packages/shared/src/environment/index.ts index 968206e08..f191596bf 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 efd2ceebd..75d24d382 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 7353d3084..dc56a1500 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16735,6 +16735,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 @@ -53823,6 +53824,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"