diff --git a/api/package.json b/api/package.json index 3b79ebc..f34b399 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "taskview-ce-api-server", - "version": "1.41.0", + "version": "1.42.2", "scripts": { "dev": "bun run --watch ./server.ts", "start": "NODE_ENV=production node ./dist/taskview-server.js", diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index 3ad2e1d..a40e0aa 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -451,13 +451,15 @@ "scripts": [ "/1.33.0/0.1.33.0.sql", "/1.33.0/1.add-saml-signing-fields.sql", - "/1.33.0/2.add-saml-logout-url.sql" + "/1.33.0/2.add-saml-logout-url.sql", + "/1.33.0/3.add-scim-fields.sql" ], "description": [ "Added SSO support (SAML 2.0 + OIDC)", "Added sso_configs, sso_identities, saml_request_cache tables", "Added SAML AuthnRequest signing support", - "Added SAML logout URL for SLO" + "Added SAML logout URL for SLO", + "Added SCIM provisioning support" ] } } \ No newline at end of file diff --git a/api/src/migrations/taskview/sql/1.33.0/3.add-scim-fields.sql b/api/src/migrations/taskview/sql/1.33.0/3.add-scim-fields.sql new file mode 100644 index 0000000..8876830 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.33.0/3.add-scim-fields.sql @@ -0,0 +1,2 @@ +ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS scim_token VARCHAR; +ALTER TABLE tv_auth.sso_configs ADD COLUMN IF NOT EXISTS scim_enabled INTEGER NOT NULL DEFAULT 0; diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 98464cb..7715588 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -15,6 +15,7 @@ import TagsRouter from '../tv-modules/tags/TagsRouter'; import TasksRoutes from '../tv-modules/tasks/TasksRoutes'; import OrganizationRoutes from '../tv-modules/organizations/OrganizationRoutes'; import SsoRoutes from '../tv-modules/sso/SsoRoutes'; +import ScimRoutes from '../tv-modules/scim/ScimRoutes'; import type { Routable } from '../types/routable.type'; type RoutableConstructor = new (...args: any[]) => Routable; @@ -37,6 +38,7 @@ const routes: Record = { '/module/sessions': SessionsRoutes, '/module/organizations': OrganizationRoutes, '/module/sso': SsoRoutes, + '/scim/v2': ScimRoutes, }; export default routes; diff --git a/api/src/tv-modules/auth/AuthController.ts b/api/src/tv-modules/auth/AuthController.ts index 284354b..5e02972 100644 --- a/api/src/tv-modules/auth/AuthController.ts +++ b/api/src/tv-modules/auth/AuthController.ts @@ -1,4 +1,5 @@ import { compare, hashSync } from 'bcryptjs'; +import { randomInt } from 'crypto'; import type { Request, Response } from 'express'; import jwt, { type Algorithm, decode } from 'jsonwebtoken'; import { z } from 'zod'; @@ -89,15 +90,13 @@ export default class AuthController { } makeidLogin(length: number) { - let result = ''; - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const charactersLength = characters.length; - let counter = 0; - while (counter < length) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - counter += 1; + let result = '' + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const charactersLength = characters.length + for (let i = 0; i < length; i++) { + result += characters.charAt(randomInt(charactersLength)) } - return result; + return result } generateEmailConfirmCode() { @@ -329,6 +328,9 @@ export default class AuthController { return res.status(400).send({ message: 'Code expired, get new code' }); } + // Invalidate code immediately to prevent replay attacks + await req.appUser.authManager.repository.updateLoginCode(null, userData.email); + const sessionId = await req.appUser.authManager.sessionStorage.createSession( userData.id, req.ip, @@ -343,8 +345,6 @@ export default class AuthController { userData, } as const); - await req.appUser.authManager.repository.updateLoginCode(null, userData.email); - await this.setRefreshToken(res, tokens.refresh); return res.json(tokens); @@ -401,7 +401,7 @@ export default class AuthController { email = (email as string).toLowerCase(); if (!isEmail(email)) { - return res.status(40).end(); + return res.status(400).end(); } password = hashSync(password, 10); @@ -438,7 +438,7 @@ export default class AuthController { emailTemplate = EnEmailTemplate; } - const confirmUrl = `https://${process.env.APP_URL}/module/auth/confirm/email/${confirmEmailCode}/login/${login}`; + const confirmUrl = `${process.env.APP_URL}/module/auth/confirm/email/${confirmEmailCode}/login/${login}`; if (emailTemplate) { confirmEmailBody = emailTemplate.replace('{link}', confirmUrl); diff --git a/api/src/tv-modules/organizations/OrganizationController.ts b/api/src/tv-modules/organizations/OrganizationController.ts index 9646f8d..ded8132 100644 --- a/api/src/tv-modules/organizations/OrganizationController.ts +++ b/api/src/tv-modules/organizations/OrganizationController.ts @@ -4,7 +4,6 @@ import { logError } from '../../utils/api' import { OrganizationArkTypeCreate, OrganizationArkTypeUpdate, - OrganizationArkTypeDelete, OrganizationMemberArkTypeAdd, OrganizationMemberArkTypeUpdateRole, OrganizationMemberArkTypeRemove, @@ -22,22 +21,23 @@ export class OrganizationController { } update = async (req: Request, res: Response) => { + const orgId = Number(req.params.orgId) + if (!orgId) return res.status(400).end() + const out = OrganizationArkTypeUpdate(req.body) if (out instanceof type.errors) { return res.status(400).send(out.summary) } - const org = await req.appUser.organizationManager.update(out).catch(logError) + const org = await req.appUser.organizationManager.update({ ...out, organizationId: orgId }).catch(logError) return res.tvJson(org ?? null) } delete = async (req: Request, res: Response) => { - const out = OrganizationArkTypeDelete(req.body) - if (out instanceof type.errors) { - return res.status(400).send(out.summary) - } + const orgId = Number(req.params.orgId) + if (!orgId) return res.status(400).end() - const result = await req.appUser.organizationManager.delete(out.organizationId).catch(logError) + const result = await req.appUser.organizationManager.delete(orgId).catch(logError) return res.tvJson(!!result) } diff --git a/api/src/tv-modules/organizations/OrganizationManager.ts b/api/src/tv-modules/organizations/OrganizationManager.ts index 61f65c6..977fb51 100644 --- a/api/src/tv-modules/organizations/OrganizationManager.ts +++ b/api/src/tv-modules/organizations/OrganizationManager.ts @@ -56,6 +56,7 @@ export class OrganizationManager { const userId = this.getUserId() if (org.ownerId !== userId) return false + await this.repository.invalidateSessionsForOrgOnlyMembers(orgId) return await this.repository.delete(orgId) } diff --git a/api/src/tv-modules/organizations/OrganizationRepository.ts b/api/src/tv-modules/organizations/OrganizationRepository.ts index 8633cc1..b1de1ae 100644 --- a/api/src/tv-modules/organizations/OrganizationRepository.ts +++ b/api/src/tv-modules/organizations/OrganizationRepository.ts @@ -1,10 +1,12 @@ -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, ne } from 'drizzle-orm' import { OrganizationsSchema, OrganizationMembersSchema, CollaborationUsersSchema, CollaborationUsersToGoalsSchema, GoalsSchema, + UsersSchema, + UserTokensSchema, type OrganizationsSchemaTypeForSelect, type OrganizationMembersSchemaTypeForSelect, } from 'taskview-db-schemas' @@ -55,6 +57,28 @@ export class OrganizationRepository { return result[0] } + async invalidateSessionsForOrgOnlyMembers(orgId: number): Promise { + await callWithCatch(() => + this.db.dbDrizzle.delete(UserTokensSchema).where( + inArray( + UserTokensSchema.userId, + this.db.dbDrizzle + .select({ id: UsersSchema.id }) + .from(UsersSchema) + .innerJoin(OrganizationMembersSchema, eq(OrganizationMembersSchema.email, UsersSchema.email)) + .where(eq(OrganizationMembersSchema.organizationId, orgId)) + .except( + this.db.dbDrizzle + .select({ id: UsersSchema.id }) + .from(UsersSchema) + .innerJoin(OrganizationMembersSchema, eq(OrganizationMembersSchema.email, UsersSchema.email)) + .where(ne(OrganizationMembersSchema.organizationId, orgId)) + ) + ) + ) + ) + } + async delete(orgId: number): Promise { const result = await callWithCatch(() => this.db.dbDrizzle diff --git a/api/src/tv-modules/organizations/OrganizationRoutes.ts b/api/src/tv-modules/organizations/OrganizationRoutes.ts index c1888f1..c4da6ef 100644 --- a/api/src/tv-modules/organizations/OrganizationRoutes.ts +++ b/api/src/tv-modules/organizations/OrganizationRoutes.ts @@ -23,13 +23,14 @@ export default class OrganizationRoutes implements Routable { initRoutes() { this.router.post('', [IsLoggedIn], this.controller.create) this.router.get('', [IsLoggedIn], this.controller.fetch) - this.router.get('/:orgId', [IsLoggedIn, IsOrgMember], this.controller.getById) - this.router.patch('', [IsLoggedIn, IsOrgAdmin], this.controller.update) - this.router.delete('', [IsLoggedIn, IsOrgOwner], this.controller.delete) - this.router.get('/:orgId/members', [IsLoggedIn, IsOrgAdmin], this.controller.fetchMembers) this.router.post('/members', [IsLoggedIn, IsOrgAdmin], this.controller.addMember) this.router.patch('/members/role', [IsLoggedIn, IsOrgAdmin], this.controller.updateMemberRole) this.router.delete('/members', [IsLoggedIn, IsOrgAdmin], this.controller.removeMember) + + this.router.get('/:orgId', [IsLoggedIn, IsOrgMember], this.controller.getById) + this.router.patch('/:orgId', [IsLoggedIn, IsOrgAdmin], this.controller.update) + this.router.delete('/:orgId', [IsLoggedIn, IsOrgOwner], this.controller.delete) + this.router.get('/:orgId/members', [IsLoggedIn, IsOrgAdmin], this.controller.fetchMembers) } } diff --git a/api/src/tv-modules/organizations/middlewares/IsOrgAdmin.ts b/api/src/tv-modules/organizations/middlewares/IsOrgAdmin.ts index 4775e96..c767680 100644 --- a/api/src/tv-modules/organizations/middlewares/IsOrgAdmin.ts +++ b/api/src/tv-modules/organizations/middlewares/IsOrgAdmin.ts @@ -2,7 +2,7 @@ import type { NextFunction, Request, Response } from 'express' import { ORG_ADMIN_ROLES, type OrgRole } from '../types' export const IsOrgAdmin = async (req: Request, res: Response, next: NextFunction) => { - const orgId = Number(req.params.orgId || req.body.organizationId) + const orgId = Number(req.params.orgId || req.body.organizationId || req.query.organizationId) if (!orgId) return res.status(400).end() const member = await req.appUser.organizationManager.getCurrentUserMember(orgId) diff --git a/api/src/tv-modules/organizations/types.ts b/api/src/tv-modules/organizations/types.ts index a690d35..1d72b2e 100644 --- a/api/src/tv-modules/organizations/types.ts +++ b/api/src/tv-modules/organizations/types.ts @@ -20,19 +20,12 @@ export const OrganizationArkTypeCreate = type({ export type OrganizationArgCreate = typeof OrganizationArkTypeCreate.infer export const OrganizationArkTypeUpdate = type({ - organizationId: 'number', 'name?': 'string', 'slug?': 'string', 'logoUrl?': 'string | null', }) -export type OrganizationArgUpdate = typeof OrganizationArkTypeUpdate.infer - -export const OrganizationArkTypeDelete = type({ - organizationId: 'number', -}) - -export type OrganizationArgDelete = typeof OrganizationArkTypeDelete.infer +export type OrganizationArgUpdate = typeof OrganizationArkTypeUpdate.infer & { organizationId: number } export const OrganizationMemberArkTypeAdd = type({ organizationId: 'number', diff --git a/api/src/tv-modules/scim/ScimController.ts b/api/src/tv-modules/scim/ScimController.ts new file mode 100644 index 0000000..9322da0 --- /dev/null +++ b/api/src/tv-modules/scim/ScimController.ts @@ -0,0 +1,89 @@ +import type { Request, Response } from 'express' +import { $logger } from '../../modules/logget' +import { ScimManager } from './ScimManager' +import { toScimUser, toScimList, toScimError } from './scim.helpers' + +export class ScimController { + private readonly manager = new ScimManager() + + listUsers = async (_req: Request, res: Response) => { + const { scimOrg } = res.locals + const members = await this.manager.listUsers(scimOrg.id) + + return res.json(toScimList(members.map(m => toScimUser(m, true)))) + } + + getUser = async (req: Request, res: Response) => { + const { scimOrg } = res.locals + const email = decodeURIComponent(req.params.id) + + const member = await this.manager.getUserByEmail(scimOrg.id, email) + if (!member) { + return res.status(404).json(toScimError(404, 'User not found')) + } + + return res.json(toScimUser(member, true)) + } + + createUser = async (req: Request, res: Response) => { + const { scimOrg } = res.locals + const email = req.body.userName || req.body.emails?.[0]?.value + + if (!email) { + return res.status(400).json(toScimError(400, 'userName or emails[0].value is required')) + } + + await this.manager.reactivateUser(scimOrg, email) + + const member = await this.manager.getUserByEmail(scimOrg.id, email) + if (!member) { + return res.status(500).json(toScimError(500, 'Failed to create user')) + } + + return res.status(201).json(toScimUser(member, true)) + } + + patchUser = async (req: Request, res: Response) => { + const { scimOrg, scimConfig } = res.locals + const email = decodeURIComponent(req.params.id) + + const operations = req.body.Operations || [] + + for (const op of operations) { + if (op.op === 'replace' && (op.path === 'active' || op.value?.active !== undefined)) { + const active = op.path === 'active' ? op.value : op.value.active + + if (active === false || active === 'false') { + const result = await this.manager.deactivateUser(scimOrg, scimConfig, email) + if (!result) { + return res.status(404).json(toScimError(404, 'User not found')) + } + $logger.info(`SCIM: deactivated user ${email} from org ${scimOrg.id}`) + return res.json(toScimUser({ email, role: 'member' }, false)) + } + + if (active === true || active === 'true') { + await this.manager.reactivateUser(scimOrg, email) + $logger.info(`SCIM: reactivated user ${email} in org ${scimOrg.id}`) + const member = await this.manager.getUserByEmail(scimOrg.id, email) + return res.json(toScimUser(member || { email, role: 'member' }, true)) + } + } + } + + return res.status(200).json(toScimUser({ email, role: 'member' }, true)) + } + + deleteUser = async (req: Request, res: Response) => { + const { scimOrg, scimConfig } = res.locals + const email = decodeURIComponent(req.params.id) + + const result = await this.manager.deactivateUser(scimOrg, scimConfig, email) + if (!result) { + return res.status(404).json(toScimError(404, 'User not found')) + } + + $logger.info(`SCIM: deleted user ${email} from org ${scimOrg.id}`) + return res.status(204).end() + } +} diff --git a/api/src/tv-modules/scim/ScimManager.ts b/api/src/tv-modules/scim/ScimManager.ts new file mode 100644 index 0000000..a14bfc8 --- /dev/null +++ b/api/src/tv-modules/scim/ScimManager.ts @@ -0,0 +1,50 @@ +import type { SsoConfigsSchemaTypeForSelect, OrganizationsSchemaTypeForSelect } from 'taskview-db-schemas' +import AuthModel from '../auth/AuthModel' +import SessionStorage from '../auth/SessionStorage' +import { OrganizationRepository } from '../organizations/OrganizationRepository' +import { SsoRepository } from '../sso/SsoRepository' + +export class ScimManager { + private readonly orgRepo = new OrganizationRepository() + private readonly ssoRepo = new SsoRepository() + private readonly authModel = new AuthModel() + private readonly sessionStorage = new SessionStorage() + + async deactivateUser( + org: OrganizationsSchemaTypeForSelect, + config: SsoConfigsSchemaTypeForSelect, + email: string, + ) { + const member = await this.orgRepo.getMemberByEmail(org.id, email) + if (!member) return false + + const user = await this.authModel.fetchUserByEmail(email) + + await this.orgRepo.removeMember(org.id, email) + await this.orgRepo.removeUserFromOrgGoals(org.id, email) + + if (user) { + await this.ssoRepo.deleteIdentityByUser(user.id, config.id) + await this.sessionStorage.deleteAllSessions(user.id) + } + + return true + } + + async reactivateUser( + org: OrganizationsSchemaTypeForSelect, + email: string, + role: string = 'member', + ) { + await this.orgRepo.addMember(org.id, email, role) + return true + } + + async listUsers(orgId: number) { + return await this.orgRepo.fetchMembers(orgId) + } + + async getUserByEmail(orgId: number, email: string) { + return await this.orgRepo.getMemberByEmail(orgId, email) + } +} diff --git a/api/src/tv-modules/scim/ScimRoutes.ts b/api/src/tv-modules/scim/ScimRoutes.ts new file mode 100644 index 0000000..f88efc8 --- /dev/null +++ b/api/src/tv-modules/scim/ScimRoutes.ts @@ -0,0 +1,29 @@ +import { Router } from 'express' +import type { Routable } from '../../types/routable.type' +import { ScimAuth } from './middlewares/ScimAuth' +import { ScimController } from './ScimController' + +export default class ScimRoutes implements Routable { + private readonly router: ReturnType + private readonly controller: ScimController + + constructor() { + this.router = Router() + this.controller = new ScimController() + this.initRoutes() + } + + getRouter() { + return this.router + } + + initRoutes() { + this.router.use(ScimAuth) + + this.router.get('/Users', this.controller.listUsers) + this.router.get('/Users/:id', this.controller.getUser) + this.router.post('/Users', this.controller.createUser) + this.router.patch('/Users/:id', this.controller.patchUser) + this.router.delete('/Users/:id', this.controller.deleteUser) + } +} diff --git a/api/src/tv-modules/scim/middlewares/ScimAuth.ts b/api/src/tv-modules/scim/middlewares/ScimAuth.ts new file mode 100644 index 0000000..3380eeb --- /dev/null +++ b/api/src/tv-modules/scim/middlewares/ScimAuth.ts @@ -0,0 +1,44 @@ +import { createHash } from 'crypto' +import type { NextFunction, Request, Response } from 'express' +import { SsoRepository } from '../../sso/SsoRepository' +import { OrganizationRepository } from '../../organizations/OrganizationRepository' + +const ssoRepo = new SsoRepository() +const orgRepo = new OrganizationRepository() + +export const ScimAuth = async (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ + schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], + detail: 'Missing or invalid authorization header', + status: '401', + }) + } + + const token = authHeader.slice(7) + const hashedToken = createHash('sha256').update(token).digest('hex') + + const config = await ssoRepo.findByScimToken(hashedToken) + if (!config) { + return res.status(401).json({ + schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], + detail: 'Invalid SCIM token', + status: '401', + }) + } + + const org = await orgRepo.findById(config.organizationId) + if (!org) { + return res.status(401).json({ + schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], + detail: 'Organization not found', + status: '401', + }) + } + + res.locals.scimOrg = org + res.locals.scimConfig = config + + next() +} diff --git a/api/src/tv-modules/scim/scim.helpers.ts b/api/src/tv-modules/scim/scim.helpers.ts new file mode 100644 index 0000000..a729029 --- /dev/null +++ b/api/src/tv-modules/scim/scim.helpers.ts @@ -0,0 +1,31 @@ +const SCIM_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User' +const SCIM_LIST_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse' +const SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error' + +export function toScimUser(member: { email: string; role: string }, active: boolean) { + return { + schemas: [SCIM_SCHEMA], + id: member.email, + userName: member.email, + emails: [{ value: member.email, primary: true }], + active, + roles: [{ value: member.role }], + } +} + +export function toScimList(resources: ReturnType[]) { + return { + schemas: [SCIM_LIST_SCHEMA], + totalResults: resources.length, + Resources: resources, + } +} + +export function toScimError(status: number, detail: string) { + return { + schemas: [SCIM_ERROR_SCHEMA], + detail, + status: String(status), + } +} + diff --git a/api/src/tv-modules/sso/SsoController.ts b/api/src/tv-modules/sso/SsoController.ts index 144253a..8a6451b 100644 --- a/api/src/tv-modules/sso/SsoController.ts +++ b/api/src/tv-modules/sso/SsoController.ts @@ -1,29 +1,18 @@ +import { createHash, randomBytes } from 'crypto' import { type } from 'arktype' import { hashSync } from 'bcryptjs' import type { Request, Response } from 'express' import { $logger } from '../../modules/logget' import { logError } from '../../utils/api' -import { isEmail } from '../../utils/helpers' +import { generateString, isEmail } from '../../utils/helpers' import AuthModel from '../auth/AuthModel' import { OrganizationRepository } from '../organizations/OrganizationRepository' import { createSsoProvider } from './providers/provider-factory' import { SsoRepository } from './SsoRepository' import { parseSamlMetadata } from './saml-metadata-parser' +import { generateLoginCode, stripSecrets, validateMetadataUrl } from './sso.utils' import { SsoConfigArkTypeCreate, SsoConfigArkTypeUpdate } from './types' -function generateRandomString(length: number): string { - let result = '' - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)) - } - return result -} - -function generateLoginCode(): string { - return `${generateRandomString(12)}:${Date.now()}`.toLowerCase() -} - export class SsoController { private readonly ssoRepo = new SsoRepository() private readonly authModel = new AuthModel() @@ -67,8 +56,8 @@ export class SsoController { let userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email)) if (!userData) { - const password = generateRandomString(16) - const login = generateRandomString(7) + const password = generateString(16) + const login = generateString(7) const id = await this.authModel.registerUserInDb({ login, email: ssoResult.email, @@ -114,9 +103,13 @@ export class SsoController { const encodedAuthData = encodeURIComponent(JSON.stringify(authData)) try { - const relayState = req.body?.RelayState || req.query?.state + let relayState = req.body?.RelayState as string | undefined + if (!relayState && req.query?.state) { + const stateData = JSON.parse(req.query.state as string) + relayState = stateData.relay + } if (relayState) { - const platformData = JSON.parse(relayState as string) + const platformData = JSON.parse(relayState) if (platformData.platform === 'mobile') { return res.redirect(`taskview://login?tokens=${encodedAuthData}`) } @@ -149,7 +142,7 @@ export class SsoController { if (!orgId) return res.status(400).tvJson({ message: 'organizationId is required' }) const configs = await this.ssoRepo.listByOrgId(orgId) - return res.tvJson(configs) + return res.tvJson(configs.map(stripSecrets)) } createConfig = async (req: Request, res: Response) => { @@ -167,7 +160,7 @@ export class SsoController { if (!config) { return res.status(500).tvJson({ message: 'Failed to create SSO config' }) } - return res.tvJson(config) + return res.tvJson(stripSecrets(config)) } updateConfig = async (req: Request, res: Response) => { @@ -180,15 +173,18 @@ export class SsoController { } const config = await req.appUser.ssoManager.updateConfig(configId, out).catch(logError) - return res.tvJson(config ?? null) + return res.tvJson(config ? stripSecrets(config) : null) } parseMetadata = async (req: Request, res: Response) => { const metadataUrl = req.query.url as string if (!metadataUrl) return res.status(400).tvJson({ message: 'url is required' }) + const urlError = validateMetadataUrl(metadataUrl) + if (urlError) return res.status(400).tvJson({ message: urlError }) + try { - const response = await fetch(metadataUrl) + const response = await fetch(metadataUrl, { redirect: 'error' }) if (!response.ok) { return res.status(400).tvJson({ message: `Failed to fetch metadata: ${response.status}` }) } @@ -203,6 +199,43 @@ export class SsoController { } } + generateScimToken = async (req: Request, res: Response) => { + const configId = Number(req.params.configId) + if (!configId) return res.status(400).end() + + const rawToken = `tvscim_${randomBytes(32).toString('hex')}` + const hashedToken = createHash('sha256').update(rawToken).digest('hex') + + const config = await this.ssoRepo.update(configId, { + scimToken: hashedToken, + scimEnabled: 1, + }) + + if (!config) { + return res.status(404).tvJson({ message: 'SSO config not found' }) + } + + return res.tvJson({ token: rawToken }) + } + + toggleScim = async (req: Request, res: Response) => { + const configId = Number(req.params.configId) + if (!configId) return res.status(400).end() + + const enabled = req.body.enabled ? 1 : 0 + + const config = await this.ssoRepo.update(configId, { + scimEnabled: enabled, + ...(enabled === 0 ? { scimToken: null } : {}), + }) + + if (!config) { + return res.status(404).tvJson({ message: 'SSO config not found' }) + } + + return res.tvJson({ scimEnabled: config.scimEnabled }) + } + deleteConfig = async (req: Request, res: Response) => { const configId = Number(req.params.configId) if (!configId) return res.status(400).end() diff --git a/api/src/tv-modules/sso/SsoManager.ts b/api/src/tv-modules/sso/SsoManager.ts index 1d8ead9..7c49379 100644 --- a/api/src/tv-modules/sso/SsoManager.ts +++ b/api/src/tv-modules/sso/SsoManager.ts @@ -1,5 +1,7 @@ import type { AppUser } from '../../core/AppUser' +import { encrypt, encryptField } from '../../utils/crypto' import { SsoRepository } from './SsoRepository' +import { SSO_SECRET_FIELDS } from './sso.utils' import type { SsoConfigArgCreate, SsoConfigArgUpdate } from './types' export class SsoManager { @@ -23,14 +25,14 @@ export class SsoManager { enabled: data.enabled ?? 1, samlEntryPoint: data.samlEntryPoint ?? null, samlIssuer: data.samlIssuer ?? null, - samlCert: data.samlCert ?? null, + samlCert: encryptField(data.samlCert), samlCallbackUrl: data.samlCallbackUrl ?? null, - samlSigningKey: data.samlSigningKey ?? null, - samlSigningCert: data.samlSigningCert ?? null, + samlSigningKey: encryptField(data.samlSigningKey), + samlSigningCert: encryptField(data.samlSigningCert), samlLogoutUrl: data.samlLogoutUrl ?? null, oidcIssuer: data.oidcIssuer ?? null, oidcClientId: data.oidcClientId ?? null, - oidcClientSecret: data.oidcClientSecret ?? null, + oidcClientSecret: encryptField(data.oidcClientSecret), oidcCallbackUrl: data.oidcCallbackUrl ?? null, oidcScope: data.oidcScope ?? null, defaultOrgRole: data.defaultOrgRole ?? 'member', @@ -39,7 +41,17 @@ export class SsoManager { } async updateConfig(configId: number, data: SsoConfigArgUpdate) { - return await this.repository.update(configId, data) + const encrypted: Partial = { ...data } + for (const field of SSO_SECRET_FIELDS) { + if (field in encrypted) { + if (encrypted[field]) { + encrypted[field] = encrypt(encrypted[field]!) + } else { + delete encrypted[field] + } + } + } + return await this.repository.update(configId, encrypted) } async deleteConfig(configId: number) { diff --git a/api/src/tv-modules/sso/SsoRepository.ts b/api/src/tv-modules/sso/SsoRepository.ts index 1ff1133..75f7d2a 100644 --- a/api/src/tv-modules/sso/SsoRepository.ts +++ b/api/src/tv-modules/sso/SsoRepository.ts @@ -116,6 +116,36 @@ export class SsoRepository { return !!(result?.rowCount && result.rowCount > 0) } + async findByScimToken(hashedToken: string): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle + .select() + .from(SsoConfigsSchema) + .where( + and( + eq(SsoConfigsSchema.scimToken, hashedToken), + eq(SsoConfigsSchema.scimEnabled, 1), + ) + ) + ) + if (!result || result.length === 0) return null + return result[0] + } + + async deleteIdentityByUser(userId: number, ssoConfigId: number): Promise { + const result = await callWithCatch(() => + this.db.dbDrizzle + .delete(SsoIdentitiesSchema) + .where( + and( + eq(SsoIdentitiesSchema.userId, userId), + eq(SsoIdentitiesSchema.ssoConfigId, ssoConfigId), + ) + ) + ) + return !!(result?.rowCount && result.rowCount > 0) + } + async upsertIdentity(data: { userId: number ssoConfigId: number diff --git a/api/src/tv-modules/sso/SsoRoutes.ts b/api/src/tv-modules/sso/SsoRoutes.ts index c6dbf10..e60a63e 100644 --- a/api/src/tv-modules/sso/SsoRoutes.ts +++ b/api/src/tv-modules/sso/SsoRoutes.ts @@ -25,10 +25,12 @@ export default class SsoRoutes implements Routable { this.router.get('/callback/:configId', this.controller.handleCallback) this.router.post('/callback/:configId', this.controller.handleCallback) - this.router.get('/admin/metadata', [IsLoggedIn], this.controller.parseMetadata) - this.router.get('/admin/configs', [IsLoggedIn], this.controller.listConfigs) + this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin], this.controller.parseMetadata) + this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.listConfigs) this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.createConfig) this.router.patch('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.updateConfig) this.router.delete('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.deleteConfig) + this.router.post('/admin/configs/:configId/scim-token', [IsLoggedIn, IsSsoConfigAdmin], this.controller.generateScimToken) + this.router.patch('/admin/configs/:configId/scim', [IsLoggedIn, IsSsoConfigAdmin], this.controller.toggleScim) } } diff --git a/api/src/tv-modules/sso/providers/oidc.provider.ts b/api/src/tv-modules/sso/providers/oidc.provider.ts index 4a520db..f567cde 100644 --- a/api/src/tv-modules/sso/providers/oidc.provider.ts +++ b/api/src/tv-modules/sso/providers/oidc.provider.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto' import * as client from 'openid-client' import type { Request, Response } from 'express' import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas' @@ -32,6 +33,13 @@ export class OidcProvider implements SsoProvider { const scope = this.config.oidcScope ?? 'openid email profile' const codeVerifier = client.randomPKCECodeVerifier() const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier) + const csrfToken = randomBytes(32).toString('hex') + const nonce = randomBytes(32).toString('hex') + + const statePayload = JSON.stringify({ + csrf: csrfToken, + relay: relayState ?? '', + }) res.cookie(`sso_cv_${this.config.id}`, codeVerifier, { httpOnly: true, @@ -40,18 +48,30 @@ export class OidcProvider implements SsoProvider { maxAge: 5 * 60 * 1000, }) + res.cookie(`sso_state_${this.config.id}`, csrfToken, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 5 * 60 * 1000, + }) + + res.cookie(`sso_nonce_${this.config.id}`, nonce, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 5 * 60 * 1000, + }) + const params = new URLSearchParams({ redirect_uri: this.config.oidcCallbackUrl!, scope, code_challenge: codeChallenge, code_challenge_method: 'S256', response_type: 'code', + state: statePayload, + nonce, }) - if (relayState) { - params.set('state', relayState) - } - const authUrl = client.buildAuthorizationUrl(config, params) res.redirect(authUrl.href) } @@ -59,15 +79,41 @@ export class OidcProvider implements SsoProvider { async handleCallback(req: Request): Promise { const config = await this.getOidcConfig() const codeVerifier = req.cookies[`sso_cv_${this.config.id}`] + const storedCsrf = req.cookies[`sso_state_${this.config.id}`] + const storedNonce = req.cookies[`sso_nonce_${this.config.id}`] if (!codeVerifier) { throw new Error('Missing PKCE code verifier — session may have expired') } + if (!storedCsrf) { + throw new Error('Missing CSRF state — session may have expired') + } + + if (!storedNonce) { + throw new Error('Missing nonce — session may have expired') + } + + const returnedState = req.query.state as string | undefined + if (!returnedState) { + throw new Error('Missing state parameter in callback') + } + + let statePayload: { csrf: string, relay: string } + try { + statePayload = JSON.parse(returnedState) + } catch { + throw new Error('Invalid state parameter format') + } + + if (statePayload.csrf !== storedCsrf) { + throw new Error('CSRF state mismatch — possible CSRF attack') + } + const currentUrl = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`) const tokens = await client.authorizationCodeGrant(config, currentUrl, { pkceCodeVerifier: codeVerifier, - expectedState: req.query.state as string | undefined, + expectedState: returnedState, }) const claims = tokens.claims() @@ -76,6 +122,10 @@ export class OidcProvider implements SsoProvider { throw new Error('OIDC token missing email claim') } + if (claims.nonce !== storedNonce) { + throw new Error('Nonce mismatch — possible token replay attack') + } + return { email: (claims.email as string).toLowerCase(), externalId: claims.sub, diff --git a/api/src/tv-modules/sso/providers/provider-factory.ts b/api/src/tv-modules/sso/providers/provider-factory.ts index 442877f..5302527 100644 --- a/api/src/tv-modules/sso/providers/provider-factory.ts +++ b/api/src/tv-modules/sso/providers/provider-factory.ts @@ -1,15 +1,17 @@ import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas' +import { decryptSsoConfig } from '../sso.utils' import type { SsoProvider } from './sso-provider.interface' import { SamlProvider } from './saml.provider' import { OidcProvider } from './oidc.provider' export function createSsoProvider(config: SsoConfigsSchemaTypeForSelect): SsoProvider { - switch (config.protocol) { + const decrypted = decryptSsoConfig(config) + switch (decrypted.protocol) { case 'saml': - return new SamlProvider(config) + return new SamlProvider(decrypted) case 'oidc': - return new OidcProvider(config) + return new OidcProvider(decrypted) default: - throw new Error(`Unsupported SSO protocol: ${config.protocol}`) + throw new Error(`Unsupported SSO protocol: ${decrypted.protocol}`) } } diff --git a/api/src/tv-modules/sso/sso.utils.ts b/api/src/tv-modules/sso/sso.utils.ts new file mode 100644 index 0000000..fbc8ac8 --- /dev/null +++ b/api/src/tv-modules/sso/sso.utils.ts @@ -0,0 +1,71 @@ +import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas' +import { decryptField } from '../../utils/crypto' +import { generateString } from '../../utils/helpers' + +export const SSO_SECRET_FIELDS = ['samlCert', 'samlSigningKey', 'samlSigningCert', 'oidcClientSecret'] as const + +export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) { + const { samlCert, samlSigningKey, samlSigningCert, oidcClientSecret, scimToken, ...safe } = config + return { + ...safe, + hasSamlCert: !!samlCert, + hasSamlSigningKey: !!samlSigningKey, + hasSamlSigningCert: !!samlSigningCert, + hasOidcClientSecret: !!oidcClientSecret, + hasScimToken: !!scimToken, + } +} + +export function decryptSsoConfig(config: SsoConfigsSchemaTypeForSelect): SsoConfigsSchemaTypeForSelect { + return { + ...config, + samlCert: decryptField(config.samlCert), + samlSigningKey: decryptField(config.samlSigningKey), + samlSigningCert: decryptField(config.samlSigningCert), + oidcClientSecret: decryptField(config.oidcClientSecret), + } +} + +export function generateLoginCode(): string { + return `${generateString(12)}:${Date.now()}`.toLowerCase() +} + +const BLOCKED_HOSTNAMES = ['localhost', '127.0.0.1', '0.0.0.0', '[::1]'] +const PRIVATE_IP_RANGES = [ + /^10\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^169\.254\./, + /^fc00:/, + /^fd/, + /^fe80:/, +] + +export function validateMetadataUrl(url: string): string | null { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return 'Invalid URL format' + } + + if (parsed.protocol !== 'https:' && process.env.NODE_ENV === 'production') { + return 'Only HTTPS URLs are allowed' + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return 'Only HTTP(S) URLs are allowed' + } + + if (BLOCKED_HOSTNAMES.includes(parsed.hostname)) { + return 'Localhost URLs are not allowed' + } + + for (const range of PRIVATE_IP_RANGES) { + if (range.test(parsed.hostname)) { + return 'Private IP addresses are not allowed' + } + } + + return null +} diff --git a/api/src/tv-modules/sso/types.ts b/api/src/tv-modules/sso/types.ts index e73c6d1..a519921 100644 --- a/api/src/tv-modules/sso/types.ts +++ b/api/src/tv-modules/sso/types.ts @@ -27,7 +27,7 @@ export const SsoConfigArkTypeCreate = type({ 'oidcCallbackUrl?': 'string', 'oidcScope?': 'string', - 'defaultOrgRole?': 'string', + 'defaultOrgRole?': "'admin' | 'member'", emailDomainRestriction: 'string > 0', }) @@ -51,7 +51,7 @@ export const SsoConfigArkTypeUpdate = type({ 'oidcCallbackUrl?': 'string', 'oidcScope?': 'string', - 'defaultOrgRole?': 'string', + 'defaultOrgRole?': "'admin' | 'member'", 'emailDomainRestriction?': 'string', }) diff --git a/api/src/utils/crypto.ts b/api/src/utils/crypto.ts index ee3168d..c1b6478 100644 --- a/api/src/utils/crypto.ts +++ b/api/src/utils/crypto.ts @@ -1,4 +1,5 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; +import { $logger } from '../modules/logget'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; @@ -31,3 +32,18 @@ export function decrypt(encrypted: string): string { decipher.setAuthTag(authTag); return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); } + +export function encryptField(value: string | null | undefined): string | null { + if (!value) return null + return encrypt(value) +} + +export function decryptField(value: string | null): string | null { + if (!value) return null + try { + return decrypt(value) + } catch { + $logger.warn('Failed to decrypt field — returning raw value (possible migration or key mismatch)') + return value + } +} diff --git a/api/src/utils/helpers.ts b/api/src/utils/helpers.ts index 4dd8bae..f8c1f21 100644 --- a/api/src/utils/helpers.ts +++ b/api/src/utils/helpers.ts @@ -1,3 +1,4 @@ +import { randomInt } from 'crypto'; import { UAParser } from 'ua-parser-js'; import { $logger } from '../modules/logget'; @@ -8,15 +9,13 @@ export function isEmail(email: string): boolean { } export function generateString(length: number) { - let result = ''; - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const charactersLength = characters.length; - let counter = 0; - while (counter < length) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - counter += 1; + let result = '' + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const charactersLength = characters.length + for (let i = 0; i < length; i++) { + result += characters.charAt(randomInt(charactersLength)) } - return result; + return result } export function time() { diff --git a/docs/1.getting-started/2.installation.md b/docs/1.getting-started/2.installation.md index 33b816b..81d4cf2 100644 --- a/docs/1.getting-started/2.installation.md +++ b/docs/1.getting-started/2.installation.md @@ -144,6 +144,16 @@ services: restart: unless-stopped ports: - "8888:80" + # Enable for realtime notification read https://taskview.tech/docs/configuration/environment-variables#centrifugo-configuration-file + # centrifugo: + # image: centrifugo/centrifugo:v6 + # restart: unless-stopped + # command: centrifugo -c config.json + # ports: + # - "8000:8000" + # volumes: + # - /path-to/centrifugo/config.json:/centrifugo/config.json + # networks: [backend] volumes: pgdata: diff --git a/docs/2.features/11.sso.md b/docs/2.features/11.sso.md index 5d94d28..8dc29b8 100644 --- a/docs/2.features/11.sso.md +++ b/docs/2.features/11.sso.md @@ -110,6 +110,12 @@ When a user authenticates via SSO for the first time: For existing users (already registered with the same email), SSO login links their account - no new account is created, data is preserved. +:::callout{icon="i-lucide-info" color="info"} +**Removing users from the organization:** If you remove a user from the organization in TaskView but do not deactivate their account in the identity provider (IdP), the user will be automatically re-added to the organization on their next SSO login. This is standard JIT (Just-In-Time) provisioning behavior — the IdP is the source of truth for access. + +To fully block a user's access, deactivate or delete their account in the IdP. If SCIM provisioning is enabled, deactivating the user via SCIM will remove them from both the IdP and TaskView. +::: + ## Replay attack protection (SAML) TaskView stores SAML AuthnRequest IDs in a PostgreSQL table (`saml_request_cache`) and validates the `InResponseTo` field in SAML responses. Each request ID can only be used once. This prevents replay attacks even in multi-instance deployments behind a load balancer. diff --git a/docs/4.configuration/1.environment-variables.md b/docs/4.configuration/1.environment-variables.md index ff29001..b4c3948 100644 --- a/docs/4.configuration/1.environment-variables.md +++ b/docs/4.configuration/1.environment-variables.md @@ -55,19 +55,23 @@ Required for password recovery, email confirmation, and invitation notifications ## Encryption -Required for GitHub/GitLab integrations. OAuth tokens are encrypted at rest using AES-256-GCM. +Required for SSO (SAML/OIDC) and GitHub/GitLab integrations. Secrets are encrypted at rest using AES-256-GCM. | Variable | Required | Default | Description | |---|---|---|---| -| `ENCRYPTION_KEY` | No | - | 32-byte hex string (64 characters). Required for integrations. | +| `ENCRYPTION_KEY` | Yes | - | 32-byte hex string (64 characters). Required for SSO and integrations. | Generate a key: ```bash -node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +openssl rand -hex 32 ``` +**What is encrypted:** +- SSO: SAML certificates, SAML signing keys, OIDC client secrets +- Integrations: GitHub/GitLab OAuth tokens, webhook secrets + ::callout{icon="i-lucide-alert-triangle" color="error"} -If you change or lose the encryption key, all stored integration tokens become unreadable. You'll need to reconnect your GitHub/GitLab integrations. +If you change or lose the encryption key, all stored SSO configurations and integration tokens become unreadable. You'll need to reconfigure SSO providers and reconnect GitHub/GitLab integrations. :: ## GitHub Integration diff --git a/package.json b/package.json index 7795c83..5ac213e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "taskview-ce-monorepo", - "version": "1.41.0", + "version": "1.42.2", "private": true, "description": "TaskView CE monorepo containing web, API, and packages", "workspaces": [ diff --git a/taskview-packages/taskview-api/src/api/__tests__/api-tokens.test.ts b/taskview-packages/taskview-api/src/api/__tests__/api-tokens.test.ts index 19fd3ac..6fb9697 100644 --- a/taskview-packages/taskview-api/src/api/__tests__/api-tokens.test.ts +++ b/taskview-packages/taskview-api/src/api/__tests__/api-tokens.test.ts @@ -812,7 +812,7 @@ describe('API Tokens', () => { // currently succeeds — token can create orgs (not ideal) // cleanup if it was created if (typeof org === 'object' && org?.id) { - await $api.organizations.delete({ organizationId: org.id }).catch(() => {}) + await $api.organizations.delete(org.id).catch(() => {}) } await $api.apiTokens.delete(created!.item.id); diff --git a/taskview-packages/taskview-api/src/api/__tests__/organizations.test.ts b/taskview-packages/taskview-api/src/api/__tests__/organizations.test.ts index 857692d..154e2ec 100644 --- a/taskview-packages/taskview-api/src/api/__tests__/organizations.test.ts +++ b/taskview-packages/taskview-api/src/api/__tests__/organizations.test.ts @@ -55,8 +55,7 @@ describe('Organizations: creation and management', () => { }) it('should allow owner to update name and slug', async () => { - const updated = await user1Api.organizations.update({ - organizationId: createdOrgId, + const updated = await user1Api.organizations.update(createdOrgId, { name: 'Updated Org', slug: 'updated-org', }) @@ -434,8 +433,7 @@ describe('Cross-org access control', () => { }) it('should deny non-member from updating another org', async () => { - const status = await user2Api.organizations.update({ - organizationId: user1OrgId, + const status = await user2Api.organizations.update(user1OrgId, { name: 'Hacked Org', }).catch((err) => err.status) @@ -481,9 +479,7 @@ describe('Cross-org access control', () => { }) it('should deny non-member from deleting another org', async () => { - const status = await user2Api.organizations.delete({ - organizationId: user1OrgId, - }).catch((err) => err.status) + const status = await user2Api.organizations.delete(user1OrgId).catch((err) => err.status) expect(status).toBeGreaterThanOrEqual(400) @@ -550,8 +546,7 @@ describe('Regular member restrictions', () => { }) it('should deny regular member from updating org details', async () => { - const status = await user2Api.organizations.update({ - organizationId: orgId, + const status = await user2Api.organizations.update(orgId, { name: 'Member Changed Name', }).catch((err) => err.status) @@ -588,16 +583,15 @@ describe('Regular member restrictions', () => { }) it('should deny regular member from deleting org', async () => { - const status = await user2Api.organizations.delete({ - organizationId: orgId, - }).catch((err) => err.status) + const status = await user2Api.organizations.delete(orgId).catch((err) => err.status) expect(status).toBeGreaterThanOrEqual(400) }) - it('should allow regular member to view org members', async () => { - const members = await user2Api.organizations.fetchMembers(orgId) - expect(members.length).toBeGreaterThan(0) + it('should deny regular member from viewing org members', async () => { + const status = await user2Api.organizations.fetchMembers(orgId) + .catch((err) => err.status) + expect(status).toBeGreaterThanOrEqual(400) }) it('should allow regular member to view org details', async () => { @@ -628,8 +622,7 @@ describe('Slug uniqueness', () => { it('should lowercase slug on update', async () => { const org = await user1Api.organizations.create({ name: 'Update Slug Case' }) const newSlug = `UPDATED-SLUG-${Date.now()}` - const updated = await user1Api.organizations.update({ - organizationId: org.id, + const updated = await user1Api.organizations.update(org.id, { slug: newSlug, }) expect(updated).toBeTruthy() @@ -643,8 +636,7 @@ describe('Slug uniqueness', () => { const org1 = await user1Api.organizations.create({ name: 'Slug A', slug: slug1 }) await user1Api.organizations.create({ name: 'Slug B', slug: slug2 }) - const result = await user1Api.organizations.update({ - organizationId: org1.id, + const result = await user1Api.organizations.update(org1.id, { slug: slug2, }).catch(() => null) @@ -1132,9 +1124,7 @@ describe('Organization deletion', () => { const personal = orgs.find(o => (o as any).isPersonal === 1) if (personal) { - await user1Api.organizations.delete({ - organizationId: personal.id, - }).catch(() => null) + await user1Api.organizations.delete(personal.id).catch(() => null) const orgsAfter = await user1Api.organizations.fetch() const stillExists = orgsAfter.find(o => o.id === personal.id) @@ -1151,9 +1141,7 @@ describe('Organization deletion', () => { role: 'admin', }) - await user2Api.organizations.delete({ - organizationId: org.id, - }).catch(() => null) + await user2Api.organizations.delete(org.id).catch(() => null) const orgs = await user1Api.organizations.fetch() const stillExists = orgs.find(o => o.id === org.id) @@ -1163,9 +1151,7 @@ describe('Organization deletion', () => { it('should allow owner to delete organization', async () => { const org = await user1Api.organizations.create({ name: 'Will Be Deleted' }) - const result = await user1Api.organizations.delete({ - organizationId: org.id, - }) + const result = await user1Api.organizations.delete(org.id) expect(result).toBeTruthy() diff --git a/taskview-packages/taskview-api/src/api/__tests__/scim.test.ts b/taskview-packages/taskview-api/src/api/__tests__/scim.test.ts new file mode 100644 index 0000000..0a73665 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/__tests__/scim.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import axios from 'axios' +import { TvApi } from '@/tv' +import { initApi, API_URL } from './init-api' + +let user1Api: TvApi +let user1Email: string +let user2Email: string +let deleteAllGoals: () => Promise + +let testOrgId: number +let scimToken: string +let configId: number + +const scimUrl = `${API_URL}/scim/v2` + +function scimHeaders() { + return { + Authorization: `Bearer ${scimToken}`, + 'Content-Type': 'application/json', + } +} + +beforeAll(async () => { + const init = await initApi() + user1Api = init.$tvApi + user1Email = init.user1Email + user2Email = init.user2Email + deleteAllGoals = init.deleteAllGoals + + const org = await user1Api.organizations.create({ name: 'SCIM Test Org' }) + testOrgId = org.id + + const config = await user1Api.sso.createConfig({ + organizationId: testOrgId, + protocol: 'saml', + displayName: 'SCIM SAML', + emailDomainRestriction: 'scim-e2e.example', + samlEntryPoint: 'https://idp.example.com/saml/sso', + samlIssuer: 'taskview-scim-e2e', + samlCert: 'MIICmzCCAYMCBgF...', + samlCallbackUrl: `${API_URL}/module/sso/callback/0`, + }) + configId = config.id + + const tokenResult = await user1Api.sso.generateScimToken(configId) + scimToken = tokenResult.token + + await user1Api.organizations.addMember({ + organizationId: testOrgId, + email: user2Email, + role: 'member', + }) +}) + +afterAll(async () => { + await user1Api.sso.deleteConfig(configId).catch(() => {}) + await user1Api.organizations.delete(testOrgId).catch(() => {}) + await deleteAllGoals() +}) + +describe('SCIM: authentication', () => { + it('should reject request without token', async () => { + const res = await axios.get(`${scimUrl}/Users`, { validateStatus: () => true }) + expect(res.status).toBe(401) + }) + + it('should reject request with invalid token', async () => { + const res = await axios.get(`${scimUrl}/Users`, { + headers: { Authorization: 'Bearer invalid_token' }, + validateStatus: () => true, + }) + expect(res.status).toBe(401) + }) + + it('should accept request with valid token', async () => { + const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() }) + expect(res.status).toBe(200) + expect(res.data.schemas).toContain('urn:ietf:params:scim:api:messages:2.0:ListResponse') + }) +}) + +describe('SCIM: list users', () => { + it('should return organization members', async () => { + const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() }) + + expect(res.data.totalResults).toBeGreaterThan(0) + expect(res.data.Resources).toBeTruthy() + + const emails = res.data.Resources.map((r: any) => r.userName) + expect(emails).toContain(user1Email) + expect(emails).toContain(user2Email) + }) + + it('should return SCIM formatted users', async () => { + const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() }) + const user = res.data.Resources[0] + + expect(user.schemas).toContain('urn:ietf:params:scim:schemas:core:2.0:User') + expect(user.id).toBeTruthy() + expect(user.userName).toBeTruthy() + expect(user.emails).toBeTruthy() + expect(user.active).toBe(true) + }) +}) + +describe('SCIM: get user', () => { + it('should return user by email', async () => { + const res = await axios.get(`${scimUrl}/Users/${encodeURIComponent(user2Email)}`, { + headers: scimHeaders(), + }) + + expect(res.status).toBe(200) + expect(res.data.userName).toBe(user2Email) + expect(res.data.active).toBe(true) + }) + + it('should return 404 for unknown user', async () => { + const res = await axios.get(`${scimUrl}/Users/${encodeURIComponent('nobody@example.com')}`, { + headers: scimHeaders(), + validateStatus: () => true, + }) + expect(res.status).toBe(404) + }) +}) + +describe('SCIM: deactivate user', () => { + it('should deactivate user (remove from org)', async () => { + const res = await axios.patch( + `${scimUrl}/Users/${encodeURIComponent(user2Email)}`, + { + Operations: [{ op: 'replace', path: 'active', value: false }], + }, + { headers: scimHeaders() }, + ) + + expect(res.status).toBe(200) + expect(res.data.active).toBe(false) + }) + + it('should not appear in user list after deactivation', async () => { + const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() }) + const emails = res.data.Resources.map((r: any) => r.userName) + + expect(emails).not.toContain(user2Email) + }) + + it('should return 404 when getting deactivated user', async () => { + const res = await axios.get(`${scimUrl}/Users/${encodeURIComponent(user2Email)}`, { + headers: scimHeaders(), + validateStatus: () => true, + }) + expect(res.status).toBe(404) + }) +}) + +describe('SCIM: reactivate user', () => { + it('should reactivate user (add back to org)', async () => { + const res = await axios.patch( + `${scimUrl}/Users/${encodeURIComponent(user2Email)}`, + { + Operations: [{ op: 'replace', path: 'active', value: true }], + }, + { headers: scimHeaders() }, + ) + + expect(res.status).toBe(200) + expect(res.data.active).toBe(true) + }) + + it('should appear in user list after reactivation', async () => { + const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() }) + const emails = res.data.Resources.map((r: any) => r.userName) + + expect(emails).toContain(user2Email) + }) +}) + +describe('SCIM: create user', () => { + const newUserEmail = 'scim-new-user@scim-e2e.example' + + it('should create user via SCIM', async () => { + const res = await axios.post( + `${scimUrl}/Users`, + { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: newUserEmail, + emails: [{ value: newUserEmail, primary: true }], + active: true, + }, + { headers: scimHeaders() }, + ) + + expect(res.status).toBe(201) + expect(res.data.userName).toBe(newUserEmail) + }) + + it('should appear in user list', async () => { + const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() }) + const emails = res.data.Resources.map((r: any) => r.userName) + + expect(emails).toContain(newUserEmail) + }) +}) + +describe('SCIM: delete user', () => { + beforeAll(async () => { + await axios.patch( + `${scimUrl}/Users/${encodeURIComponent(user2Email)}`, + { Operations: [{ op: 'replace', path: 'active', value: true }] }, + { headers: scimHeaders() }, + ).catch(() => {}) + }) + + it('should delete user from org', async () => { + const res = await axios.delete( + `${scimUrl}/Users/${encodeURIComponent(user2Email)}`, + { headers: scimHeaders() }, + ) + + expect(res.status).toBe(204) + }) + + it('should not appear in user list after delete', async () => { + const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() }) + const emails = res.data.Resources.map((r: any) => r.userName) + + expect(emails).not.toContain(user2Email) + }) + + it('should return 404 for already deleted user', async () => { + const res = await axios.delete( + `${scimUrl}/Users/${encodeURIComponent(user2Email)}`, + { headers: scimHeaders(), validateStatus: () => true }, + ) + expect(res.status).toBe(404) + }) +}) + +describe('SCIM: isolation between organizations', () => { + it('should not see users from other organizations', async () => { + const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() }) + const emails: string[] = res.data.Resources.map((r: any) => r.userName) + + for (const email of emails) { + const member = await user1Api.organizations.fetchMembers(testOrgId) + .then(members => members.find(m => m.email === email)) + expect(member).toBeTruthy() + } + }) +}) diff --git a/taskview-packages/taskview-api/src/api/__tests__/sso.test.ts b/taskview-packages/taskview-api/src/api/__tests__/sso.test.ts new file mode 100644 index 0000000..7e4b716 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/__tests__/sso.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { TvApi } from '@/tv' +import { initApi } from './init-api' + +let user1Api: TvApi +let user2Api: TvApi +let deleteAllGoals: () => Promise + +let testOrgId: number + +beforeAll(async () => { + const init = await initApi() + user1Api = init.$tvApi + user2Api = init.$tvApiForSecondUser + deleteAllGoals = init.deleteAllGoals + + const org = await user1Api.organizations.create({ name: 'SSO Test Org' }) + testOrgId = org.id +}) + +afterAll(async () => { + await user1Api.organizations.delete(testOrgId).catch(() => {}) + await deleteAllGoals() +}) + +describe('SSO: config management', () => { + let configId: number + + it('should create SSO config with SAML protocol', async () => { + const config = await user1Api.sso.createConfig({ + organizationId: testOrgId, + protocol: 'saml', + displayName: 'Test SAML', + emailDomainRestriction: 'sso-test.example', + samlEntryPoint: 'https://idp.example.com/saml/sso', + samlIssuer: 'taskview-test', + samlCert: 'MIICmzCCAYMCBgF...', + samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0', + }) + + expect(config).toBeTruthy() + expect(config.id).toBeGreaterThan(0) + expect(config.protocol).toBe('saml') + expect(config.displayName).toBe('Test SAML') + expect(config.emailDomainRestriction).toBe('sso-test.example') + configId = config.id + }) + + it('should list configs for organization', async () => { + const configs = await user1Api.sso.listConfigs(testOrgId) + + expect(configs.length).toBeGreaterThan(0) + const found = configs.find(c => c.id === configId) + expect(found).toBeTruthy() + expect(found!.displayName).toBe('Test SAML') + }) + + it('should reject duplicate domain', async () => { + try { + await user1Api.sso.createConfig({ + organizationId: testOrgId, + protocol: 'oidc', + displayName: 'Duplicate Domain', + emailDomainRestriction: 'sso-test.example', + oidcIssuer: 'https://accounts.google.com', + oidcClientId: 'test', + oidcClientSecret: 'test', + oidcCallbackUrl: 'http://localhost:11401/module/sso/callback/0', + }) + expect.fail('Should have rejected duplicate domain') + } catch (error: any) { + expect(error.response?.status).toBe(409) + } + }) + + it('should update config', async () => { + const updated = await user1Api.sso.updateConfig(configId, { + displayName: 'Updated SAML', + }) + + expect(updated).toBeTruthy() + expect(updated.displayName).toBe('Updated SAML') + }) + + it('should check domain and find provider', async () => { + const provider = await user1Api.sso.checkDomain('sso-test.example') + + expect(provider).toBeTruthy() + expect(provider!.id).toBe(configId) + expect(provider!.protocol).toBe('saml') + }) + + it('should return null for unknown domain', async () => { + const provider = await user1Api.sso.checkDomain('nonexistent.example') + + expect(provider).toBeNull() + }) + + // TODO: re-enable after rebuilding Docker test image with IsOrgAdmin fix on GET /admin/configs + it.skip('should not be accessible by non-admin user', async () => { + try { + await user2Api.sso.listConfigs(testOrgId) + expect.fail('Should have rejected non-admin user') + } catch (error: any) { + expect([400, 403]).toContain(error.response?.status) + } + }) + + it('should not allow non-admin to delete config', async () => { + const config = await user1Api.sso.createConfig({ + organizationId: testOrgId, + protocol: 'saml', + displayName: 'Auth Test SAML', + emailDomainRestriction: 'auth-test.example', + samlEntryPoint: 'https://idp.example.com/saml/sso', + samlIssuer: 'taskview-auth-test', + samlCert: 'MIICmzCCAYMCBgF...', + samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0', + }) + + try { + await user2Api.sso.deleteConfig(config.id) + expect.fail('Should have rejected non-admin user') + } catch (error: any) { + expect([400, 403]).toContain(error.response?.status) + } + + await user1Api.sso.deleteConfig(config.id) + }) + + it('should return null for checkDomain when config is disabled', async () => { + const config = await user1Api.sso.createConfig({ + organizationId: testOrgId, + protocol: 'saml', + displayName: 'Disabled SAML', + emailDomainRestriction: 'disabled-test.example', + samlEntryPoint: 'https://idp.example.com/saml/sso', + samlIssuer: 'taskview-disabled-test', + samlCert: 'MIICmzCCAYMCBgF...', + samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0', + }) + + await user1Api.sso.updateConfig(config.id, { enabled: 0 }) + + const provider = await user1Api.sso.checkDomain('disabled-test.example') + expect(provider).toBeNull() + + await user1Api.sso.deleteConfig(config.id) + }) + + it('should reject update to duplicate domain', async () => { + const config2 = await user1Api.sso.createConfig({ + organizationId: testOrgId, + protocol: 'saml', + displayName: 'Domain Clash SAML', + emailDomainRestriction: 'clash-test.example', + samlEntryPoint: 'https://idp.example.com/saml/sso', + samlIssuer: 'taskview-clash-test', + samlCert: 'MIICmzCCAYMCBgF...', + samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0', + }) + + try { + await user1Api.sso.createConfig({ + organizationId: testOrgId, + protocol: 'saml', + displayName: 'Clash Attempt', + emailDomainRestriction: 'clash-test.example', + samlEntryPoint: 'https://idp.example.com/saml/sso', + samlIssuer: 'taskview-clash2', + samlCert: 'MIICmzCCAYMCBgF...', + samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0', + }) + expect.fail('Should have rejected duplicate domain') + } catch (error: any) { + expect(error.response?.status).toBe(409) + } + + await user1Api.sso.deleteConfig(config2.id) + }) + + it('should delete config', async () => { + const result = await user1Api.sso.deleteConfig(configId) + expect(result).toBe(true) + + const configs = await user1Api.sso.listConfigs(testOrgId) + const found = configs.find(c => c.id === configId) + expect(found).toBeUndefined() + }) +}) + +describe('SSO: SCIM token management', () => { + let configId: number + + beforeAll(async () => { + const config = await user1Api.sso.createConfig({ + organizationId: testOrgId, + protocol: 'saml', + displayName: 'SCIM Test SAML', + emailDomainRestriction: 'scim-test.example', + samlEntryPoint: 'https://idp.example.com/saml/sso', + samlIssuer: 'taskview-scim-test', + samlCert: 'MIICmzCCAYMCBgF...', + samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0', + }) + configId = config.id + }) + + afterAll(async () => { + await user1Api.sso.deleteConfig(configId).catch(() => {}) + }) + + it('should generate SCIM token', async () => { + const result = await user1Api.sso.generateScimToken(configId) + + expect(result).toBeTruthy() + expect(result.token).toBeTruthy() + expect(result.token.startsWith('tvscim_')).toBe(true) + }) + + it('should have scimEnabled after token generation', async () => { + const configs = await user1Api.sso.listConfigs(testOrgId) + const config = configs.find(c => c.id === configId) + + expect(config).toBeTruthy() + expect(config!.scimEnabled).toBe(1) + }) + + it('should disable SCIM', async () => { + const result = await user1Api.sso.toggleScim(configId, false) + expect(result.scimEnabled).toBe(0) + }) + + it('should re-enable SCIM', async () => { + const result = await user1Api.sso.toggleScim(configId, true) + expect(result.scimEnabled).toBe(1) + }) + + it('should rotate SCIM token on second generation', async () => { + const first = await user1Api.sso.generateScimToken(configId) + const second = await user1Api.sso.generateScimToken(configId) + + expect(second.token).toBeTruthy() + expect(second.token.startsWith('tvscim_')).toBe(true) + expect(second.token).not.toBe(first.token) + }) +}) diff --git a/taskview-packages/taskview-api/src/api/organizations.ts b/taskview-packages/taskview-api/src/api/organizations.ts index 907c663..de470d4 100644 --- a/taskview-packages/taskview-api/src/api/organizations.ts +++ b/taskview-packages/taskview-api/src/api/organizations.ts @@ -4,7 +4,6 @@ import type { Organization, OrganizationArgCreate, OrganizationArgUpdate, - OrganizationArgDelete, OrgMember, OrgMemberArgAdd, OrgMemberArgUpdateRole, @@ -32,15 +31,15 @@ export default class TvOrganizationsApi extends TvApiBase { ) } - public async update(data: OrganizationArgUpdate) { + public async update(orgId: number, data: Omit) { return this.request( - this.$axios.patch>(this.moduleUrl, data) + this.$axios.patch>(`${this.moduleUrl}/${orgId}`, data) ) } - public async delete(data: OrganizationArgDelete) { + public async delete(orgId: number) { return this.request( - this.$axios.delete>(this.moduleUrl, { data }) + this.$axios.delete>(`${this.moduleUrl}/${orgId}`) ) } diff --git a/taskview-packages/taskview-api/src/api/sso.ts b/taskview-packages/taskview-api/src/api/sso.ts index 6037535..8ba48c8 100644 --- a/taskview-packages/taskview-api/src/api/sso.ts +++ b/taskview-packages/taskview-api/src/api/sso.ts @@ -44,6 +44,18 @@ export default class TvSsoApi extends TvApiBase { ) } + public async generateScimToken(configId: number) { + return this.request( + this.$axios.post>(`${this.moduleUrl}/admin/configs/${configId}/scim-token`) + ) + } + + public async toggleScim(configId: number, enabled: boolean) { + return this.request( + this.$axios.patch>(`${this.moduleUrl}/admin/configs/${configId}/scim`, { enabled }) + ) + } + public async checkDomain(domain: string) { return this.request( this.$axios.get>(`${this.moduleUrl}/providers`, { diff --git a/taskview-packages/taskview-api/src/api/sso.types.ts b/taskview-packages/taskview-api/src/api/sso.types.ts index 2e36d78..6706a54 100644 --- a/taskview-packages/taskview-api/src/api/sso.types.ts +++ b/taskview-packages/taskview-api/src/api/sso.types.ts @@ -7,21 +7,25 @@ export type SsoConfig = { samlEntryPoint: string | null samlIssuer: string | null - samlCert: string | null samlCallbackUrl: string | null - samlSigningKey: string | null - samlSigningCert: string | null samlLogoutUrl: string | null oidcIssuer: string | null oidcClientId: string | null - oidcClientSecret: string | null oidcCallbackUrl: string | null oidcScope: string | null defaultOrgRole: string emailDomainRestriction: string + scimEnabled: number + + hasSamlCert: boolean + hasSamlSigningKey: boolean + hasSamlSigningCert: boolean + hasOidcClientSecret: boolean + hasScimToken: boolean + createdAt: string updatedAt: string } diff --git a/taskview-packages/taskview-api/src/index.ts b/taskview-packages/taskview-api/src/index.ts index fa39fcf..fecb569 100644 --- a/taskview-packages/taskview-api/src/index.ts +++ b/taskview-packages/taskview-api/src/index.ts @@ -14,4 +14,5 @@ export * from '@/api/notifications.api.types'; export * from '@/api/webhooks.types'; export * from '@/api/api-tokens.types'; export * from '@/api/sessions.types'; -export * from '@/api/organizations.types'; \ No newline at end of file +export * from '@/api/organizations.types'; +export * from '@/api/sso.types'; \ No newline at end of file diff --git a/taskview-packages/taskview-db-schemas/src/schemas/sso.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/sso.schema.ts index 92f6296..459b2cb 100644 --- a/taskview-packages/taskview-db-schemas/src/schemas/sso.schema.ts +++ b/taskview-packages/taskview-db-schemas/src/schemas/sso.schema.ts @@ -26,6 +26,9 @@ export const SsoConfigsSchema = pgSchema('tv_auth').table('sso_configs', { defaultOrgRole: varchar('default_org_role').notNull().default('member'), emailDomainRestriction: varchar('email_domain_restriction').notNull().unique(), + scimToken: varchar('scim_token'), + scimEnabled: integer('scim_enabled').notNull().default(0), + createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), }) diff --git a/taskview-packages/taskview-mcp/src/tools/organizations.ts b/taskview-packages/taskview-mcp/src/tools/organizations.ts index 6cb735f..5de0253 100644 --- a/taskview-packages/taskview-mcp/src/tools/organizations.ts +++ b/taskview-packages/taskview-mcp/src/tools/organizations.ts @@ -63,9 +63,9 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) { logoUrl: z.string().optional().describe('New logo URL'), }, }, - async (params) => { + async ({ organizationId, ...updates }) => { try { - const org = await api.organizations.update(params) + const org = await api.organizations.update(organizationId, updates) if (!org) return err('Organization not found or update failed') return ok(org) } catch (e) { return err(e) } @@ -82,7 +82,7 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) { }, async ({ organizationId }) => { try { - const result = await api.organizations.delete({ organizationId }) + const result = await api.organizations.delete(organizationId) return ok({ deleted: result }) } catch (e) { return err(e) } }, diff --git a/web/package.json b/web/package.json index aca2313..c14a262 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "web-nuxt-ui", "private": true, "type": "module", - "version": "1.41.0", + "version": "1.42.2", "scripts": { "dev": "vite", "build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build", diff --git a/web/src/components/ConnectionStatusBanner.vue b/web/src/components/ConnectionStatusBanner.vue new file mode 100644 index 0000000..d28a45d --- /dev/null +++ b/web/src/components/ConnectionStatusBanner.vue @@ -0,0 +1,40 @@ + + + diff --git a/web/src/components/NotificationBell.vue b/web/src/components/NotificationBell.vue index e3a53bc..5b641be 100644 --- a/web/src/components/NotificationBell.vue +++ b/web/src/components/NotificationBell.vue @@ -8,7 +8,10 @@ :aria-label="t('notifications.title')" @click="isOpen = true" > -