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:
@@ -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
|
||||
|
||||
Vendored
+14
-7
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/**"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user