From 915e8d9f9f29253352a6da8baa28e7e6c684dba2 Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Tue, 18 Aug 2026 18:57:17 +0200 Subject: [PATCH 1/2] fix: sso domain verification and #77 --- api/.env.example | 3 + api/src/migrations/taskview/migrate.json | 13 + .../sql/1.63.0/0.sso-domain-verification.sql | 3 + .../1.63.0/1.sso-domain-verified-unique.sql | 6 + api/src/tv-modules/auth/AuthModel.ts | 33 +- api/src/tv-modules/sso/SsoController.ts | 234 +++++++++-- api/src/tv-modules/sso/SsoManager.ts | 115 +++++- api/src/tv-modules/sso/SsoRepository.ts | 81 +++- api/src/tv-modules/sso/SsoRoutes.ts | 2 + .../tv-modules/sso/providers/oidc.provider.ts | 13 +- .../tv-modules/sso/providers/saml.provider.ts | 29 +- .../sso/providers/sso-provider.interface.ts | 1 + api/src/tv-modules/sso/sso.utils.ts | 96 ++++- api/src/tv-modules/sso/types.ts | 79 +++- api/src/types/auth.types.ts | 6 + api/src/utils/helpers.ts | 9 + docs/2.features/11.sso.md | 19 + .../1.environment-variables.md | 3 + .../api/__tests__/docker/docker-compose.yml | 3 + .../src/api/__tests__/sso.test.ts | 150 ++++++- taskview-packages/taskview-api/src/api/sso.ts | 14 + .../taskview-api/src/api/sso.types.ts | 19 + .../src/schemas/sso.schema.ts | 14 +- web/e2e/docker-compose.yml | 2 + web/e2e/fixtures/mock-oidc-idp.ts | 143 +++++++ web/e2e/sso-oidc.spec.ts | 385 ++++++++++++++++++ .../organizations/parts/OrgSsoConfigCard.vue | 6 + .../parts/OrgSsoDomainSection.vue | 133 ++++++ .../organizations/parts/SsoOidcForm.vue | 5 +- .../organizations/parts/SsoSamlForm.vue | 5 +- web/src/locales/de.ts | 14 + web/src/locales/en.ts | 14 + web/src/locales/es.ts | 14 + web/src/locales/ru.ts | 14 + web/src/pages/login.vue | 61 +-- 35 files changed, 1633 insertions(+), 108 deletions(-) create mode 100644 api/src/migrations/taskview/sql/1.63.0/0.sso-domain-verification.sql create mode 100644 api/src/migrations/taskview/sql/1.63.0/1.sso-domain-verified-unique.sql create mode 100644 web/e2e/fixtures/mock-oidc-idp.ts create mode 100644 web/e2e/sso-oidc.spec.ts create mode 100644 web/src/components/features/organizations/parts/OrgSsoDomainSection.vue diff --git a/api/.env.example b/api/.env.example index 475d4fd..c82d87d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index e371cc3..95aface 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -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= or https:///.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)." + ] } } diff --git a/api/src/migrations/taskview/sql/1.63.0/0.sso-domain-verification.sql b/api/src/migrations/taskview/sql/1.63.0/0.sso-domain-verification.sql new file mode 100644 index 0000000..a997cc4 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.63.0/0.sso-domain-verification.sql @@ -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; diff --git a/api/src/migrations/taskview/sql/1.63.0/1.sso-domain-verified-unique.sql b/api/src/migrations/taskview/sql/1.63.0/1.sso-domain-verified-unique.sql new file mode 100644 index 0000000..79f90aa --- /dev/null +++ b/api/src/migrations/taskview/sql/1.63.0/1.sso-domain-verified-unique.sql @@ -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; diff --git a/api/src/tv-modules/auth/AuthModel.ts b/api/src/tv-modules/auth/AuthModel.ts index 4707089..ae04b07 100644 --- a/api/src/tv-modules/auth/AuthModel.ts +++ b/api/src/tv-modules/auth/AuthModel.ts @@ -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 { + 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 { try { const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2'; diff --git a/api/src/tv-modules/sso/SsoController.ts b/api/src/tv-modules/sso/SsoController.ts index 6ab75c8..c03cb38 100644 --- a/api/src/tv-modules/sso/SsoController.ts +++ b/api/src/tv-modules/sso/SsoController.ts @@ -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 { + 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 { + 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 { + 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() diff --git a/api/src/tv-modules/sso/SsoManager.ts b/api/src/tv-modules/sso/SsoManager.ts index 7c49379..14587fb 100644 --- a/api/src/tv-modules/sso/SsoManager.ts +++ b/api/src/tv-modules/sso/SsoManager.ts @@ -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 = { ...data } + const current = await this.repository.findById(configId) + if (!current) return null + + const encrypted: Partial & { + 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 { + 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 { + 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) } diff --git a/api/src/tv-modules/sso/SsoRepository.ts b/api/src/tv-modules/sso/SsoRepository.ts index 75f7d2a..b1a9030 100644 --- a/api/src/tv-modules/sso/SsoRepository.ts +++ b/api/src/tv-modules/sso/SsoRepository.ts @@ -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 { + 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 { + 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 { 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 { + async findIdentity(args: FindSsoIdentityArgs): Promise { + 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 { + 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 { + await this.db.dbDrizzle + .delete(SsoIdentitiesSchema) + .where(eq(SsoIdentitiesSchema.ssoConfigId, ssoConfigId)) + } + + async upsertIdentity(data: UpsertSsoIdentityArgs): Promise { const existing = await callWithCatch(() => this.db.dbDrizzle .select() diff --git a/api/src/tv-modules/sso/SsoRoutes.ts b/api/src/tv-modules/sso/SsoRoutes.ts index 0ece0bc..56ce3ba 100644 --- a/api/src/tv-modules/sso/SsoRoutes.ts +++ b/api/src/tv-modules/sso/SsoRoutes.ts @@ -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) } diff --git a/api/src/tv-modules/sso/providers/oidc.provider.ts b/api/src/tv-modules/sso/providers/oidc.provider.ts index 38e6a2e..a968222 100644 --- a/api/src/tv-modules/sso/providers/oidc.provider.ts +++ b/api/src/tv-modules/sso/providers/oidc.provider.ts @@ -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 { + 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 { 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}`, } } diff --git a/api/src/tv-modules/sso/providers/saml.provider.ts b/api/src/tv-modules/sso/providers/saml.provider.ts index 721b52a..f0f093e 100644 --- a/api/src/tv-modules/sso/providers/saml.provider.ts +++ b/api/src/tv-modules/sso/providers/saml.provider.ts @@ -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 { - 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 { + 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 } diff --git a/api/src/tv-modules/sso/providers/sso-provider.interface.ts b/api/src/tv-modules/sso/providers/sso-provider.interface.ts index 8eeba92..87dced5 100644 --- a/api/src/tv-modules/sso/providers/sso-provider.interface.ts +++ b/api/src/tv-modules/sso/providers/sso-provider.interface.ts @@ -4,6 +4,7 @@ export type SsoAuthResult = { email: string externalId: string displayName?: string + preferredUsername?: string provider: string } diff --git a/api/src/tv-modules/sso/sso.utils.ts b/api/src/tv-modules/sso/sso.utils.ts index fbc8ac8..92b1457 100644 --- a/api/src/tv-modules/sso/sso.utils.ts +++ b/api/src/tv-modules/sso/sso.utils.ts @@ -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 { + 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 { + 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 { + 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, diff --git a/api/src/tv-modules/sso/types.ts b/api/src/tv-modules/sso/types.ts index a519921..23eda11 100644 --- a/api/src/tv-modules/sso/types.ts +++ b/api/src/tv-modules/sso/types.ts @@ -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 } diff --git a/api/src/types/auth.types.ts b/api/src/types/auth.types.ts index af83ba7..53155c5 100644 --- a/api/src/types/auth.types.ts +++ b/api/src/types/auth.types.ts @@ -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({ diff --git a/api/src/utils/helpers.ts b/api/src/utils/helpers.ts index 728ff43..f1d5d2b 100644 --- a/api/src/utils/helpers.ts +++ b/api/src/utils/helpers.ts @@ -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); } diff --git a/docs/2.features/11.sso.md b/docs/2.features/11.sso.md index fd7b5fd..92bd077 100644 --- a/docs/2.features/11.sso.md +++ b/docs/2.features/11.sso.md @@ -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=` +2. **HTTP file** — serve the token (plain text) at: + `https:///.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 diff --git a/docs/4.configuration/1.environment-variables.md b/docs/4.configuration/1.environment-variables.md index 9d53d8e..e9c2db0 100644 --- a/docs/4.configuration/1.environment-variables.md +++ b/docs/4.configuration/1.environment-variables.md @@ -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 diff --git a/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml b/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml index 1abf2ae..e48c7da 100644 --- a/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml +++ b/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml @@ -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: diff --git a/taskview-packages/taskview-api/src/api/__tests__/sso.test.ts b/taskview-packages/taskview-api/src/api/__tests__/sso.test.ts index 7e4b716..548d963 100644 --- a/taskview-packages/taskview-api/src/api/__tests__/sso.test.ts +++ b/taskview-packages/taskview-api/src/api/__tests__/sso.test.ts @@ -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 diff --git a/taskview-packages/taskview-api/src/api/sso.ts b/taskview-packages/taskview-api/src/api/sso.ts index 176ff76..da4b767 100644 --- a/taskview-packages/taskview-api/src/api/sso.ts +++ b/taskview-packages/taskview-api/src/api/sso.ts @@ -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>(`${this.moduleUrl}/admin/configs/${configId}/verify-domain`) + ) + } + + public async checkDomainVerification(configId: number) { + return this.request( + this.$axios.post>(`${this.moduleUrl}/admin/configs/${configId}/verify-domain/check`) + ) + } + 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 0d4683c..8848e3e 100644 --- a/taskview-packages/taskview-api/src/api/sso.types.ts +++ b/taskview-packages/taskview-api/src/api/sso.types.ts @@ -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' 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 459b2cb..8967982 100644 --- a/taskview-packages/taskview-db-schemas/src/schemas/sso.schema.ts +++ b/taskview-packages/taskview-db-schemas/src/schemas/sso.schema.ts @@ -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 diff --git a/web/e2e/docker-compose.yml b/web/e2e/docker-compose.yml index 95a674d..cf3af3a 100644 --- a/web/e2e/docker-compose.yml +++ b/web/e2e/docker-compose.yml @@ -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 diff --git a/web/e2e/fixtures/mock-oidc-idp.ts b/web/e2e/fixtures/mock-oidc-idp.ts new file mode 100644 index 0000000..1dc8e86 --- /dev/null +++ b/web/e2e/fixtures/mock-oidc-idp.ts @@ -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 +} + +function base64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64url') +} + +function readBody(req: IncomingMessage): Promise { + 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 { + 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() + + 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((resolve) => server.listen(port, '127.0.0.1', resolve)) + + return { + issuer, + clientId, + clientSecret, + setUser: (user) => (currentUser = user), + close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), + } +} diff --git a/web/e2e/sso-oidc.spec.ts b/web/e2e/sso-oidc.spec.ts new file mode 100644 index 0000000..518550b --- /dev/null +++ b/web/e2e/sso-oidc.spec.ts @@ -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}` }, + }) + } + }) +}) diff --git a/web/src/components/features/organizations/parts/OrgSsoConfigCard.vue b/web/src/components/features/organizations/parts/OrgSsoConfigCard.vue index 4d8d77a..5058d3a 100644 --- a/web/src/components/features/organizations/parts/OrgSsoConfigCard.vue +++ b/web/src/components/features/organizations/parts/OrgSsoConfigCard.vue @@ -44,6 +44,11 @@ + + +
+
+
+

+ {{ t('sso.domainVerification') }} +

+

+ {{ t('sso.domainVerificationDescription') }} +

+
+ + {{ verified ? t('sso.domainVerified') : t('sso.domainUnverified') }} + +
+ +

+ {{ t('sso.domainTrustedHint') }} +

+ + +
+ + + diff --git a/web/src/components/features/organizations/parts/SsoOidcForm.vue b/web/src/components/features/organizations/parts/SsoOidcForm.vue index ed30294..5ef9ea0 100644 --- a/web/src/components/features/organizations/parts/SsoOidcForm.vue +++ b/web/src/components/features/organizations/parts/SsoOidcForm.vue @@ -30,7 +30,10 @@ - + - + { 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', - }) } }) From aaa876412da4b8531b81a4be5c233b9edee6a88f Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Thu, 20 Aug 2026 13:51:32 +0200 Subject: [PATCH 2/2] fix: #101 --- .../organizations/parts/OrgDetailModal.vue | 18 +++++++++++++++--- .../features/organizations/types.ts | 1 + .../dashboard-second/SidebarOrgSelect.vue | 19 ++++++++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 web/src/components/features/organizations/types.ts diff --git a/web/src/components/features/organizations/parts/OrgDetailModal.vue b/web/src/components/features/organizations/parts/OrgDetailModal.vue index 08069b2..28bb1e2 100644 --- a/web/src/components/features/organizations/parts/OrgDetailModal.vue +++ b/web/src/components/features/organizations/parts/OrgDetailModal.vue @@ -34,6 +34,7 @@ ({ 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('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 diff --git a/web/src/components/features/organizations/types.ts b/web/src/components/features/organizations/types.ts new file mode 100644 index 0000000..5aff3cd --- /dev/null +++ b/web/src/components/features/organizations/types.ts @@ -0,0 +1 @@ +export type OrgDetailTab = 'general' | 'members' | 'sso' diff --git a/web/src/components/sidebars/dashboard-second/SidebarOrgSelect.vue b/web/src/components/sidebars/dashboard-second/SidebarOrgSelect.vue index 5a5db1c..904061d 100644 --- a/web/src/components/sidebars/dashboard-second/SidebarOrgSelect.vue +++ b/web/src/components/sidebars/dashboard-second/SidebarOrgSelect.vue @@ -54,12 +54,13 @@ class="flex items-center justify-center gap-2 mb-3" > @@ -86,6 +88,7 @@ @@ -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('general') + +function openDetail(tab: OrgDetailTab) { + detailTab.value = tab + isDetailOpen.value = true +} function selectOrg(org: Organization) { open.value = false