"use strict";
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.enterpriseSsoCookies = exports.EnterpriseSsoService = void 0;
const crypto_1 = __importDefault(require("crypto"));
const config_1 = require("../config");
const enterpriseAuditService_1 = require("./enterpriseAuditService");
const enterpriseDb_1 = require("./enterpriseDb");
const SESSION_COOKIE_NAME = 'sp_sso_session';
const STATE_COOKIE_NAME = 'sp_oidc_state';
const SESSION_TOKEN_PREFIX = 'sp_sso_';
const DEFAULT_SESSION_TTL_MS = 8 * 60 * 60 * 1000;
function nowMs() {
    return Date.now();
}
function sanitizeId(value) {
    if (typeof value !== 'string')
        return '';
    return value.trim().replace(/[^a-zA-Z0-9._:-]/g, '').slice(0, 128);
}
function safeString(value) {
    if (typeof value !== 'string')
        return undefined;
    const trimmed = value.trim();
    return trimmed ? trimmed.replace(/[\r\n]/g, '').slice(0, 320) : undefined;
}
function hmac(value, secret) {
    return crypto_1.default.createHmac('sha256', secret).update(value).digest('base64url');
}
function parseCookieHeader(header) {
    const cookies = new Map();
    if (!header)
        return cookies;
    for (const part of header.split(';')) {
        const index = part.indexOf('=');
        if (index <= 0)
            continue;
        const name = part.slice(0, index).trim();
        const value = part.slice(index + 1).trim();
        if (name)
            cookies.set(name, decodeURIComponent(value));
    }
    return cookies;
}
function bearerTokenFromRequest(req) {
    const authHeader = req.headers.authorization;
    if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
        return authHeader.slice('Bearer '.length).trim();
    }
    return undefined;
}
function claimString(userInfo, keys) {
    for (const key of keys) {
        const value = userInfo.claims[key];
        const sanitized = safeString(value);
        if (sanitized)
            return sanitized;
    }
    return undefined;
}
function claimValue(userInfo, keys) {
    for (const key of keys) {
        const value = userInfo.claims[key];
        if (value !== undefined && value !== null)
            return value;
    }
    return undefined;
}
function parseDomainTenantMap(value) {
    const map = new Map();
    if (!value)
        return map;
    const trimmed = value.trim();
    if (!trimmed)
        return map;
    try {
        const parsed = JSON.parse(trimmed);
        for (const [domain, tenantId] of Object.entries(parsed)) {
            const sanitizedTenant = sanitizeId(tenantId);
            if (domain.trim() && sanitizedTenant) {
                map.set(domain.trim().toLowerCase(), sanitizedTenant);
            }
        }
        return map;
    }
    catch {
        // Fall through to comma-separated "example.com=tenant-a" parsing.
    }
    for (const entry of trimmed.split(',')) {
        const [domain, tenantId] = entry.split('=').map(part => part?.trim());
        const sanitizedTenant = sanitizeId(tenantId);
        if (domain && sanitizedTenant) {
            map.set(domain.toLowerCase(), sanitizedTenant);
        }
    }
    return map;
}
function scopesForRole(role) {
    if (role === 'org_admin' || role === 'workspace_admin')
        return ['*'];
    if (role === 'viewer')
        return ['trace:read', 'report:read'];
    return ['trace:read', 'trace:write', 'agent:run', 'report:read'];
}
function normalizeRoles(input, fallbackRole = 'analyst') {
    const raw = Array.isArray(input)
        ? input
        : typeof input === 'string'
            ? input.split(',')
            : [];
    const roles = raw
        .map(role => sanitizeId(role))
        .filter(Boolean);
    return roles.length > 0 ? [...new Set(roles)] : [fallbackRole];
}
function normalizeReturnTo(value) {
    const candidate = safeString(value);
    if (!candidate || !candidate.startsWith('/'))
        return undefined;
    if (candidate.startsWith('//'))
        return undefined;
    return candidate;
}
class EnterpriseSsoService {
    constructor(db = (0, enterpriseDb_1.openEnterpriseDb)()) {
        this.db = db;
    }
    static getInstance() {
        if (!EnterpriseSsoService.instance) {
            EnterpriseSsoService.instance = new EnterpriseSsoService();
        }
        return EnterpriseSsoService.instance;
    }
    static resetForTests() {
        EnterpriseSsoService.instance = undefined;
    }
    static setInstanceForTests(service) {
        EnterpriseSsoService.instance = service;
    }
    get sessionCookieName() {
        return SESSION_COOKIE_NAME;
    }
    get stateCookieName() {
        return STATE_COOKIE_NAME;
    }
    createStatePayload(returnTo) {
        return {
            state: crypto_1.default.randomBytes(24).toString('base64url'),
            nonce: crypto_1.default.randomBytes(24).toString('base64url'),
            returnTo: normalizeReturnTo(returnTo),
            createdAt: nowMs(),
        };
    }
    signStatePayload(payload) {
        return this.signJson(payload);
    }
    verifyStatePayload(signedValue) {
        if (!signedValue)
            return null;
        const parsed = this.verifyJson(signedValue);
        if (!parsed || !parsed.state || !parsed.nonce)
            return null;
        if (nowMs() - parsed.createdAt > 10 * 60 * 1000)
            return null;
        return parsed;
    }
    createSessionCookieValue(accessToken) {
        return accessToken;
    }
    resolveRequestIdentityFromRequest(req) {
        const token = this.extractSessionToken(req);
        if (!token)
            return null;
        const session = this.getSessionFromToken(token);
        if (!session || !session.selectedWorkspaceId)
            return null;
        return {
            userId: session.userId,
            email: session.authContext.email || '',
            subscription: 'enterprise',
            authType: 'sso',
            tenantId: session.tenantId,
            workspaceId: session.selectedWorkspaceId,
            roles: session.authContext.roles,
            scopes: session.authContext.scopes,
        };
    }
    hasSessionCredential(req) {
        return Boolean(this.extractSessionToken(req));
    }
    getOnboardingSessionFromRequest(req) {
        const token = this.extractSessionToken(req);
        return token ? this.getSessionFromToken(token) : null;
    }
    completeOidcLogin(userInfo) {
        const tenantId = this.resolveTenantId(userInfo);
        if (!tenantId) {
            return {
                status: 'needs_tenant_join',
                reason: 'No tenant claim, domain mapping, or default tenant matched this SSO identity',
            };
        }
        const createdUser = this.upsertTenantAndUser(tenantId, userInfo);
        const userId = this.userIdFor(userInfo);
        if (createdUser) {
            this.recordAudit({
                tenantId,
                actorUserId: userId,
                action: 'user_created',
                resourceType: 'user',
                resourceId: userId,
                metadata: { source: 'oidc', issuer: userInfo.issuer },
            });
        }
        const memberships = this.listMemberships(tenantId, userId);
        const selectedWorkspace = this.resolveSelectedWorkspace(userInfo, memberships);
        const roleClaim = claimValue(userInfo, ['roles', 'groups']);
        const roles = selectedWorkspace
            ? normalizeRoles(roleClaim, selectedWorkspace.role)
            : normalizeRoles(roleClaim);
        const scopes = [...new Set(roles.flatMap(scopesForRole))];
        const session = this.createSsoSession({
            tenantId,
            userId,
            selectedWorkspaceId: selectedWorkspace?.workspaceId,
            roles,
            scopes,
            email: userInfo.email,
            displayName: userInfo.displayName,
        });
        this.recordAudit({
            tenantId,
            workspaceId: selectedWorkspace?.workspaceId,
            actorUserId: userId,
            action: 'sso_login',
            resourceType: 'sso_session',
            resourceId: session.sessionId,
            metadata: { issuer: userInfo.issuer, subjectHash: this.subjectHash(userInfo) },
        });
        if (!selectedWorkspace && memberships.length === 0) {
            return {
                status: 'no_workspace_membership',
                accessToken: session.accessToken,
                sessionId: session.sessionId,
                tenantId,
                userId,
                expiresAt: session.expiresAt,
                workspaces: [],
            };
        }
        if (!selectedWorkspace) {
            return {
                status: 'needs_workspace_selection',
                accessToken: session.accessToken,
                sessionId: session.sessionId,
                tenantId,
                userId,
                expiresAt: session.expiresAt,
                workspaces: memberships,
            };
        }
        this.auditWorkspaceReady(tenantId, userId, selectedWorkspace.workspaceId, session.sessionId, true);
        return {
            status: 'ready',
            accessToken: session.accessToken,
            sessionId: session.sessionId,
            tenantId,
            userId,
            workspaceId: selectedWorkspace.workspaceId,
            expiresAt: session.expiresAt,
            workspaces: memberships,
        };
    }
    selectWorkspace(accessToken, workspaceIdInput) {
        const workspaceId = sanitizeId(workspaceIdInput);
        const session = this.getSessionFromToken(accessToken);
        if (!session) {
            return { status: 'needs_tenant_join', reason: 'SSO session is missing or expired' };
        }
        const membership = this.listMemberships(session.tenantId, session.userId)
            .find(item => item.workspaceId === workspaceId);
        if (!membership) {
            return {
                status: 'needs_workspace_selection',
                accessToken,
                sessionId: session.id,
                tenantId: session.tenantId,
                userId: session.userId,
                expiresAt: session.expiresAt,
                workspaces: this.listMemberships(session.tenantId, session.userId),
                reason: 'Selected workspace is not available to this user',
            };
        }
        const roles = [membership.role];
        const scopes = scopesForRole(membership.role);
        this.db.prepare(`
      UPDATE sso_sessions
      SET selected_workspace_id = ?, workspace_id = ?, auth_context_json = ?
      WHERE id = ?
    `).run(workspaceId, workspaceId, JSON.stringify({ ...session.authContext, roles, scopes }), session.id);
        this.auditWorkspaceReady(session.tenantId, session.userId, workspaceId, session.id, false);
        return {
            status: 'ready',
            accessToken,
            sessionId: session.id,
            tenantId: session.tenantId,
            userId: session.userId,
            workspaceId,
            expiresAt: session.expiresAt,
            workspaces: this.listMemberships(session.tenantId, session.userId),
        };
    }
    revokeSession(accessToken) {
        const sessionId = this.sessionIdFromToken(accessToken);
        if (!sessionId)
            return false;
        const result = this.db.prepare(`
      UPDATE sso_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL
    `).run(nowMs(), sessionId);
        return result.changes > 0;
    }
    listAuditEvents() {
        return (0, enterpriseAuditService_1.listEnterpriseAuditEvents)(this.db);
    }
    extractSessionToken(req) {
        const bearer = bearerTokenFromRequest(req);
        if (bearer?.startsWith(SESSION_TOKEN_PREFIX))
            return bearer;
        const cookieToken = parseCookieHeader(req.headers.cookie).get(SESSION_COOKIE_NAME);
        return cookieToken?.startsWith(SESSION_TOKEN_PREFIX) ? cookieToken : undefined;
    }
    createSsoSession(input) {
        const sessionId = crypto_1.default.randomUUID();
        const createdAt = nowMs();
        const expiresAt = createdAt + this.sessionTtlMs();
        this.db.prepare(`
      INSERT INTO sso_sessions
        (id, tenant_id, workspace_id, user_id, selected_workspace_id, auth_context_json, created_at, expires_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    `).run(sessionId, input.tenantId, input.selectedWorkspaceId ?? null, input.userId, input.selectedWorkspaceId ?? null, JSON.stringify({
            authType: 'sso',
            roles: input.roles,
            scopes: input.scopes,
            email: input.email,
            displayName: input.displayName,
        }), createdAt, expiresAt);
        return {
            sessionId,
            accessToken: this.signSessionId(sessionId),
            expiresAt,
        };
    }
    getSessionFromToken(accessToken) {
        const sessionId = this.sessionIdFromToken(accessToken);
        if (!sessionId)
            return null;
        const row = this.db.prepare(`
      SELECT * FROM sso_sessions WHERE id = ?
    `).get(sessionId);
        if (!row || row.revoked_at || row.expires_at <= nowMs())
            return null;
        return {
            id: row.id,
            tenantId: row.tenant_id,
            workspaceId: row.workspace_id ?? undefined,
            userId: row.user_id,
            selectedWorkspaceId: row.selected_workspace_id ?? undefined,
            authContext: JSON.parse(row.auth_context_json),
            createdAt: row.created_at,
            expiresAt: row.expires_at,
            revokedAt: row.revoked_at ?? undefined,
        };
    }
    sessionIdFromToken(accessToken) {
        if (!accessToken.startsWith(SESSION_TOKEN_PREFIX))
            return null;
        const signed = accessToken.slice(SESSION_TOKEN_PREFIX.length);
        const separator = signed.lastIndexOf('.');
        if (separator <= 0)
            return null;
        const sessionId = signed.slice(0, separator);
        const signature = signed.slice(separator + 1);
        const expected = hmac(sessionId, this.cookieSecret());
        if (signature.length !== expected.length)
            return null;
        if (!crypto_1.default.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)))
            return null;
        return sessionId;
    }
    signSessionId(sessionId) {
        return `${SESSION_TOKEN_PREFIX}${sessionId}.${hmac(sessionId, this.cookieSecret())}`;
    }
    signJson(payload) {
        const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
        return `${encoded}.${hmac(encoded, this.cookieSecret())}`;
    }
    verifyJson(signedValue) {
        const separator = signedValue.lastIndexOf('.');
        if (separator <= 0)
            return null;
        const encoded = signedValue.slice(0, separator);
        const signature = signedValue.slice(separator + 1);
        const expected = hmac(encoded, this.cookieSecret());
        if (signature.length !== expected.length)
            return null;
        if (!crypto_1.default.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)))
            return null;
        try {
            return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
        }
        catch {
            return null;
        }
    }
    cookieSecret() {
        const configured = process.env.SMARTPERFETTO_SSO_COOKIE_SECRET
            || process.env.SMARTPERFETTO_API_KEY;
        if (configured && configured.length >= 16)
            return configured;
        if ((0, config_1.resolveFeatureConfig)(process.env).enterprise) {
            throw new Error('SMARTPERFETTO_SSO_COOKIE_SECRET must be set for enterprise SSO');
        }
        return 'dev-only-smartperfetto-sso-cookie-secret';
    }
    sessionTtlMs() {
        const parsed = Number.parseInt(process.env.SMARTPERFETTO_SSO_SESSION_TTL_MS || '', 10);
        return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SESSION_TTL_MS;
    }
    resolveTenantId(userInfo) {
        const claimTenant = sanitizeId(claimString(userInfo, [
            'smartperfetto_tenant_id',
            'tenant_id',
            'https://smartperfetto.dev/tenant_id',
        ]));
        if (claimTenant)
            return claimTenant;
        const email = userInfo.email || claimString(userInfo, ['email']);
        const domain = email?.split('@')[1]?.toLowerCase();
        if (domain) {
            const mappedTenant = parseDomainTenantMap(process.env.SMARTPERFETTO_OIDC_EMAIL_DOMAIN_MAP).get(domain);
            if (mappedTenant)
                return mappedTenant;
        }
        const defaultTenant = sanitizeId(process.env.SMARTPERFETTO_OIDC_DEFAULT_TENANT_ID);
        return defaultTenant || null;
    }
    userIdFor(userInfo) {
        const hash = crypto_1.default
            .createHash('sha256')
            .update(`${userInfo.issuer}|${userInfo.subject}`)
            .digest('hex')
            .slice(0, 20);
        return `sso-${hash}`;
    }
    subjectHash(userInfo) {
        return crypto_1.default
            .createHash('sha256')
            .update(`${userInfo.issuer}|${userInfo.subject}`)
            .digest('hex')
            .slice(0, 12);
    }
    upsertTenantAndUser(tenantId, userInfo) {
        const existing = this.db.prepare('SELECT id FROM users WHERE id = ?').get(this.userIdFor(userInfo));
        const now = nowMs();
        this.db.prepare(`
      INSERT OR IGNORE INTO organizations (id, name, status, plan, created_at, updated_at)
      VALUES (?, ?, 'active', 'enterprise', ?, ?)
    `).run(tenantId, tenantId, now, now);
        this.db.prepare(`
      INSERT INTO users (id, tenant_id, email, display_name, idp_subject, created_at, updated_at)
      VALUES (?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(id) DO UPDATE SET
        email = excluded.email,
        display_name = excluded.display_name,
        idp_subject = excluded.idp_subject,
        updated_at = excluded.updated_at
    `).run(this.userIdFor(userInfo), tenantId, userInfo.email || `${this.userIdFor(userInfo)}@sso.local`, userInfo.displayName || userInfo.email || this.userIdFor(userInfo), `${userInfo.issuer}|${userInfo.subject}`, now, now);
        return !existing;
    }
    listMemberships(tenantId, userId) {
        return this.db.prepare(`
      SELECT m.workspace_id, w.name, m.role
      FROM memberships m
      JOIN workspaces w ON w.id = m.workspace_id AND w.tenant_id = m.tenant_id
      WHERE m.tenant_id = ? AND m.user_id = ?
      ORDER BY w.name ASC
    `).all(tenantId, userId).map(row => ({
            workspaceId: row.workspace_id,
            name: row.name,
            role: row.role,
        }));
    }
    resolveSelectedWorkspace(userInfo, memberships) {
        const claimWorkspace = sanitizeId(claimString(userInfo, [
            'smartperfetto_workspace_id',
            'workspace_id',
            'https://smartperfetto.dev/workspace_id',
        ]));
        if (claimWorkspace) {
            return memberships.find(item => item.workspaceId === claimWorkspace) || null;
        }
        return memberships.length === 1 ? memberships[0] : null;
    }
    auditWorkspaceReady(tenantId, userId, workspaceId, sessionId, automatic) {
        this.recordAudit({
            tenantId,
            workspaceId,
            actorUserId: userId,
            action: 'workspace_selected',
            resourceType: 'workspace',
            resourceId: workspaceId,
            metadata: { automatic },
        });
        this.recordAudit({
            tenantId,
            workspaceId,
            actorUserId: userId,
            action: 'provider_default_resolved',
            resourceType: 'provider',
            resourceId: 'default',
            metadata: { sessionId },
        });
    }
    recordAudit(input) {
        (0, enterpriseAuditService_1.recordEnterpriseAuditEvent)(this.db, input);
    }
}
exports.EnterpriseSsoService = EnterpriseSsoService;
exports.enterpriseSsoCookies = {
    session: SESSION_COOKIE_NAME,
    state: STATE_COOKIE_NAME,
};
//# sourceMappingURL=enterpriseSsoService.js.map