feat(sso): early sso testing

* feat(workspaces): add workspace sso feature flag

* feat(workspaceSso): wip validate sso

* feat(workspaces): validate and add sso provider to the workspace with user sso sessions

* feat(workspaces): validate and add sso provider to the workspace with user sso sessions
This commit is contained in:
Gergő Jedlicska
2024-10-01 18:15:25 +02:00
committed by GitHub
parent 677b8202fa
commit 7fbda629b7
15 changed files with 729 additions and 13 deletions
+2 -2
View File
@@ -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
+14 -7
View File
@@ -4,6 +4,20 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Attach",
"port": 9229,
"request": "attach",
"skipFiles": ["<node_internals>/**"],
"type": "node"
},
{
"name": "Launch node",
"program": "${workspaceFolder}/bin/www",
"request": "launch",
"skipFiles": ["<node_internals>/**"],
"type": "node"
},
{
"name": "Attach by Process ID",
"processId": "${command:PickProcess}",
@@ -66,13 +80,6 @@
"runtimeExecutable": "npm",
"skipFiles": ["<node_internals>/**"],
"type": "node"
},
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/dist/bin/www"
}
]
}
@@ -41,6 +41,7 @@ export type AuthSessionData = {
// More specific params used in OpenID based strategies
tokenSet?: TokenSet
userinfo?: UserinfoResponse
codeVerifier?: string
}
export type AuthRequestData = {
@@ -59,5 +59,5 @@ export const defaultErrorHandler: ErrorRequestHandler = (err, req, res, next) =>
res.status(resolveStatusCode(e)).json({
error: resolveErrorInfo(e)
})
next()
next(err)
}
@@ -0,0 +1,81 @@
/* eslint-disable camelcase */
import { BaseError } from '@/modules/shared/errors'
import { OIDCProvider, OIDCProviderAttributes } from '@/modules/workspaces/domain/sso'
import { generators, Issuer, type Client } from 'openid-client'
export const getProviderAuthorizationUrl = async ({
provider,
redirectUrl,
codeVerifier
}: {
provider: OIDCProvider
redirectUrl: URL
codeVerifier: string
}): Promise<URL> => {
const { client } = await initializeIssuerAndClient({ provider, redirectUrl })
const code_challenge = generators.codeChallenge(codeVerifier)
return new URL(
client.authorizationUrl({
scope: 'openid email profile',
redirect_uri: redirectUrl.toString(),
code_challenge,
code_challenge_method: 'S256'
})
)
}
export const initializeIssuerAndClient = async ({
provider,
redirectUrl
}: {
provider: OIDCProvider
redirectUrl?: URL
}): Promise<{ issuer: Issuer; client: Client }> => {
const issuer = await Issuer.discover(provider.issuerUrl)
const client = new issuer.Client({
client_id: provider.clientId,
client_secret: provider.clientSecret,
redirect_uris: redirectUrl ? [redirectUrl.toString()] : [],
response_types: ['code']
})
return { issuer, client }
}
export const getOIDCProviderAttributes = async ({
provider
}: {
provider: OIDCProvider
}): Promise<OIDCProviderAttributes> => {
try {
const { issuer, client } = await initializeIssuerAndClient({ provider })
return {
issuer: {
claimsSupported: (issuer.claims_supported as string[] | undefined) ?? [],
grantTypesSupported:
(issuer.grant_types_supported as string[] | undefined) ?? [],
responseTypesSupported:
(issuer.response_types_supported as string[] | undefined) ?? []
},
client: {
grantTypes: (client.grant_types as string[] | undefined) ?? []
}
}
} catch (err) {
if (err instanceof Error) {
if ('code' in err) {
if (err.code === 'ECONNREFUSED')
throw new BaseError(
'cannot connect to the provider, pls check the connection url',
err
)
} else if ('error' in err) {
if (err.error === 'Realm does not exist')
throw new BaseError(
"The realm doesn't exist, please check your url and OIDC config",
err
)
}
}
throw err
}
}
@@ -0,0 +1,85 @@
import { z } from 'zod'
export const oidcProvider = z.object({
providerName: z.string().min(1),
clientId: z.string().min(5),
clientSecret: z.string().min(1),
issuerUrl: z.string().min(1).url()
})
export type OIDCProvider = z.infer<typeof oidcProvider>
type ProviderBaseRecord = {
id: string
createdAt: Date
updatedAt: Date
}
export type OIDCProviderRecord = {
providerType: 'oidc'
provider: OIDCProvider
} & ProviderBaseRecord
// since storage is encrypted and provider data should be stored as a json string,
// this record type could be extended to be a union for other provider types too, like SAML
export type ProviderRecord = OIDCProviderRecord
export type StoreProviderRecord = (args: {
providerRecord: ProviderRecord
}) => Promise<void>
export type WorkspaceSsoProvider = {
workspaceId: string
providerId: string
} & ProviderRecord
export type GetWorkspaceSsoProvider = (args: {
workspaceId: string
}) => Promise<WorkspaceSsoProvider | null>
export type UserSsoSession = {
userId: string
providerId: string
createdAt: Date
lifespan: number
}
export type StoreUserSsoSession = (args: {
userSsoSession: UserSsoSession
}) => Promise<void>
export const oidcProviderValidationRequest = z.object({
token: z.string(),
provider: oidcProvider
})
export type OIDCProviderValidationRequest = z.infer<
typeof oidcProviderValidationRequest
>
export type OIDCProviderAttributes = {
issuer: {
claimsSupported: string[]
grantTypesSupported: string[]
responseTypesSupported: string[]
}
client: {
grantTypes: string[]
}
}
export type GetOIDCProviderAttributes = (args: {
provider: OIDCProvider
}) => Promise<OIDCProviderAttributes>
export type StoreOIDCProviderValidationRequest = (
args: OIDCProviderValidationRequest
) => Promise<void>
export type GetOIDCProviderData = (args: {
validationToken: string
}) => Promise<OIDCProvider | null>
export type AssociateSsoProviderWithWorkspace = (args: {
workspaceId: string
providerId: string
}) => Promise<void>
+6 -2
View File
@@ -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()])
@@ -0,0 +1,95 @@
import {
oidcProvider,
GetOIDCProviderData,
StoreOIDCProviderValidationRequest,
StoreProviderRecord,
ProviderRecord,
AssociateSsoProviderWithWorkspace,
StoreUserSsoSession,
UserSsoSession,
GetWorkspaceSsoProvider
} from '@/modules/workspaces/domain/sso'
import Redis from 'ioredis'
import { Knex } from 'knex'
import { omit } from 'lodash'
type Crypt = (input: string) => Promise<string>
type StoredSsoProvider = Omit<ProviderRecord, 'provider'> & {
encryptedProviderData: string
}
type WorkspaceSsoProvider = { workspaceId: string; providerId: string }
const tables = {
ssoProviders: (db: Knex) => db<StoredSsoProvider>('sso_providers'),
userSsoSessions: (db: Knex) => db<UserSsoSession>('user_sso_sessions'),
workspaceSsoProviders: (db: Knex) =>
db<WorkspaceSsoProvider>('workspace_sso_providers')
}
export const storeOIDCProviderValidationRequestFactory =
({
redis,
encrypt
}: {
redis: Redis
encrypt: Crypt
}): StoreOIDCProviderValidationRequest =>
async ({ provider, token }) => {
const providerData = await encrypt(JSON.stringify(provider))
await redis.set(token, providerData)
}
export const getOIDCProviderFactory =
({ redis, decrypt }: { redis: Redis; decrypt: Crypt }): GetOIDCProviderData =>
async ({ validationToken }: { validationToken: string }) => {
const encryptedProviderData = await redis.get(validationToken)
if (!encryptedProviderData) return null
const provider = oidcProvider.parse(
JSON.parse(await decrypt(encryptedProviderData))
)
return provider
}
export const getWorkspaceSsoProviderFactory =
({ db, decrypt }: { db: Knex; decrypt: Crypt }): GetWorkspaceSsoProvider =>
async ({ workspaceId }) => {
const maybeProvider = await db<WorkspaceSsoProvider & StoredSsoProvider>(
'workspace_sso_providers'
)
.where({ workspaceId })
.first()
if (!maybeProvider) return null
const decryptedProviderData = await decrypt(maybeProvider.encryptedProviderData)
switch (maybeProvider.providerType) {
case 'oidc':
return {
...omit(maybeProvider),
provider: oidcProvider.parse(decryptedProviderData)
}
default:
// this is an internal error
throw new Error('Provider type not supported')
}
}
export const storeProviderRecordFactory =
({ db, encrypt }: { db: Knex; encrypt: Crypt }): StoreProviderRecord =>
async ({ providerRecord }) => {
const encryptedProviderData = await encrypt(JSON.stringify(providerRecord.provider))
const insertModel = { ...omit(providerRecord, 'provider'), encryptedProviderData }
await tables.ssoProviders(db).insert(insertModel)
}
export const associateSsoProviderWithWorkspaceFactory =
({ db }: { db: Knex }): AssociateSsoProviderWithWorkspace =>
async ({ providerId, workspaceId }) => {
await tables.workspaceSsoProviders(db).insert({ providerId, workspaceId })
}
// this should be an upsert, if the session exists, we just update the createdAt and lifespan
export const storeUserSsoSessionFactory =
({ db }: { db: Knex }): StoreUserSsoSession =>
async ({ userSsoSession }) => {
await tables.userSsoSessions(db).insert(userSsoSession)
}
@@ -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<string, string>
}): 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
@@ -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<string> => {
// 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()
}
@@ -0,0 +1,49 @@
import { Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
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<void> {
await knex.schema.dropTable('user_sso_sessions')
await knex.schema.dropTable('workspace_sso_providers')
await knex.schema.dropTable('sso_providers')
}
+1
View File
@@ -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"
},
+1
View File
@@ -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
+9 -1
View File
@@ -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/**"
},
+12
View File
@@ -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"