mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Merge pull request #103 from Gimanh/fix/issues
fix: sso domain verification and #77
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))),
|
||||
}
|
||||
}
|
||||
@@ -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}` },
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user