Compare commits

..

4 Commits

Author SHA1 Message Date
Nikolai Giman 15050541fa chore: version 2026-08-20 14:40:23 +02:00
Nikolai Giman aaa876412d fix: #101 2026-08-20 13:51:32 +02:00
Nikolai Giman 915e8d9f9f fix: sso domain verification and #77 2026-08-18 18:57:17 +02:00
Nikolai Giman 55ded8bac9 Merge pull request #99 from Gimanh/fix/98
fix: #98
2026-08-04 20:45:47 +02:00
42 changed files with 1669 additions and 118 deletions
+3
View File
@@ -19,6 +19,9 @@ ACCESS_LIFE_TIME=1d
REFRESH_LIFE_TIME=2d
JWT_ALG=HS256
# SSO: comma-separated email domains that skip DNS/HTTP ownership proof (air-gapped installs)
#SSO_TRUSTED_DOMAINS=company.com,corp.local
# SMTP Configuration
SMTP_HOST=smtp.domain.com
SMTP_PORT=465
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.51.0",
"version": "1.52.0",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
+13
View File
@@ -737,5 +737,18 @@
"description": [
"Log of sent project-invite emails (collaboration.invite_emails) backing the per-recipient cooldown and the hourly per-initiator sending cap"
]
},
"58": {
"version": "1.63.0",
"name": "SSO domain verification",
"releaseDate": "20260813",
"scripts": [
"/1.63.0/0.sso-domain-verification.sql",
"/1.63.0/1.sso-domain-verified-unique.sql"
],
"description": [
"SSO configs require proving ownership of email_domain_restriction before login is allowed: DNS TXT taskview-sso-verify=<token> or https://<domain>/.well-known/taskview-sso-verify.txt. Air-gapped installs can skip this for listed domains via SSO_TRUSTED_DOMAINS.",
"Replaces the plain UNIQUE(email_domain_restriction) with a partial unique index over verified configs only, so an unverified config can no longer squat a domain and block its real owner — multiple orgs may hold a pending config for the same domain, but only one can verify it (first-to-verify wins)."
]
}
}
@@ -0,0 +1,3 @@
ALTER TABLE tv_auth.sso_configs
ADD COLUMN IF NOT EXISTS domain_verify_token VARCHAR,
ADD COLUMN IF NOT EXISTS domain_verified_at TIMESTAMP;
@@ -0,0 +1,6 @@
ALTER TABLE tv_auth.sso_configs
DROP CONSTRAINT IF EXISTS sso_configs_email_domain_restriction_key;
CREATE UNIQUE INDEX IF NOT EXISTS sso_configs_verified_domain_uniq
ON tv_auth.sso_configs (email_domain_restriction)
WHERE domain_verified_at IS NOT NULL;
+32 -1
View File
@@ -2,7 +2,7 @@ import { eq, sql } from 'drizzle-orm';
import { CollaborationUsersSchema, OrganizationMembersSchema, SsoIdentitiesSchema, UsersSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserEmailArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
export default class AuthModel {
private readonly db: Database;
@@ -189,6 +189,37 @@ export default class AuthModel {
}
}
async updateUserEmail(args: UpdateUserEmailArgs): Promise<UpdateUserCredentialsResult> {
try {
await this.db.dbDrizzle.transaction(async (tx) => {
await tx
.update(UsersSchema)
.set({ email: args.email })
.where(eq(UsersSchema.id, args.userId));
await tx
.update(OrganizationMembersSchema)
.set({ email: args.email })
.where(eq(OrganizationMembersSchema.email, args.oldEmail));
await tx
.update(CollaborationUsersSchema)
.set({ email: args.email })
.where(eq(CollaborationUsersSchema.email, args.oldEmail));
await tx
.update(SsoIdentitiesSchema)
.set({ email: args.email })
.where(eq(SsoIdentitiesSchema.userId, args.userId));
});
return 'ok';
} catch (error) {
const pgCode = (error as { code?: string })?.code ?? (error as { cause?: { code?: string } })?.cause?.code;
if (pgCode === '23505') {
return 'conflict';
}
$logger.error(error, `Can not update email for user ${args.userId}`);
return 'error';
}
}
async updateUserPassword(password: string, userId: number): Promise<boolean> {
try {
const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2';
+192 -42
View File
@@ -5,15 +5,24 @@ import type { Request, Response } from 'express'
import { $logger } from '../../modules/logget'
import { PublicApiUrl } from '../../modules/public-url'
import { logError } from '../../utils/api'
import { generateString, isEmail } from '../../utils/helpers'
import { generateLetters, generateString } from '../../utils/helpers'
import AuthModel from '../auth/AuthModel'
import { GoalsRepository } from '../goals/GoalsRepository'
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'
import { generateLoginCode, isSsoDomainVerified, stripSecrets, validateMetadataUrl } from './sso.utils'
import {
SsoConfigArkTypeCreate,
SsoConfigArkTypeUpdate,
SsoDomainNotVerifiedError,
type ApplySsoIdpEmailArgs,
type ResolveSsoUserArgs,
type ResolveSsoUserResult,
type SsoCallbackError,
} from './types'
import type { UserDbRecord } from '../../types/auth.types'
export class SsoController {
private readonly ssoRepo = new SsoRepository()
@@ -21,6 +30,103 @@ export class SsoController {
private readonly orgRepo = new OrganizationRepository()
private readonly goalsRepo = new GoalsRepository()
private async resolveLogin(preferredUsername?: string): Promise<string> {
const base = preferredUsername?.trim().slice(0, 50)
if (!base) return generateString(7)
if (!(await this.authModel.getUserByLogin(base))) return base
for (let attempt = 0; attempt < 10; attempt++) {
const suffix = `.${generateLetters(3)}`
const candidate = `${base.slice(0, 50 - suffix.length)}${suffix}`
if (!(await this.authModel.getUserByLogin(candidate))) return candidate
}
return generateString(7)
}
private redirectSsoError(res: Response, error: SsoCallbackError) {
return res.redirect(`${process.env.APP_URL}/login?sso_error=${error}`)
}
private async createSsoUser(args: ResolveSsoUserArgs): Promise<UserDbRecord | false> {
const password = generateString(16)
const login = await this.resolveLogin(args.preferredUsername)
const id = await this.authModel.registerUserInDb({
login,
email: args.email,
password: hashSync(password, 10),
block: 0,
confirmEmailCode: '',
})
if (!id) {
$logger.error('Failed to create user during SSO login')
return false
}
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
if (personalOrg) {
await this.orgRepo.addMember(personalOrg.id, args.email, 'owner')
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
}
return await this.authModel.fetchUserById(id)
}
private async applyIdpEmail(args: ApplySsoIdpEmailArgs): Promise<'ok' | 'email_in_use' | 'error'> {
if (args.user.email.toLowerCase() === args.email) return 'ok'
const taken = await this.authModel.getUserByLogin(args.email, true)
if (taken && taken.id !== args.user.id) return 'email_in_use'
const result = await this.authModel.updateUserEmail({
userId: args.user.id,
oldEmail: args.user.email,
email: args.email,
})
if (result === 'conflict') return 'email_in_use'
if (result !== 'ok') return 'error'
return 'ok'
}
private async resolveSsoUser(args: ResolveSsoUserArgs): Promise<ResolveSsoUserResult> {
const identity = await this.ssoRepo.findIdentity({
ssoConfigId: args.ssoConfigId,
externalId: args.externalId,
})
if (identity) {
const user = await this.authModel.fetchUserById(identity.userId)
if (!user) return { ok: false, error: 'authentication_failed' }
const emailResult = await this.applyIdpEmail({ user, email: args.email })
if (emailResult === 'email_in_use') return { ok: false, error: 'email_in_use' }
if (emailResult !== 'ok') return { ok: false, error: 'authentication_failed' }
const refreshed = await this.authModel.fetchUserById(user.id)
if (!refreshed) return { ok: false, error: 'authentication_failed' }
return { ok: true, user: refreshed }
}
const existing = await this.authModel.getUserByLogin(args.email, true)
if (existing) {
const linked = await this.ssoRepo.findIdentityByUser({
ssoConfigId: args.ssoConfigId,
userId: existing.id,
})
if (linked && linked.externalId !== args.externalId) {
return { ok: false, error: 'email_in_use' }
}
return { ok: true, user: existing }
}
const created = await this.createSsoUser(args)
if (!created) return { ok: false, error: 'authentication_failed' }
return { ok: true, user: created }
}
initiateLogin = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).tvJson({ message: 'Invalid config ID' })
@@ -28,6 +134,10 @@ export class SsoController {
const config = await this.ssoRepo.findEnabledById(configId)
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
if (!isSsoDomainVerified(config)) {
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
}
try {
const provider = createSsoProvider(config)
const relayState = JSON.stringify({ platform: req.query.platform || '' })
@@ -45,50 +155,36 @@ export class SsoController {
const config = await this.ssoRepo.findEnabledById(configId)
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
if (!isSsoDomainVerified(config)) {
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
}
try {
const provider = createSsoProvider(config)
const ssoResult = await provider.handleCallback(req)
if (config.emailDomainRestriction) {
const domain = ssoResult.email.split('@')[1]
if (domain !== config.emailDomainRestriction) {
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
}
if (!config.emailDomainRestriction) {
return this.redirectSsoError(res, 'authentication_failed')
}
let userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
if (!userData) {
const password = generateString(16)
const login = generateString(7)
const id = await this.authModel.registerUserInDb({
login,
email: ssoResult.email,
password: hashSync(password, 10),
block: 0,
confirmEmailCode: '',
})
if (!id) {
$logger.error('Failed to create user during SSO login')
return res.status(500).tvJson({ message: 'Failed to create user' })
}
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
if (personalOrg) {
await this.orgRepo.addMember(personalOrg.id, ssoResult.email, 'owner')
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
}
userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
const domain = ssoResult.email.split('@')[1]
if (domain !== config.emailDomainRestriction) {
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
}
if (!userData) {
return res.status(500).tvJson({ message: 'Failed to resolve user after SSO login' })
const resolved = await this.resolveSsoUser({
ssoConfigId: config.id,
email: ssoResult.email,
externalId: ssoResult.externalId,
preferredUsername: ssoResult.preferredUsername,
})
if (!resolved.ok) {
return this.redirectSsoError(res, resolved.error)
}
await this.orgRepo.addMember(config.organizationId, ssoResult.email, config.defaultOrgRole)
const userData = resolved.user
await this.orgRepo.addMember(config.organizationId, userData.email, config.defaultOrgRole)
await this.ssoRepo.upsertIdentity({
userId: userData.id,
@@ -132,7 +228,7 @@ export class SsoController {
if (!domain) return res.tvJson(null)
const config = await this.ssoRepo.findEnabledByDomain(domain)
if (!config) return res.tvJson(null)
if (!config || !isSsoDomainVerified(config)) return res.tvJson(null)
return res.tvJson({
id: config.id,
@@ -165,11 +261,18 @@ export class SsoController {
return res.status(400).send(out.summary)
}
const existing = await this.ssoRepo.findEnabledByDomain(out.emailDomainRestriction)
if (existing) {
const domain = out.emailDomainRestriction.toLowerCase()
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: out.organizationId })
if (sameOrg) {
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
}
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
if (verified) {
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
}
const config = await req.appUser.ssoManager.createConfig(out).catch(logError)
if (!config) {
return res.status(500).tvJson({ message: 'Failed to create SSO config' })
@@ -186,8 +289,33 @@ export class SsoController {
return res.status(400).send(out.summary)
}
const config = await req.appUser.ssoManager.updateConfig(configId, out).catch(logError)
return res.tvJson(config ? stripSecrets(config) : null)
if (out.emailDomainRestriction) {
const domain = out.emailDomainRestriction.toLowerCase()
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
if (verified && verified.id !== configId) {
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
}
const current = await this.ssoRepo.findById(configId)
if (current) {
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: current.organizationId })
if (sameOrg && sameOrg.id !== configId) {
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
}
}
}
try {
const config = await req.appUser.ssoManager.updateConfig(configId, out)
return res.tvJson(config ? stripSecrets(config) : null)
} catch (error) {
if (error instanceof SsoDomainNotVerifiedError) {
return res.status(403).tvJson({ message: 'Domain is not verified' })
}
logError(error)
return res.tvJson(null)
}
}
parseMetadata = async (req: Request, res: Response) => {
@@ -213,6 +341,28 @@ export class SsoController {
}
}
startDomainVerification = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).end()
const result = await req.appUser.ssoManager.startDomainVerification(configId).catch(logError)
if (!result) {
return res.status(404).tvJson({ message: 'SSO config not found' })
}
return res.tvJson(result)
}
checkDomainVerification = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).end()
const result = await req.appUser.ssoManager.checkDomainVerification(configId).catch(logError)
if (!result) {
return res.status(404).tvJson({ message: 'SSO config not found' })
}
return res.tvJson(result)
}
generateScimToken = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).end()
+110 -5
View File
@@ -1,8 +1,22 @@
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'
import {
SSO_SECRET_FIELDS,
generateDomainVerifyToken,
isSsoDomainVerified,
isTrustedSsoDomain,
proveSsoDomainOwnership,
ssoDomainVerifyDnsRecord,
ssoDomainVerifyHttpUrl,
} from './sso.utils'
import {
SsoDomainNotVerifiedError,
type CheckDomainVerificationResult,
type SsoConfigArgCreate,
type SsoConfigArgUpdate,
type StartDomainVerificationResult,
} from './types'
export class SsoManager {
public readonly repository: SsoRepository
@@ -18,11 +32,14 @@ export class SsoManager {
}
async createConfig(data: SsoConfigArgCreate) {
const domain = data.emailDomainRestriction.toLowerCase()
const trusted = isTrustedSsoDomain(domain)
return await this.repository.create({
organizationId: data.organizationId,
protocol: data.protocol,
displayName: data.displayName,
enabled: data.enabled ?? 1,
enabled: trusted ? (data.enabled ?? 1) : 0,
samlEntryPoint: data.samlEntryPoint ?? null,
samlIssuer: data.samlIssuer ?? null,
samlCert: encryptField(data.samlCert),
@@ -36,12 +53,22 @@ export class SsoManager {
oidcCallbackUrl: data.oidcCallbackUrl ?? null,
oidcScope: data.oidcScope ?? null,
defaultOrgRole: data.defaultOrgRole ?? 'member',
emailDomainRestriction: data.emailDomainRestriction.toLowerCase(),
emailDomainRestriction: domain,
domainVerifyToken: generateDomainVerifyToken(),
domainVerifiedAt: trusted ? new Date() : null,
})
}
async updateConfig(configId: number, data: SsoConfigArgUpdate) {
const encrypted: Partial<SsoConfigArgUpdate> = { ...data }
const current = await this.repository.findById(configId)
if (!current) return null
const encrypted: Partial<SsoConfigArgUpdate> & {
domainVerifyToken?: string
domainVerifiedAt?: Date | null
enabled?: number
} = { ...data }
for (const field of SSO_SECRET_FIELDS) {
if (field in encrypted) {
if (encrypted[field]) {
@@ -51,9 +78,87 @@ export class SsoManager {
}
}
}
if (data.emailDomainRestriction) {
const domain = data.emailDomainRestriction.toLowerCase()
encrypted.emailDomainRestriction = domain
if (domain !== current.emailDomainRestriction) {
const trusted = isTrustedSsoDomain(domain)
encrypted.domainVerifyToken = generateDomainVerifyToken()
encrypted.domainVerifiedAt = trusted ? new Date() : null
if (!trusted) encrypted.enabled = 0
await this.repository.deleteIdentitiesByConfig(configId)
}
}
const nextDomain = encrypted.emailDomainRestriction ?? current.emailDomainRestriction
const nextVerifiedAt = 'domainVerifiedAt' in encrypted
? encrypted.domainVerifiedAt
: current.domainVerifiedAt
const wouldBeVerified = isTrustedSsoDomain(nextDomain) || !!nextVerifiedAt
if (data.enabled === 1 && !wouldBeVerified) {
throw new SsoDomainNotVerifiedError()
}
return await this.repository.update(configId, encrypted)
}
async startDomainVerification(configId: number): Promise<StartDomainVerificationResult | null> {
const config = await this.repository.findById(configId)
if (!config) return null
let token = config.domainVerifyToken
if (!token) {
token = generateDomainVerifyToken()
const updated = await this.repository.update(configId, { domainVerifyToken: token })
if (!updated) return null
}
return {
token,
dnsRecord: ssoDomainVerifyDnsRecord(token),
httpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
isDomainVerified: isSsoDomainVerified({ ...config, domainVerifyToken: token }),
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
}
}
async checkDomainVerification(configId: number): Promise<CheckDomainVerificationResult | null> {
const config = await this.repository.findById(configId)
if (!config) return null
if (!config.domainVerifyToken) {
return {
verified: isSsoDomainVerified(config),
method: isTrustedSsoDomain(config.emailDomainRestriction) ? 'trusted' : null
}
}
const method = await proveSsoDomainOwnership({
domain: config.emailDomainRestriction,
token: config.domainVerifyToken,
})
if (!method) {
return { verified: isSsoDomainVerified(config), method: null }
}
if (!config.domainVerifiedAt || method === 'trusted') {
const updated = await this.repository.update(configId, {
domainVerifiedAt: new Date(),
enabled: 1,
})
// The partial unique index rejects a second verified config for the same
// domain another organization proved ownership first.
if (!updated) {
return { verified: false, method: null }
}
}
return { verified: true, method }
}
async deleteConfig(configId: number) {
return await this.repository.delete(configId)
}
+74 -7
View File
@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm'
import { and, eq, isNotNull } from 'drizzle-orm'
import {
SsoConfigsSchema,
SsoIdentitiesSchema,
@@ -8,6 +8,12 @@ import {
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
FindSsoConfigByDomainAndOrgArgs,
FindSsoIdentityArgs,
FindSsoIdentityByUserArgs,
UpsertSsoIdentityArgs,
} from './types'
export class SsoRepository {
private readonly db: Database
@@ -16,6 +22,38 @@ export class SsoRepository {
this.db = Database.getInstance()
}
async findVerifiedByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SsoConfigsSchema)
.where(
and(
eq(SsoConfigsSchema.emailDomainRestriction, domain.toLowerCase()),
isNotNull(SsoConfigsSchema.domainVerifiedAt),
)
)
)
if (!result || result.length === 0) return null
return result[0]
}
async findByDomainAndOrg(args: FindSsoConfigByDomainAndOrgArgs): Promise<SsoConfigsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SsoConfigsSchema)
.where(
and(
eq(SsoConfigsSchema.emailDomainRestriction, args.domain.toLowerCase()),
eq(SsoConfigsSchema.organizationId, args.organizationId),
)
)
)
if (!result || result.length === 0) return null
return result[0]
}
async findEnabledByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
@@ -146,12 +184,41 @@ export class SsoRepository {
return !!(result?.rowCount && result.rowCount > 0)
}
async upsertIdentity(data: {
userId: number
ssoConfigId: number
externalId: string
email: string
}): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
async findIdentity(args: FindSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
const result = await this.db.dbDrizzle
.select()
.from(SsoIdentitiesSchema)
.where(
and(
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
eq(SsoIdentitiesSchema.externalId, args.externalId),
)
)
if (result.length === 0) return null
return result[0]
}
async findIdentityByUser(args: FindSsoIdentityByUserArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
const result = await this.db.dbDrizzle
.select()
.from(SsoIdentitiesSchema)
.where(
and(
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
eq(SsoIdentitiesSchema.userId, args.userId),
)
)
if (result.length === 0) return null
return result[0]
}
async deleteIdentitiesByConfig(ssoConfigId: number): Promise<void> {
await this.db.dbDrizzle
.delete(SsoIdentitiesSchema)
.where(eq(SsoIdentitiesSchema.ssoConfigId, ssoConfigId))
}
async upsertIdentity(data: UpsertSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
const existing = await callWithCatch(() =>
this.db.dbDrizzle
.select()
+2
View File
@@ -32,6 +32,8 @@ export default class SsoRoutes implements Routable {
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/verify-domain', [IsLoggedIn, IsSsoConfigAdmin], this.controller.startDomainVerification)
this.router.post('/admin/configs/:configId/verify-domain/check', [IsLoggedIn, IsSsoConfigAdmin], this.controller.checkDomainVerification)
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)
}
@@ -2,6 +2,7 @@ import { randomBytes } from 'crypto'
import * as client from 'openid-client'
import type { Request, Response } from 'express'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { PublicApiUrl } from '../../../modules/public-url'
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
export class OidcProvider implements SsoProvider {
@@ -28,7 +29,12 @@ export class OidcProvider implements SsoProvider {
return this.oidcConfig
}
async initiateLogin(_req: Request, res: Response, relayState?: string): Promise<void> {
private resolveCallbackUrl(req: Request): string {
return this.config.oidcCallbackUrl?.trim()
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
}
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
const config = await this.getOidcConfig()
const scope = this.config.oidcScope ?? 'openid email profile'
const codeVerifier = client.randomPKCECodeVerifier()
@@ -63,7 +69,7 @@ export class OidcProvider implements SsoProvider {
})
const params = new URLSearchParams({
redirect_uri: this.config.oidcCallbackUrl!,
redirect_uri: this.resolveCallbackUrl(req),
scope,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
@@ -110,7 +116,7 @@ export class OidcProvider implements SsoProvider {
throw new Error('CSRF state mismatch — possible CSRF attack')
}
const callbackOrigin = new URL(this.config.oidcCallbackUrl!).origin
const callbackOrigin = new URL(this.resolveCallbackUrl(req)).origin
const currentUrl = new URL(req.originalUrl, callbackOrigin)
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: codeVerifier,
@@ -128,6 +134,7 @@ export class OidcProvider implements SsoProvider {
email: (claims.email as string).toLowerCase(),
externalId: claims.sub,
displayName: claims.name as string | undefined,
preferredUsername: claims.preferred_username as string | undefined,
provider: `oidc-${this.config.id}`,
}
}
@@ -1,6 +1,8 @@
import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
import type { Request, Response } from 'express'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { PublicApiUrl } from '../../../modules/public-url'
import type { SamlOptionsArgs } from '../types'
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
import { SamlDbCacheProvider } from './saml-cache-provider'
@@ -11,12 +13,12 @@ function normalizeCert(cert: string): string {
.replace(/[\s\r\n]/g, '')
}
function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertion' | 'response') {
function buildSamlOptions({ config, mode, callbackUrl }: SamlOptionsArgs) {
return {
entryPoint: config.samlEntryPoint!,
issuer: config.samlIssuer!,
idpCert: normalizeCert(config.samlCert!),
callbackUrl: config.samlCallbackUrl!,
callbackUrl,
wantAssertionsSigned: mode === 'assertion',
wantAuthnResponseSigned: mode === 'response',
validateInResponseTo: ValidateInResponseTo.always,
@@ -31,29 +33,38 @@ function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertio
}
export class SamlProvider implements SsoProvider {
private readonly samlAssertion: SAML
private readonly samlResponse: SAML
private readonly config: SsoConfigsSchemaTypeForSelect
constructor(config: SsoConfigsSchemaTypeForSelect) {
this.config = config
this.samlAssertion = new SAML(buildSamlOptions(config, 'assertion'))
this.samlResponse = new SAML(buildSamlOptions(config, 'response'))
}
private resolveCallbackUrl(req: Request): string {
return this.config.samlCallbackUrl?.trim()
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
}
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
const loginUrl = await this.samlAssertion.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
const saml = new SAML(buildSamlOptions({
config: this.config,
mode: 'assertion',
callbackUrl: this.resolveCallbackUrl(req),
}))
const loginUrl = await saml.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
res.redirect(loginUrl)
}
async handleCallback(req: Request): Promise<SsoAuthResult> {
const callbackUrl = this.resolveCallbackUrl(req)
let profile
try {
const result = await this.samlAssertion.validatePostResponseAsync(req.body)
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'assertion', callbackUrl }))
const result = await saml.validatePostResponseAsync(req.body)
profile = result.profile
} catch {
const result = await this.samlResponse.validatePostResponseAsync(req.body)
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'response', callbackUrl }))
const result = await saml.validatePostResponseAsync(req.body)
profile = result.profile
}
@@ -4,6 +4,7 @@ export type SsoAuthResult = {
email: string
externalId: string
displayName?: string
preferredUsername?: string
provider: string
}
+95 -1
View File
@@ -1,11 +1,49 @@
import { randomBytes } from 'crypto'
import { resolveTxt } from 'node:dns/promises'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { decryptField } from '../../utils/crypto'
import { generateString } from '../../utils/helpers'
import type { CheckSsoDomainProofArgs, SsoDomainVerificationMethod } from './types'
export const SSO_SECRET_FIELDS = ['samlCert', 'samlSigningKey', 'samlSigningCert', 'oidcClientSecret'] as const
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
export const SSO_DOMAIN_TXT_PREFIX = 'taskview-sso-verify='
export const SSO_DOMAIN_WELL_KNOWN_PATH = '/.well-known/taskview-sso-verify.txt'
export function generateDomainVerifyToken(): string {
return `tvdom_${randomBytes(32).toString('hex')}`
}
export function trustedSsoDomains(): string[] {
const raw = process.env.SSO_TRUSTED_DOMAINS
if (!raw?.trim()) return []
return raw
.split(',')
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean)
}
export function isTrustedSsoDomain(domain: string): boolean {
return trustedSsoDomains().includes(domain.trim().toLowerCase())
}
export function isSsoDomainVerified(config: SsoConfigsSchemaTypeForSelect): boolean {
if (isTrustedSsoDomain(config.emailDomainRestriction)) return true
return !!config.domainVerifiedAt
}
export function ssoDomainVerifyHttpUrl(domain: string): string {
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
return `${protocol}://${domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`
}
export function ssoDomainVerifyDnsRecord(token: string): string {
return `${SSO_DOMAIN_TXT_PREFIX}${token}`
}
export function toClientSsoConfig(config: SsoConfigsSchemaTypeForSelect) {
const { samlCert, samlSigningKey, samlSigningCert, oidcClientSecret, scimToken, ...safe } = config
const token = config.domainVerifyToken
return {
...safe,
hasSamlCert: !!samlCert,
@@ -13,9 +51,65 @@ export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
hasSamlSigningCert: !!samlSigningCert,
hasOidcClientSecret: !!oidcClientSecret,
hasScimToken: !!scimToken,
isDomainVerified: isSsoDomainVerified(config),
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
domainVerifyDnsRecord: token ? ssoDomainVerifyDnsRecord(token) : null,
domainVerifyHttpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
}
}
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
return toClientSsoConfig(config)
}
function tokenMatchesProof(body: string, token: string): boolean {
const trimmed = body.trim()
return trimmed === token || trimmed === ssoDomainVerifyDnsRecord(token)
}
export async function checkSsoDomainDnsTxt(args: CheckSsoDomainProofArgs): Promise<boolean> {
try {
const records = await resolveTxt(args.domain)
return records.some((chunks) => tokenMatchesProof(chunks.join(''), args.token))
} catch {
return false
}
}
export async function checkSsoDomainHttpFile(args: CheckSsoDomainProofArgs): Promise<boolean> {
const urls = process.env.NODE_ENV === 'production'
? [`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`]
: [
`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
`http://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
]
for (const url of urls) {
const urlError = validateMetadataUrl(url)
if (urlError) continue
try {
const response = await fetch(url, {
redirect: 'error',
signal: AbortSignal.timeout(5000),
})
if (!response.ok) continue
if (tokenMatchesProof(await response.text(), args.token)) return true
} catch {
continue
}
}
return false
}
export async function proveSsoDomainOwnership(args: CheckSsoDomainProofArgs): Promise<SsoDomainVerificationMethod | null> {
if (isTrustedSsoDomain(args.domain)) return 'trusted'
if (await checkSsoDomainDnsTxt(args)) return 'dns'
if (await checkSsoDomainHttpFile(args)) return 'http'
return null
}
export function decryptSsoConfig(config: SsoConfigsSchemaTypeForSelect): SsoConfigsSchemaTypeForSelect {
return {
...config,
+78 -1
View File
@@ -1,4 +1,12 @@
import { type } from 'arktype'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import type { UserDbRecord } from '../../types/auth.types'
export type SamlOptionsArgs = {
config: SsoConfigsSchemaTypeForSelect
mode: 'assertion' | 'response'
callbackUrl: string
}
export const SsoProtocols = {
SAML: 'saml',
@@ -52,7 +60,76 @@ export const SsoConfigArkTypeUpdate = type({
'oidcScope?': 'string',
'defaultOrgRole?': "'admin' | 'member'",
'emailDomainRestriction?': 'string',
'emailDomainRestriction?': 'string > 0',
})
export type SsoConfigArgUpdate = typeof SsoConfigArkTypeUpdate.infer
export type CheckSsoDomainProofArgs = {
domain: string
token: string
}
export type SsoDomainVerificationMethod = 'dns' | 'http' | 'trusted'
export type StartDomainVerificationResult = {
token: string
dnsRecord: string
httpUrl: string
isDomainVerified: boolean
isDomainTrusted: boolean
}
export type CheckDomainVerificationResult = {
verified: boolean
method: SsoDomainVerificationMethod | null
}
export class SsoDomainNotVerifiedError extends Error {
readonly code = 'domain_unverified'
constructor() {
super('SSO domain is not verified')
this.name = 'SsoDomainNotVerifiedError'
}
}
export type FindSsoConfigByDomainAndOrgArgs = {
domain: string
organizationId: number
}
export type FindSsoIdentityArgs = {
ssoConfigId: number
externalId: string
}
export type FindSsoIdentityByUserArgs = {
ssoConfigId: number
userId: number
}
export type UpsertSsoIdentityArgs = {
userId: number
ssoConfigId: number
externalId: string
email: string
}
export type ResolveSsoUserArgs = {
ssoConfigId: number
email: string
externalId: string
preferredUsername?: string
}
export type ApplySsoIdpEmailArgs = {
user: UserDbRecord
email: string
}
export type SsoCallbackError = 'authentication_failed' | 'email_in_use'
export type ResolveSsoUserResult =
| { ok: true, user: UserDbRecord }
| { ok: false, error: SsoCallbackError }
+6
View File
@@ -115,6 +115,12 @@ export type UpdateUserCredentialsArgs = {
passwordHash: string;
};
export type UpdateUserEmailArgs = {
userId: number;
oldEmail: string;
email: string;
};
export type UpdateUserCredentialsResult = 'ok' | 'conflict' | 'error';
export const RefreshTokenSchema = z.object({
+9
View File
@@ -29,6 +29,15 @@ export function generateString(length: number) {
return result
}
export function generateLetters(length: number) {
let result = ''
const characters = 'abcdefghijklmnopqrstuvwxyz'
for (let i = 0; i < length; i++) {
result += characters.charAt(randomInt(characters.length))
}
return result
}
export function time() {
return Math.floor(Date.now() / 1000);
}
+19
View File
@@ -22,6 +22,23 @@ Users who sign in via SSO are automatically added to the organization that owns
Go to your organization's settings → **SSO** tab. You need the **admin** or **owner** role.
### Domain verification
SSO login stays off until the organization proves it owns the email domain (so another org on a shared instance cannot claim `gmail.com` or your company domain).
After you save the SSO config, TaskView shows a verification token. Use **one** of:
1. **DNS TXT** — add a TXT record on the domain:
`taskview-sso-verify=<token>`
2. **HTTP file** — serve the token (plain text) at:
`https://<domain>/.well-known/taskview-sso-verify.txt`
Then click **Check domain**. Either method is enough. After a successful check, SSO login is enabled.
**Closed-network / air-gapped installs:** you may not have public DNS. Set `SSO_TRUSTED_DOMAINS=company.com,corp.local` on the API server. Domains in that list skip the DNS/HTTP check and are treated as verified.
Existing SSO configs created before this check are not verified: logins stop until an admin completes verification or the domain is listed in `SSO_TRUSTED_DOMAINS`.
### SAML 2.0
**Required fields:**
@@ -145,6 +162,8 @@ Request IDs expire after 5 minutes.
| POST | `/module/sso/admin/configs` | Create SSO config |
| PATCH | `/module/sso/admin/configs/{configId}` | Update SSO config |
| DELETE | `/module/sso/admin/configs/{configId}` | Delete SSO config |
| POST | `/module/sso/admin/configs/{configId}/verify-domain` | Return DNS TXT and HTTP well-known proof for the domain |
| POST | `/module/sso/admin/configs/{configId}/verify-domain/check` | Check DNS TXT then HTTP file; enable SSO on success |
## Database tables
@@ -51,6 +51,7 @@ Unlike everything else on this page, this variable is set on the **web app conta
| `AUTH_LOGIN_METHODS` | No | all enabled | Comma-separated list of login methods to offer: `magic-link`, `password`, `sso`, `social`. Disabled methods disappear from the login page and their API endpoints return 403. The API refuses to start if the list contains a typo or disables every method. |
| `PASSWORD_CHANGE_CONFIRMATION` | No | `email` | How account password changes are confirmed: `email` — a confirmation code is sent to the user's email (requires SMTP); `password` — the user confirms with their current password (works without SMTP, recommended for installs without a mail server). |
| `ALLOW_PUBLIC_REGISTRATION` | No | `true` | Set to `false` to close the instance: strangers can no longer create accounts — the registration endpoint returns 403, and magic-link / social sign-in stop auto-creating users. Emails invited to an organization or project can still sign in and get their account created on first login. |
| `SSO_TRUSTED_DOMAINS` | No | empty | Comma-separated email domains that skip DNS/HTTP ownership checks for SSO (air-gapped / closed-network installs). Example: `company.com,corp.local`. On a public instance leave this unset so every org must prove it owns the domain. |
::callout{icon="i-lucide-shield" color="warning"}
Generate a strong JWT secret: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`
@@ -267,6 +268,8 @@ REFRESH_LIFE_TIME="9d"
#AUTH_LOGIN_METHODS="magic-link,password,sso,social"
# Password change confirmation: "email" (code by email, needs SMTP) or "password" (no SMTP needed)
#PASSWORD_CHANGE_CONFIRMATION="email"
# Air-gapped SSO: skip DNS/HTTP domain proof for these email domains
#SSO_TRUSTED_DOMAINS="company.com,corp.local"
SMTP_HOST=smtp
SMTP_PORT=587
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.51.0",
"version": "1.52.0",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -38,6 +38,9 @@ services:
ALLOW_PUBLIC_REGISTRATION: "false"
# IdP-facing URLs are built from this base (see sso-public-urls.test.ts)
API_PUBLIC_URL: "https://api.public.example"
# Domains that skip DNS/HTTP ownership proof — used by sso.test.ts to
# deterministically produce a *verified* config (method 'trusted').
SSO_TRUSTED_DOMAINS: "owned-sso.example"
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
@@ -26,7 +26,7 @@ afterAll(async () => {
describe('SSO: config management', () => {
let configId: number
it('should create SSO config with SAML protocol', async () => {
it('should create an unverified SSO config', async () => {
const config = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
@@ -43,6 +43,9 @@ describe('SSO: config management', () => {
expect(config.protocol).toBe('saml')
expect(config.displayName).toBe('Test SAML')
expect(config.emailDomainRestriction).toBe('sso-test.example')
expect(config.enabled).toBe(0)
expect(config.isDomainVerified).toBe(false)
expect(config.domainVerifyToken).toBeTruthy()
configId = config.id
})
@@ -82,12 +85,24 @@ describe('SSO: config management', () => {
expect(updated.displayName).toBe('Updated SAML')
})
it('should check domain and find provider', async () => {
it('should not list an unverified domain as a public provider', async () => {
const provider = await user1Api.sso.checkDomain('sso-test.example')
expect(provider).toBeNull()
})
expect(provider).toBeTruthy()
expect(provider!.id).toBe(configId)
expect(provider!.protocol).toBe('saml')
it('should return DNS and HTTP proof instructions', async () => {
const started = await user1Api.sso.startDomainVerification(configId)
expect(started.token).toBeTruthy()
expect(started.dnsRecord).toBe(`taskview-sso-verify=${started.token}`)
expect(started.httpUrl).toContain('/.well-known/taskview-sso-verify.txt')
expect(started.isDomainVerified).toBe(false)
})
it('should not mark a domain verified when DNS and HTTP proofs are missing', async () => {
const result = await user1Api.sso.checkDomainVerification(configId)
expect(result.verified).toBe(false)
expect(result.method).toBeNull()
})
it('should return null for unknown domain', async () => {
@@ -189,6 +204,131 @@ describe('SSO: config management', () => {
})
})
describe('SSO: cross-org domain squatting', () => {
const squatDomain = 'squat-test.example'
let secondOrgId: number
let firstConfigId: number
let secondConfigId: number
beforeAll(async () => {
const org = await user2Api.organizations.create({ name: 'SSO Squat Org' })
secondOrgId = org.id
})
afterAll(async () => {
await user1Api.sso.deleteConfig(firstConfigId).catch(() => {})
await user2Api.sso.deleteConfig(secondConfigId).catch(() => {})
await user2Api.organizations.delete(secondOrgId).catch(() => {})
})
it('lets a different org create an unverified config for the same domain (no squatting)', async () => {
const first = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Squat First',
emailDomainRestriction: squatDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-squat-1',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
firstConfigId = first.id
expect(first.isDomainVerified).toBe(false)
const second = await user2Api.sso.createConfig({
organizationId: secondOrgId,
protocol: 'saml',
displayName: 'Squat Second',
emailDomainRestriction: squatDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-squat-2',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
secondConfigId = second.id
expect(second.isDomainVerified).toBe(false)
expect(second.id).not.toBe(first.id)
})
it('still rejects a duplicate config for the same domain within one org', async () => {
try {
await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'oidc',
displayName: 'Squat Same Org',
emailDomainRestriction: squatDomain,
oidcIssuer: 'https://accounts.google.com',
oidcClientId: 'test',
oidcClientSecret: 'test',
oidcCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect.fail('Should have rejected duplicate domain within the same org')
} catch (error: any) {
expect(error.response?.status).toBe(409)
}
})
it('rejects a second org creating a config for a domain another org already verified', async () => {
const ownedDomain = 'owned-sso.example' // in SSO_TRUSTED_DOMAINS → verified on creation
const owner = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Owned First',
emailDomainRestriction: ownedDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-owned-1',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect(owner.isDomainVerified).toBe(true)
try {
await user2Api.sso.createConfig({
organizationId: secondOrgId,
protocol: 'saml',
displayName: 'Owned Second',
emailDomainRestriction: ownedDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-owned-2',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect.fail('Should have rejected a domain already verified by another org')
} catch (error: any) {
expect(error.response?.status).toBe(409)
} finally {
await user1Api.sso.deleteConfig(owner.id).catch(() => {})
}
})
it('rejects switching a pending config onto a domain another org already verified', async () => {
const ownedDomain = 'owned-sso.example'
const owner = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Owned For Update',
emailDomainRestriction: ownedDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-owned-3',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect(owner.isDomainVerified).toBe(true)
try {
// user2's still-pending squat config tries to grab the owned domain
await user2Api.sso.updateConfig(secondConfigId, { emailDomainRestriction: ownedDomain })
expect.fail('Should have rejected switching onto a domain owned by another org')
} catch (error: any) {
expect(error.response?.status).toBe(409)
} finally {
await user1Api.sso.deleteConfig(owner.id).catch(() => {})
}
})
})
describe('SSO: SCIM token management', () => {
let configId: number
@@ -4,6 +4,8 @@ import type {
SsoConfig,
SsoConfigArgCreate,
SsoConfigArgUpdate,
SsoDomainVerificationCheck,
SsoDomainVerificationStart,
SsoProviderPublic,
SsoPublicUrls,
} from './sso.types'
@@ -63,6 +65,18 @@ export default class TvSsoApi extends TvApiBase {
)
}
public async startDomainVerification(configId: number) {
return this.request(
this.$axios.post<AppResponse<SsoDomainVerificationStart>>(`${this.moduleUrl}/admin/configs/${configId}/verify-domain`)
)
}
public async checkDomainVerification(configId: number) {
return this.request(
this.$axios.post<AppResponse<SsoDomainVerificationCheck>>(`${this.moduleUrl}/admin/configs/${configId}/verify-domain/check`)
)
}
public async checkDomain(domain: string) {
return this.request(
this.$axios.get<AppResponse<SsoProviderPublic | null>>(`${this.moduleUrl}/providers`, {
@@ -25,11 +25,30 @@ export type SsoConfig = {
hasSamlSigningCert: boolean
hasOidcClientSecret: boolean
hasScimToken: boolean
domainVerifyToken: string | null
domainVerifiedAt: string | null
isDomainVerified: boolean
isDomainTrusted: boolean
domainVerifyDnsRecord: string | null
domainVerifyHttpUrl: string
createdAt: string
updatedAt: string
}
export type SsoDomainVerificationStart = {
token: string
dnsRecord: string
httpUrl: string
isDomainVerified: boolean
isDomainTrusted: boolean
}
export type SsoDomainVerificationCheck = {
verified: boolean
method: 'dns' | 'http' | 'trusted' | null
}
export type SsoConfigArgCreate = {
organizationId: number
protocol: 'saml' | 'oidc'
@@ -1,4 +1,5 @@
import { bigint, integer, pgSchema, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
import { bigint, integer, pgSchema, text, timestamp, unique, uniqueIndex, varchar } from 'drizzle-orm/pg-core'
import { UsersSchema } from './users.schema'
import { OrganizationsSchema } from './organizations.schema'
@@ -24,14 +25,21 @@ export const SsoConfigsSchema = pgSchema('tv_auth').table('sso_configs', {
oidcScope: varchar('oidc_scope'),
defaultOrgRole: varchar('default_org_role').notNull().default('member'),
emailDomainRestriction: varchar('email_domain_restriction').notNull().unique(),
emailDomainRestriction: varchar('email_domain_restriction').notNull(),
scimToken: varchar('scim_token'),
scimEnabled: integer('scim_enabled').notNull().default(0),
domainVerifyToken: varchar('domain_verify_token'),
domainVerifiedAt: timestamp('domain_verified_at'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
})
}, (table) => [
uniqueIndex('sso_configs_verified_domain_uniq')
.on(table.emailDomainRestriction)
.where(sql`${table.domainVerifiedAt} IS NOT NULL`),
])
export type SsoConfigsSchemaTypeForSelect = typeof SsoConfigsSchema.$inferSelect
export type SsoConfigsSchemaTypeForInsert = typeof SsoConfigsSchema.$inferInsert
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-mcp",
"version": "1.51.0",
"version": "1.52.0",
"description": "MCP (Model Context Protocol) server for TaskView — lets AI assistants (Claude Code, Claude Desktop, etc.) manage projects and tasks via the TaskView API",
"type": "module",
"bin": {
+2
View File
@@ -41,6 +41,8 @@ services:
condition: service_completed_successfully
env_file:
- ../../dockers-check/.env.taskview
environment:
SSO_TRUSTED_DOMAINS: sso-e2e.test,auto.sso-e2e.test
volumes:
- ./e2e_logs:/usr/src/app/logs
- ./e2e_updates:/usr/src/app/updates
+143
View File
@@ -0,0 +1,143 @@
import { createServer, type IncomingMessage, type Server } from 'node:http'
import { createSign, generateKeyPairSync, randomUUID } from 'node:crypto'
export type MockIdpUser = {
sub: string
email: string
name?: string
preferredUsername?: string
}
export type MockOidcIdp = {
issuer: string
clientId: string
clientSecret: string
setUser: (user: MockIdpUser) => void
close: () => Promise<void>
}
function base64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64url')
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let data = ''
req.on('data', (chunk) => (data += chunk))
req.on('end', () => resolve(data))
})
}
/**
* Minimal in-process OIDC identity provider for e2e tests: serves discovery,
* authorize (immediate redirect back with a code), token (RS256-signed id_token
* with the claims of the current test user) and JWKS endpoints.
*/
export async function startMockOidcIdp(port: number): Promise<MockOidcIdp> {
const issuer = `http://127.0.0.1:${port}`
const clientId = 'taskview-e2e-client'
const clientSecret = 'taskview-e2e-secret'
const kid = 'e2e-key'
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
const publicJwk = { ...publicKey.export({ format: 'jwk' }), kid, alg: 'RS256', use: 'sig' }
let currentUser: MockIdpUser = { sub: 'e2e-sub', email: 'e2e@example.test' }
const nonceByCode = new Map<string, string>()
function signIdToken(nonce: string | undefined): string {
const now = Math.floor(Date.now() / 1000)
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid }))
const payload = base64url(
JSON.stringify({
iss: issuer,
aud: clientId,
sub: currentUser.sub,
iat: now,
exp: now + 3600,
email: currentUser.email,
...(currentUser.name ? { name: currentUser.name } : {}),
...(currentUser.preferredUsername ? { preferred_username: currentUser.preferredUsername } : {}),
...(nonce ? { nonce } : {}),
}),
)
const signature = createSign('RSA-SHA256').update(`${header}.${payload}`).sign(privateKey)
return `${header}.${payload}.${base64url(signature)}`
}
const server: Server = createServer(async (req, res) => {
const url = new URL(req.url ?? '/', issuer)
if (url.pathname === '/.well-known/openid-configuration') {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(
JSON.stringify({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['openid', 'email', 'profile'],
}),
)
return
}
if (url.pathname === '/authorize') {
const redirectUri = url.searchParams.get('redirect_uri')
const state = url.searchParams.get('state')
const nonce = url.searchParams.get('nonce')
if (!redirectUri) {
res.writeHead(400).end('missing redirect_uri')
return
}
const code = randomUUID()
if (nonce) nonceByCode.set(code, nonce)
const target = new URL(redirectUri)
target.searchParams.set('code', code)
if (state) target.searchParams.set('state', state)
res.writeHead(302, { location: target.href }).end()
return
}
if (url.pathname === '/token' && req.method === 'POST') {
const body = new URLSearchParams(await readBody(req))
const code = body.get('code') ?? ''
const nonce = nonceByCode.get(code)
nonceByCode.delete(code)
res.writeHead(200, { 'content-type': 'application/json' })
res.end(
JSON.stringify({
access_token: randomUUID(),
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid email profile',
id_token: signIdToken(nonce),
}),
)
return
}
if (url.pathname === '/jwks') {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ keys: [publicJwk] }))
return
}
res.writeHead(404).end()
})
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
return {
issuer,
clientId,
clientSecret,
setUser: (user) => (currentUser = user),
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
}
}
+385
View File
@@ -0,0 +1,385 @@
import { test, expect, request as pwRequest, type APIRequestContext } from '@playwright/test'
import { TEST_USER } from './fixtures/auth'
import { startMockOidcIdp, type MockIdpUser, type MockOidcIdp } from './fixtures/mock-oidc-idp'
const API_URL = process.env.E2E_API_URL ?? 'http://localhost:1401'
const IDP_PORT = 14655
const RUN_ID = Date.now()
const EMAIL_DOMAIN = 'sso-e2e.test'
const AUTO_EMAIL_DOMAIN = 'auto.sso-e2e.test'
const TRUSTED_DOMAINS_HINT = 'SSO_TRUSTED_DOMAINS=sso-e2e.test,auto.sso-e2e.test'
const RANDOM_LOGIN_RE = /^[A-Za-z0-9]{7}$/
let api: APIRequestContext
let idp: MockOidcIdp
let adminToken: string
let ssoConfigId: number
let adminOrgId: number
let firstCollisionLogin: string
function getSetCookies(headers: { name: string, value: string }[]): string[] {
return headers
.filter((h) => h.name.toLowerCase() === 'set-cookie')
.map((h) => h.value.split(';')[0])
}
function decodeJwtPayload(token: string): { userData: { id: number, login: string, email: string } } {
return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString())
}
/**
* Walks the full OIDC dance against the running API and the mock IdP:
* initiate -> IdP authorize -> API callback -> one-time code -> JWT.
* Returns the login stored for the user, taken from the issued JWT payload.
*/
async function loginViaSso(args: { user: MockIdpUser, configId?: number }): Promise<{ login: string, email: string }> {
const { user, configId = ssoConfigId } = args
idp.setUser(user)
const flow = await pwRequest.newContext()
try {
const initiate = await flow.get(`${API_URL}/module/sso/login/${configId}`, { maxRedirects: 0 })
expect(initiate.status()).toBe(302)
const cookies = getSetCookies(initiate.headersArray())
expect(cookies.length).toBeGreaterThanOrEqual(3)
const authorizeUrl = initiate.headers()['location']
const authorize = await flow.get(authorizeUrl, { maxRedirects: 0 })
expect(authorize.status()).toBe(302)
const callbackUrl = authorize.headers()['location']
const callback = await flow.get(callbackUrl, {
maxRedirects: 0,
headers: { cookie: cookies.join('; ') },
})
expect(callback.status()).toBe(302)
const redirect = new URL(callback.headers()['location'])
expect(redirect.searchParams.has('sso_error'), `SSO callback failed: ${redirect.href}`).toBe(false)
const authData = JSON.parse(redirect.searchParams.get('tokens')!)
const byCode = await flow.post(`${API_URL}/module/auth/login-by-code`, {
data: { email: authData.email, code: authData.code },
})
expect(byCode.ok()).toBe(true)
const { access } = await byCode.json()
const { userData } = decodeJwtPayload(access)
return { login: userData.login, email: userData.email }
} finally {
await flow.dispose()
}
}
test.describe.configure({ mode: 'serial' })
test.describe('SSO OIDC login', () => {
test.beforeAll(async () => {
idp = await startMockOidcIdp(IDP_PORT)
api = await pwRequest.newContext()
const login = await api.post(`${API_URL}/module/auth/login`, {
form: { login: TEST_USER.login, password: TEST_USER.password },
})
expect(login.ok()).toBe(true)
adminToken = (await login.json()).access
const orgs = await api.get(`${API_URL}/module/organizations`, {
headers: { Authorization: `Bearer ${adminToken}` },
})
const adminOrg = (await orgs.json()).response
.find((org: { currentUserRole: string }) => ['owner', 'admin'].includes(org.currentUserRole))
expect(adminOrg).toBeTruthy()
adminOrgId = adminOrg.id
const listed = await api.get(`${API_URL}/module/sso/admin/configs`, {
headers: { Authorization: `Bearer ${adminToken}` },
params: { organizationId: adminOrg.id },
})
expect(listed.ok()).toBe(true)
for (const config of (await listed.json()).response as { id: number, emailDomainRestriction: string }[]) {
if ([EMAIL_DOMAIN, AUTO_EMAIL_DOMAIN].includes(config.emailDomainRestriction)) {
await api.delete(`${API_URL}/module/sso/admin/configs/${config.id}`, {
headers: { Authorization: `Bearer ${adminToken}` },
})
}
}
const created = await api.post(`${API_URL}/module/sso/admin/configs`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: {
organizationId: adminOrg.id,
protocol: 'oidc',
displayName: `E2E OIDC ${RUN_ID}`,
enabled: 1,
oidcIssuer: idp.issuer,
oidcClientId: idp.clientId,
oidcClientSecret: idp.clientSecret,
oidcCallbackUrl: `${API_URL}/module/sso/callback/0`,
oidcScope: 'openid email profile',
defaultOrgRole: 'member',
emailDomainRestriction: EMAIL_DOMAIN,
},
})
expect(created.ok()).toBe(true)
const createdBody = await created.json()
ssoConfigId = createdBody.response.id
expect(
createdBody.response.isDomainTrusted || createdBody.response.isDomainVerified,
`SSO login e2e needs the API to trust ${EMAIL_DOMAIN}. Set ${TRUSTED_DOMAINS_HINT} on the API.`,
).toBe(true)
expect(createdBody.response.enabled).toBe(1)
const patched = await api.patch(`${API_URL}/module/sso/admin/configs/${ssoConfigId}`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { oidcCallbackUrl: `${API_URL}/module/sso/callback/${ssoConfigId}` },
})
expect(patched.ok()).toBe(true)
})
test.afterAll(async () => {
if (ssoConfigId) {
await api.delete(`${API_URL}/module/sso/admin/configs/${ssoConfigId}`, {
headers: { Authorization: `Bearer ${adminToken}` },
})
}
await api?.dispose()
await idp?.close()
})
test('unverified domain cannot start SSO login', async () => {
const created = await api.post(`${API_URL}/module/sso/admin/configs`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: {
organizationId: adminOrgId,
protocol: 'oidc',
displayName: `E2E OIDC unverified ${RUN_ID}`,
enabled: 1,
oidcIssuer: idp.issuer,
oidcClientId: idp.clientId,
oidcClientSecret: idp.clientSecret,
oidcCallbackUrl: `${API_URL}/module/sso/callback/0`,
oidcScope: 'openid email profile',
defaultOrgRole: 'member',
emailDomainRestriction: `unverified-${RUN_ID}.example`,
},
})
expect(created.ok()).toBe(true)
const unverified = await created.json()
expect(unverified.response.isDomainVerified).toBe(false)
expect(unverified.response.enabled).toBe(0)
const unverifiedId = unverified.response.id
try {
const initiate = await api.get(`${API_URL}/module/sso/login/${unverifiedId}`, { maxRedirects: 0 })
expect([302, 404]).toContain(initiate.status())
if (initiate.status() === 302) {
const location = new URL(initiate.headers()['location'])
expect(location.searchParams.get('sso_error')).toBe('domain_unverified')
}
} finally {
await api.delete(`${API_URL}/module/sso/admin/configs/${unverifiedId}`, {
headers: { Authorization: `Bearer ${adminToken}` },
})
}
})
test('new SSO user gets preferred_username as login', async () => {
const preferredUsername = `pu.${RUN_ID}`
const { login } = await loginViaSso({
user: {
sub: `sub-1-${RUN_ID}`,
email: `first.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'First User',
preferredUsername,
},
})
expect(login).toBe(preferredUsername)
})
test('new SSO user without preferred_username gets a random 7-char login', async () => {
const { login } = await loginViaSso({
user: {
sub: `sub-2-${RUN_ID}`,
email: `second.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'Second User',
},
})
expect(login).toMatch(RANDOM_LOGIN_RE)
})
test('taken preferred_username gets a random letter suffix', async () => {
const preferredUsername = `pu.${RUN_ID}`
const { login } = await loginViaSso({
user: {
sub: `sub-3-${RUN_ID}`,
email: `third.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'Third User',
preferredUsername,
},
})
expect(login).toMatch(new RegExp(`^pu\\.${RUN_ID}\\.[a-z]{3}$`))
firstCollisionLogin = login
})
test('second collision gets a different letter suffix', async () => {
const preferredUsername = `pu.${RUN_ID}`
const { login } = await loginViaSso({
user: {
sub: `sub-4-${RUN_ID}`,
email: `fourth.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'Fourth User',
preferredUsername,
},
})
expect(login).toMatch(new RegExp(`^pu\\.${RUN_ID}\\.[a-z]{3}$`))
expect(login).not.toBe(firstCollisionLogin)
})
test('IdP email change keeps the same TaskView user', async () => {
const preferredUsername = `email.change.${RUN_ID}`
const sub = `sub-email-${RUN_ID}`
const first = await loginViaSso({
user: {
sub,
email: `before.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'Before Change',
preferredUsername,
},
})
expect(first.login).toBe(preferredUsername)
const second = await loginViaSso({
user: {
sub,
email: `after.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'After Change',
preferredUsername: `should.not.apply.${RUN_ID}`,
},
})
expect(second.login).toBe(preferredUsername)
expect(second.email).toBe(`after.${RUN_ID}@${EMAIL_DOMAIN}`)
})
test('IdP email change refuses an address already used by another user', async () => {
const taken = await loginViaSso({
user: {
sub: `sub-taken-${RUN_ID}`,
email: `taken.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'Taken User',
preferredUsername: `taken.${RUN_ID}`,
},
})
expect(taken.login).toBe(`taken.${RUN_ID}`)
await loginViaSso({
user: {
sub: `sub-changer-${RUN_ID}`,
email: `changer.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'Changer',
preferredUsername: `changer.${RUN_ID}`,
},
})
const flow = await pwRequest.newContext()
try {
idp.setUser({
sub: `sub-changer-${RUN_ID}`,
email: `taken.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'Changer',
preferredUsername: `changer.${RUN_ID}`,
})
const initiate = await flow.get(`${API_URL}/module/sso/login/${ssoConfigId}`, { maxRedirects: 0 })
expect(initiate.status()).toBe(302)
const cookies = getSetCookies(initiate.headersArray())
const authorize = await flow.get(initiate.headers()['location'], { maxRedirects: 0 })
expect(authorize.status()).toBe(302)
const callback = await flow.get(authorize.headers()['location'], {
maxRedirects: 0,
headers: { cookie: cookies.join('; ') },
})
expect(callback.status()).toBe(302)
const redirect = new URL(callback.headers()['location'])
expect(redirect.searchParams.get('sso_error')).toBe('email_in_use')
} finally {
await flow.dispose()
}
})
test('second IdP user cannot take an email already linked on this SSO', async () => {
const linked = await loginViaSso({
user: {
sub: `sub-linked-${RUN_ID}`,
email: `linked.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'Linked User',
preferredUsername: `linked.${RUN_ID}`,
},
})
expect(linked.login).toBe(`linked.${RUN_ID}`)
const flow = await pwRequest.newContext()
try {
idp.setUser({
sub: `sub-other-${RUN_ID}`,
email: `linked.${RUN_ID}@${EMAIL_DOMAIN}`,
name: 'Other User',
preferredUsername: `other.${RUN_ID}`,
})
const initiate = await flow.get(`${API_URL}/module/sso/login/${ssoConfigId}`, { maxRedirects: 0 })
expect(initiate.status()).toBe(302)
const cookies = getSetCookies(initiate.headersArray())
const authorize = await flow.get(initiate.headers()['location'], { maxRedirects: 0 })
expect(authorize.status()).toBe(302)
const callback = await flow.get(authorize.headers()['location'], {
maxRedirects: 0,
headers: { cookie: cookies.join('; ') },
})
expect(callback.status()).toBe(302)
const redirect = new URL(callback.headers()['location'])
expect(redirect.searchParams.get('sso_error')).toBe('email_in_use')
} finally {
await flow.dispose()
}
})
test('flow works without configured callback URL (auto-derived from request)', async () => {
const created = await api.post(`${API_URL}/module/sso/admin/configs`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: {
organizationId: adminOrgId,
protocol: 'oidc',
displayName: `E2E OIDC auto ${RUN_ID}`,
enabled: 1,
oidcIssuer: idp.issuer,
oidcClientId: idp.clientId,
oidcClientSecret: idp.clientSecret,
oidcCallbackUrl: '',
oidcScope: 'openid email profile',
defaultOrgRole: 'member',
emailDomainRestriction: AUTO_EMAIL_DOMAIN,
},
})
expect(created.ok()).toBe(true)
const autoBody = await created.json()
expect(
autoBody.response.isDomainTrusted || autoBody.response.isDomainVerified,
`SSO login e2e needs the API to trust ${AUTO_EMAIL_DOMAIN}. Set ${TRUSTED_DOMAINS_HINT} on the API.`,
).toBe(true)
const autoConfigId = autoBody.response.id
try {
const preferredUsername = `auto.pu.${RUN_ID}`
const { login } = await loginViaSso({
user: {
sub: `sub-5-${RUN_ID}`,
email: `fifth.${RUN_ID}@${AUTO_EMAIL_DOMAIN}`,
name: 'Fifth User',
preferredUsername,
},
configId: autoConfigId,
})
expect(login).toBe(preferredUsername)
} finally {
await api.delete(`${API_URL}/module/sso/admin/configs/${autoConfigId}`, {
headers: { Authorization: `Bearer ${adminToken}` },
})
}
})
})
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.51.0",
"version": "1.52.0",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",
@@ -34,6 +34,7 @@
</template>
<UTabs
v-model="activeTab"
:items="tabs"
class="w-full"
:ui="{ list: 'rounded-2xl', indicator: 'rounded-xl' }"
@@ -106,6 +107,7 @@
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Organization } from 'taskview-api'
import type { OrgDetailTab } from '../types'
import { useOrganizationStore } from '@/stores/organization.store'
import { useTaskView } from '@/composables/useTaskView'
import { useOrgPermissions } from '@/composables/useOrgPermissions'
@@ -115,6 +117,7 @@ import OrgSsoSettings from './OrgSsoSettings.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{
organization: Organization | null
initialTab?: OrgDetailTab
}>()
const { t } = useI18n()
@@ -127,17 +130,26 @@ const editName = ref('')
const editSlug = ref('')
const saving = ref(false)
const activeTab = ref<OrgDetailTab>('general')
const tabs = computed(() => {
const items = [
{ label: t('organizations.general'), slot: 'general' },
{ label: t('organizations.general'), slot: 'general', value: 'general' },
]
if (isAdmin.value) {
items.push({ label: t('organizations.members'), slot: 'members' })
items.push({ label: 'SSO', slot: 'sso' })
items.push({ label: t('organizations.members'), slot: 'members', value: 'members' })
items.push({ label: 'SSO', slot: 'sso', value: 'sso' })
}
return items
})
watch(open, (isOpen) => {
if (isOpen) {
const requested = props.initialTab ?? 'general'
activeTab.value = tabs.value.some(tab => tab.value === requested) ? requested : 'general'
}
})
watch(() => props.organization, (org) => {
if (org) {
editName.value = org.name
@@ -44,6 +44,11 @@
</div>
</div>
<OrgSsoDomainSection
:config="config"
@updated="$emit('updated')"
/>
<OrgSsoScimSection
:config="config"
:endpoint-url="scimEndpointUrl"
@@ -56,6 +61,7 @@
import { useI18n } from 'vue-i18n'
import type { SsoConfig } from 'taskview-api'
import OrgSsoScimSection from './OrgSsoScimSection.vue'
import OrgSsoDomainSection from './OrgSsoDomainSection.vue'
defineProps<{
config: SsoConfig
@@ -0,0 +1,133 @@
<template>
<div class="flex flex-col gap-3 p-3 rounded-md border border-default">
<div class="flex flex-col gap-2">
<div>
<p class="text-sm font-medium">
{{ t('sso.domainVerification') }}
</p>
<p class="text-xs text-dimmed">
{{ t('sso.domainVerificationDescription') }}
</p>
</div>
<UBadge
:color="verified ? 'success' : 'warning'"
icon="mage:exclamation-triangle"
>
{{ verified ? t('sso.domainVerified') : t('sso.domainUnverified') }}
</UBadge>
</div>
<p
v-if="config.isDomainTrusted"
class="text-xs text-dimmed"
>
{{ t('sso.domainTrustedHint') }}
</p>
<template v-else>
<div class="flex flex-col gap-1">
<p class="text-xs text-dimmed">
{{ t('sso.domainVerifyDns') }}
</p>
<div class="flex items-center gap-2">
<code class="text-xs flex-1 break-all">{{ dnsRecord }}</code>
<UButton
icon="i-lucide-copy"
size="xs"
variant="ghost"
:disabled="!dnsRecord"
@click="copyToClipboard(dnsRecord)"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<p class="text-xs text-dimmed">
{{ t('sso.domainVerifyHttp') }}
</p>
<div class="flex items-center gap-2">
<code class="text-xs flex-1 break-all">{{ httpUrl }}</code>
<UButton
icon="i-lucide-copy"
size="xs"
variant="ghost"
:disabled="!httpUrl"
@click="copyToClipboard(httpUrl)"
/>
</div>
<p class="text-xs text-dimmed">
{{ t('sso.domainVerifyHttpBody', { token: config.domainVerifyToken || '' }) }}
</p>
</div>
<UButton
:label="t('sso.domainVerifyCheck')"
icon="i-lucide-shield-check"
variant="soft"
size="lg"
:loading="checking"
@click="check"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { $tvApi } from '@/plugins/axios'
import type { SsoConfig } from 'taskview-api'
const props = defineProps<{
config: SsoConfig
}>()
const emit = defineEmits<{
updated: []
}>()
const { t } = useI18n()
const toast = useToast()
const checking = ref(false)
const verified = computed(() => props.config.isDomainVerified)
const dnsRecord = computed(() => props.config.domainVerifyDnsRecord || '')
const httpUrl = computed(() => props.config.domainVerifyHttpUrl || '')
onMounted(async () => {
if (props.config.domainVerifyToken || props.config.isDomainTrusted) return
try {
await $tvApi.sso.startDomainVerification(props.config.id)
emit('updated')
} catch {
/* token will appear after the next fetch */
}
})
async function copyToClipboard(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
toast.add({ title: t('sso.copied'), color: 'success' })
} catch {
toast.add({ title: t('sso.copyFailed'), color: 'error' })
}
}
async function check() {
checking.value = true
try {
const result = await $tvApi.sso.checkDomainVerification(props.config.id)
if (result.verified) {
toast.add({ title: t('sso.domainVerifySuccess'), color: 'success' })
emit('updated')
} else {
toast.add({ title: t('sso.domainVerifyFailed'), color: 'error' })
}
} catch {
toast.add({ title: t('sso.domainVerifyFailed'), color: 'error' })
} finally {
checking.value = false
}
}
</script>
@@ -30,7 +30,10 @@
</template>
</UFormField>
<UFormField :label="t('sso.oidcCallbackUrl')">
<UFormField
:label="t('sso.oidcCallbackUrl')"
:description="t('sso.callbackUrlAutoHint')"
>
<UInput
v-model="form.oidcCallbackUrl"
:placeholder="callbackUrlPlaceholder"
@@ -49,7 +49,10 @@
</template>
</UFormField>
<UFormField :label="t('sso.samlCallbackUrl')">
<UFormField
:label="t('sso.samlCallbackUrl')"
:description="t('sso.callbackUrlAutoHint')"
>
<UInput
v-model="form.samlCallbackUrl"
:placeholder="callbackUrlPlaceholder"
@@ -0,0 +1 @@
export type OrgDetailTab = 'general' | 'members' | 'sso'
@@ -54,12 +54,13 @@
class="flex items-center justify-center gap-2 mb-3"
>
<UButton
v-if="isAdmin"
icon="i-lucide-user-plus"
color="neutral"
variant="soft"
size="sm"
:ui="{ base: 'rounded-lg' }"
@click="isDetailOpen = true"
@click="openDetail('members')"
/>
<UButton
icon="i-lucide-pencil"
@@ -67,15 +68,16 @@
variant="soft"
size="sm"
:ui="{ base: 'rounded-lg' }"
@click="isDetailOpen = true"
@click="openDetail('general')"
/>
<UButton
v-if="isAdmin"
icon="i-lucide-shield"
color="neutral"
variant="soft"
size="sm"
:ui="{ base: 'rounded-lg' }"
@click="isDetailOpen = true"
@click="openDetail('sso')"
/>
</div>
@@ -86,6 +88,7 @@
<OrgDetailModal
v-model="isDetailOpen"
:organization="currentOrg"
:initial-tab="detailTab"
/>
</div>
</template>
@@ -95,8 +98,10 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import type { Organization } from 'taskview-api'
import type { OrgDetailTab } from '@/components/features/organizations/types'
import { useOrganizationStore } from '@/stores/organization.store'
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
import { useOrgPermissions } from '@/composables/useOrgPermissions'
import OrgCreateModal from '@/components/features/organizations/parts/OrgCreateModal.vue'
import OrgDetailModal from '@/components/features/organizations/parts/OrgDetailModal.vue'
@@ -105,9 +110,17 @@ const orgStore = useOrganizationStore()
const { organizations, currentOrg } = storeToRefs(orgStore)
const { switchOrg } = useOrgSwitcher()
const { isAdmin } = useOrgPermissions(() => currentOrg.value)
const open = ref(false)
const isCreateOpen = ref(false)
const isDetailOpen = ref(false)
const detailTab = ref<OrgDetailTab>('general')
function openDetail(tab: OrgDetailTab) {
detailTab.value = tab
isDetailOpen.value = true
}
function selectOrg(org: Organization) {
open.value = false
+14
View File
@@ -67,6 +67,8 @@ export default {
ssoNotFound: 'SSO nicht konfiguriert',
ssoNotFoundDescription: 'Kein SSO-Anbieter für die Domain {domain} gefunden',
ssoError: 'SSO-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.',
ssoDomainUnverified: 'SSO ist nicht verfügbar, bis die Organisation diese E-Mail-Domain bestätigt.',
ssoEmailInUse: 'Diese E-Mail wird bereits von einem anderen TaskView-Konto verwendet.',
invalidEmail: 'Ungültige E-Mail-Adresse',
loginRequired: 'Anmeldung erforderlich',
passwordRequired: 'Passwort erforderlich',
@@ -861,6 +863,7 @@ export default {
oidcClientId: 'Client-ID',
oidcClientSecret: 'Client-Secret',
oidcCallbackUrl: 'Callback-URL',
callbackUrlAutoHint: 'Leer lassen, um die URL automatisch zu ermitteln',
enabled: 'Aktiviert',
disabled: 'Deaktiviert',
callbackUrlLabel: 'Callback-URL (in Ihrem IdP verwenden)',
@@ -876,6 +879,17 @@ export default {
saveFailed: 'SSO-Konfiguration konnte nicht gespeichert werden',
deleteFailed: 'SSO-Konfiguration konnte nicht gelöscht werden',
fillRequired: 'Bitte füllen Sie alle erforderlichen Felder aus',
domainVerification: 'Domain-Verifizierung',
domainVerificationDescription: 'Weisen Sie den Besitz dieser E-Mail-Domain nach, bevor sich Benutzer per SSO anmelden können.',
domainVerified: 'Verifiziert',
domainUnverified: 'Nicht verifiziert',
domainTrustedHint: 'Diese Domain ist auf dem Server vertrauenswürdig (SSO_TRUSTED_DOMAINS).',
domainVerifyDns: 'DNS-TXT-Eintrag',
domainVerifyHttp: 'Oder diese Datei bereitstellen',
domainVerifyHttpBody: 'Dateiinhalt: {token}',
domainVerifyCheck: 'Domain prüfen',
domainVerifySuccess: 'Domain verifiziert. SSO-Anmeldung ist aktiviert.',
domainVerifyFailed: 'Domain noch nicht verifiziert. Prüfen Sie den DNS-TXT-Eintrag oder die Well-Known-Datei.',
},
scim: {
description: 'Benutzer-Lebenszyklus automatisch von Ihrem IdP synchronisieren',
+14
View File
@@ -79,6 +79,8 @@ export default {
ssoNotFound: 'SSO not configured',
ssoNotFoundDescription: 'No SSO provider found for domain {domain}',
ssoError: 'SSO authentication failed. Please try again.',
ssoDomainUnverified: 'SSO is not available until the organization verifies this email domain.',
ssoEmailInUse: 'This email is already used by another TaskView account.',
// Validation
invalidEmail: 'Invalid email address',
@@ -875,6 +877,7 @@ export default {
oidcClientId: 'Client ID',
oidcClientSecret: 'Client Secret',
oidcCallbackUrl: 'Callback URL',
callbackUrlAutoHint: 'Leave empty to detect automatically',
enabled: 'Enabled',
disabled: 'Disabled',
callbackUrlLabel: 'Callback URL (use this in your IdP)',
@@ -890,6 +893,17 @@ export default {
saveFailed: 'Failed to save SSO configuration',
deleteFailed: 'Failed to delete SSO configuration',
fillRequired: 'Please fill in all required fields',
domainVerification: 'Domain verification',
domainVerificationDescription: 'Prove you own this email domain before users can sign in with SSO.',
domainVerified: 'Verified',
domainUnverified: 'Not verified',
domainTrustedHint: 'This domain is trusted by the server (SSO_TRUSTED_DOMAINS).',
domainVerifyDns: 'DNS TXT record',
domainVerifyHttp: 'Or host this file',
domainVerifyHttpBody: 'File contents: {token}',
domainVerifyCheck: 'Check domain',
domainVerifySuccess: 'Domain verified. SSO login is enabled.',
domainVerifyFailed: 'Domain not verified yet. Check the DNS TXT record or the well-known file.',
},
scim: {
description: 'Automatically sync user lifecycle from your IdP',
+14
View File
@@ -67,6 +67,8 @@ export default {
ssoNotFound: 'SSO no configurado',
ssoNotFoundDescription: 'No se encontró ningún proveedor de SSO para el dominio {domain}',
ssoError: 'Error de autenticación SSO. Inténtalo de nuevo.',
ssoDomainUnverified: 'SSO no está disponible hasta que la organización verifique este dominio de correo.',
ssoEmailInUse: 'Este correo ya está usado por otra cuenta de TaskView.',
invalidEmail: 'Dirección de correo electrónico inválida',
loginRequired: 'El usuario es obligatorio',
passwordRequired: 'La contraseña es obligatoria',
@@ -861,6 +863,7 @@ export default {
oidcClientId: 'ID de cliente',
oidcClientSecret: 'Secreto de cliente',
oidcCallbackUrl: 'URL de Callback',
callbackUrlAutoHint: 'Déjalo vacío para detectarla automáticamente',
enabled: 'Activado',
disabled: 'Desactivado',
callbackUrlLabel: 'URL de Callback (úsala en tu IdP)',
@@ -876,6 +879,17 @@ export default {
saveFailed: 'Error al guardar la configuración de SSO',
deleteFailed: 'Error al eliminar la configuración de SSO',
fillRequired: 'Rellena todos los campos obligatorios',
domainVerification: 'Verificación de dominio',
domainVerificationDescription: 'Demuestra que eres el dueño de este dominio de correo antes de que los usuarios puedan iniciar sesión con SSO.',
domainVerified: 'Verificado',
domainUnverified: 'No verificado',
domainTrustedHint: 'Este dominio es de confianza en el servidor (SSO_TRUSTED_DOMAINS).',
domainVerifyDns: 'Registro DNS TXT',
domainVerifyHttp: 'O publica este archivo',
domainVerifyHttpBody: 'Contenido del archivo: {token}',
domainVerifyCheck: 'Comprobar dominio',
domainVerifySuccess: 'Dominio verificado. El inicio de sesión SSO está activado.',
domainVerifyFailed: 'El dominio aún no está verificado. Revisa el registro DNS TXT o el archivo well-known.',
},
scim: {
description: 'Sincroniza automáticamente el ciclo de vida de los usuarios desde tu IdP',
+14
View File
@@ -79,6 +79,8 @@ export default {
ssoNotFound: 'SSO не настроен',
ssoNotFoundDescription: 'Для домена {domain} не настроен SSO провайдер',
ssoError: 'Ошибка SSO авторизации. Попробуйте снова.',
ssoDomainUnverified: 'SSO недоступен, пока организация не подтвердит этот email-домен.',
ssoEmailInUse: 'Эта почта уже занята другим аккаунтом TaskView.',
// Validation
invalidEmail: 'Неверный email адрес',
@@ -848,6 +850,7 @@ export default {
oidcClientId: 'Client ID',
oidcClientSecret: 'Client Secret',
oidcCallbackUrl: 'Callback URL',
callbackUrlAutoHint: 'Можно оставить пустым — адрес будет определён автоматически',
enabled: 'Включён',
disabled: 'Выключен',
callbackUrlLabel: 'Callback URL (укажи в настройках IdP)',
@@ -863,6 +866,17 @@ export default {
saveFailed: 'Не удалось сохранить настройки SSO',
deleteFailed: 'Не удалось удалить SSO конфигурацию',
fillRequired: 'Заполните все обязательные поля',
domainVerification: 'Подтверждение домена',
domainVerificationDescription: 'Подтвердите владение email-доменом, прежде чем пользователи смогут входить через SSO.',
domainVerified: 'Подтверждён',
domainUnverified: 'Не подтверждён',
domainTrustedHint: 'Этот домен доверен на сервере (SSO_TRUSTED_DOMAINS).',
domainVerifyDns: 'DNS TXT-запись',
domainVerifyHttp: 'Или разместите этот файл',
domainVerifyHttpBody: 'Содержимое файла: {token}',
domainVerifyCheck: 'Проверить домен',
domainVerifySuccess: 'Домен подтверждён. Вход через SSO включён.',
domainVerifyFailed: 'Домен ещё не подтверждён. Проверьте DNS TXT или файл well-known.',
},
scim: {
description: 'Автоматическая синхронизация пользователей из IdP',
+32 -29
View File
@@ -49,39 +49,42 @@ onMounted(async () => {
return
}
// If user already has a valid token, redirect to dashboard
// Handle SSO error redirect
if (route.query.sso_error) {
const ssoErrorKey = {
'registration-disabled': 'auth.registrationDisabled',
domain_unverified: 'auth.ssoDomainUnverified',
email_in_use: 'auth.ssoEmailInUse',
}[String(route.query.sso_error)] ?? 'auth.ssoError'
toast.add({
title: t('auth.error'),
description: t(ssoErrorKey),
color: 'error',
})
}
const tokens = route.query.tokens as string
if (tokens) {
try {
const result = JSON.parse(decodeURIComponent(tokens)) as LoginTokens
await loginByCode(result.code, result.email)
return
} catch (error) {
console.error('Failed to process tokens from URL:', error)
toast.add({
title: t('auth.error'),
description: t('auth.loginFailed'),
color: 'error',
})
return
}
}
const existingToken = await $ls.getToken()
if (existingToken) {
await $ls.updateUserStoreByToken()
await redirectToUser(router)
return
}
// Handle SSO error redirect
if (route.query.sso_error) {
toast.add({
title: t('auth.error'),
description: route.query.sso_error === 'registration-disabled'
? t('auth.registrationDisabled')
: t('auth.ssoError'),
color: 'error',
})
}
// Handle OAuth callback tokens from URL
try {
const tokens = route.query.tokens as string
if (!tokens) return
const result = JSON.parse(decodeURIComponent(tokens)) as LoginTokens
await loginByCode(result.code, result.email)
} catch (error) {
console.error('Failed to process tokens from URL:', error)
toast.add({
title: t('auth.error'),
description: t('auth.loginFailed'),
color: 'error',
})
}
})