import { __decorate, __metadata, __param } from "tslib";
import { createHash, timingSafeEqual } from 'node:crypto';
import { GraphQLError } from 'graphql';
import { Inject, Injectable, Scope } from 'graphql-modules';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { narrowReadScope, readScopeFromMemberships, resolveWriteTargetBusinessId, } from '../../../shared/helpers/auth-scope.js';
import { ENVIRONMENT, RAW_AUTH } from '../../../shared/tokens.js';
import { DBProvider } from '../../app-providers/db.provider.js';
import { ALLOWED_API_KEY_ROLES } from '../helpers/api-keys.helper.js';
// Global cache for JWKS functions to prevent re-fetching on every request
const jwksCache = new Map();
export async function handleDevBypassAuth(db, userId) {
    const normalizedUserId = userId.trim();
    if (!normalizedUserId) {
        return null;
    }
    // Resolve ALL memberships for the user (previously LIMIT 1). The primary
    // membership (most recently updated) still backs the single-business tenant
    // context for now; scope rules are applied in a later step.
    const { rows } = await db.query(`SELECT user_id, business_id, role_id, auth0_user_id
     FROM accounter_schema.business_users
     WHERE user_id = $1
     ORDER BY updated_at DESC`, [normalizedUserId]);
    if (!rows.length) {
        return null;
    }
    const primary = rows[0];
    const memberships = rows.map(row => ({
        businessId: row.business_id,
        roleId: row.role_id,
    }));
    return {
        authType: 'devBypass',
        token: normalizedUserId,
        user: {
            userId: primary.user_id,
            auth0UserId: primary.auth0_user_id,
            email: 'dev-user',
            roleId: primary.role_id,
            permissions: [],
            emailVerified: true,
            permissionsVersion: 0,
        },
        tenant: {
            businessId: primary.business_id,
            roleId: primary.role_id,
        },
        memberships,
        // Default read scope is every accessible business; the provider narrows it
        // when a valid X-Business-Scope header is requested.
        activeReadScope: readScopeFromMemberships(memberships),
    };
}
let AuthContextProvider = class AuthContextProvider {
    env;
    rawAuth;
    db;
    cachedContext = undefined;
    handlingAuth = null;
    constructor(env, rawAuth, db) {
        this.env = env;
        this.rawAuth = rawAuth;
        this.db = db;
    }
    async getJwtIdentity() {
        const token = this.rawAuth.token;
        if (this.rawAuth.authType !== 'jwt' || !token) {
            return null;
        }
        try {
            if (!this.env.auth0) {
                return null;
            }
            const domain = this.env.auth0.domain;
            let JWKS = jwksCache.get(domain);
            if (!JWKS) {
                JWKS = createRemoteJWKSet(new URL(`https://${domain}/.well-known/jwks.json`));
                jwksCache.set(domain, JWKS);
            }
            const { payload } = await jwtVerify(token, JWKS, {
                issuer: `https://${domain}/`,
                audience: this.env.auth0.audience,
            });
            const auth0UserId = payload.sub;
            if (!auth0UserId) {
                return null;
            }
            const jwtEmailClaim = payload['email'];
            const jwtEmailVerifiedClaim = payload['email_verified'];
            return {
                auth0UserId,
                email: typeof jwtEmailClaim === 'string' ? jwtEmailClaim : null,
                emailVerified: jwtEmailVerifiedClaim === true,
            };
        }
        catch {
            return null;
        }
    }
    async getAuthContext() {
        if (this.cachedContext !== undefined) {
            return this.cachedContext;
        }
        if (!this.rawAuth.authType) {
            return null;
        }
        if (this.handlingAuth) {
            return this.handlingAuth;
        }
        if (this.rawAuth.authType === 'jwt') {
            this.handlingAuth = this.handleJwtAuth();
            return this.handlingAuth;
        }
        if (this.rawAuth.authType === 'apiKey') {
            this.handlingAuth = this.handleApiKeyAuth();
            return this.handlingAuth;
        }
        if (this.rawAuth.authType === 'devBypass') {
            this.handlingAuth = this.handleDevBypassAuth();
            return this.handlingAuth;
        }
        if (this.rawAuth.authType === 'gatewayControlPlane') {
            this.handlingAuth = this.handleGatewayControlPlaneAuth();
            return this.handlingAuth;
        }
        return null;
    }
    /**
     * Resolve the request's read scope and attach it to the context.
     *
     * - Defaults to every business the user belongs to.
     * - When a valid `X-Business-Scope` header is present, narrows to that subset;
     *   rejects (returns null) if it requests a business outside the memberships
     *   or if the header was malformed.
     * - API keys are pinned to their single business and ignore the header.
     */
    applyRequestedReadScope(context) {
        const memberships = context.memberships ?? [];
        const defaultScope = readScopeFromMemberships(memberships);
        if (context.authType === 'apiKey') {
            return { ...context, activeReadScope: defaultScope };
        }
        const requested = this.rawAuth.requestedBusinessScope;
        if (!requested || requested.kind === 'absent') {
            return { ...context, activeReadScope: defaultScope };
        }
        if (requested.kind === 'invalid') {
            console.warn('AuthContext: rejecting request with malformed X-Business-Scope header', {
                errors: requested.errors,
                userId: context.user?.userId,
            });
            throw new GraphQLError('X-Business-Scope header is malformed', {
                extensions: { code: 'FORBIDDEN' },
            });
        }
        const narrowed = narrowReadScope(memberships, requested.businessIds);
        if (!narrowed) {
            console.warn('AuthContext: rejecting X-Business-Scope outside of user memberships', {
                requested: requested.businessIds,
                allowed: memberships.map(m => m.businessId),
                userId: context.user?.userId,
            });
            throw new GraphQLError('X-Business-Scope contains businesses outside your membership', {
                extensions: { code: 'FORBIDDEN' },
            });
        }
        // Re-point the single write-target / owning business to stay consistent with
        // the narrowed scope. Without this, `tenant.businessId` keeps pointing at the
        // primary membership even when it falls outside the requested scope, so any
        // consumer reading `tenant.businessId` directly would operate on the wrong
        // business. When the primary is still in scope this is a no-op.
        const writeTargetId = resolveWriteTargetBusinessId(context.tenant.businessId, narrowed);
        const targetMembership = writeTargetId
            ? memberships.find(m => m.businessId === writeTargetId)
            : undefined;
        const tenant = writeTargetId && writeTargetId !== context.tenant.businessId
            ? {
                ...context.tenant,
                businessId: writeTargetId,
                roleId: targetMembership?.roleId ?? context.tenant.roleId,
                businessName: targetMembership?.businessName,
            }
            : context.tenant;
        return { ...context, tenant, activeReadScope: narrowed };
    }
    async handleDevBypassAuth() {
        const userId = this.rawAuth.token;
        if (!userId) {
            return null;
        }
        try {
            const context = await handleDevBypassAuth(this.db, userId);
            const scoped = context ? this.applyRequestedReadScope(context) : null;
            this.cachedContext = scoped;
            this.handlingAuth = null;
            return scoped;
        }
        catch (error) {
            this.handlingAuth = null;
            if (error instanceof GraphQLError) {
                throw error;
            }
            console.error('AuthContext: Failed to process dev bypass auth', error);
            return null;
        }
    }
    async handleApiKeyAuth() {
        const token = this.rawAuth.token;
        if (!token) {
            return null;
        }
        try {
            const context = await this.resolveApiKeyContext(token);
            const scoped = context ? this.applyRequestedReadScope(context) : null;
            this.cachedContext = scoped;
            this.handlingAuth = null;
            return scoped;
        }
        catch (error) {
            this.handlingAuth = null;
            if (error instanceof GraphQLError) {
                throw error;
            }
            console.error('AuthContext: Failed to process API key', error);
            return null;
        }
    }
    async resolveApiKeyContext(plaintextKey) {
        const keyHash = createHash('sha256').update(plaintextKey).digest('hex');
        const { rows } = await this.db.query(`SELECT id, business_id, role_id
       FROM accounter_schema.api_keys
       WHERE key_hash = $1
         AND revoked_at IS NULL`, [keyHash]);
        if (!rows.length) {
            return null;
        }
        const apiKey = rows[0];
        if (!ALLOWED_API_KEY_ROLES.includes(apiKey.role_id)) {
            console.warn(`AuthContext: API key has disallowed role '${apiKey.role_id}', rejecting`);
            return null;
        }
        // Reduce write amplification by touching usage timestamp at most once per hour.
        await this.db.query(`UPDATE accounter_schema.api_keys
       SET last_used_at = NOW()
       WHERE id = $1
         AND (last_used_at IS NULL OR last_used_at < NOW() - INTERVAL '1 hour')`, [apiKey.id]);
        return {
            authType: 'apiKey',
            token: plaintextKey,
            tenant: {
                businessId: apiKey.business_id,
            },
            user: {
                userId: `api-key:${apiKey.id}`,
                auth0UserId: null,
                email: '',
                roleId: apiKey.role_id,
                permissions: [],
                emailVerified: true,
                permissionsVersion: 0,
            },
            // API keys are pinned to a single business, so the membership set is
            // exactly that one business.
            memberships: [{ businessId: apiKey.business_id, roleId: apiKey.role_id }],
        };
    }
    async handleGatewayControlPlaneAuth() {
        // Yield to the microtask queue so getAuthContext can assign this.handlingAuth
        // before any early-return path clears it. Without this, the function runs
        // synchronously (no await), clearing this.handlingAuth before the caller's
        // assignment, which then overwrites null with the resolved promise.
        await Promise.resolve();
        const token = this.rawAuth.token;
        if (!token) {
            this.handlingAuth = null;
            this.cachedContext = null;
            return null;
        }
        const expectedToken = this.env.gatewayControlPlane?.token;
        if (!expectedToken) {
            // eslint-disable-next-line no-console
            console.warn('AuthContext: GATEWAY_CP_TOKEN is not configured; rejecting gateway control-plane auth attempt');
            this.handlingAuth = null;
            this.cachedContext = null;
            return null;
        }
        // Timing-safe comparison: hash both values to ensure equal-length buffers
        // and prevent token-length leakage.
        const tokenHash = createHash('sha256').update(token).digest();
        const expectedHash = createHash('sha256').update(expectedToken).digest();
        if (!timingSafeEqual(tokenHash, expectedHash)) {
            this.handlingAuth = null;
            this.cachedContext = null;
            return null;
        }
        const context = {
            authType: 'gatewayControlPlane',
            token,
            user: {
                userId: 'gateway-control-plane',
                auth0UserId: null,
                email: '',
                roleId: 'gateway_control_plane',
                permissions: [],
                emailVerified: false,
                permissionsVersion: 0,
            },
            // Empty businessId: no real tenant context for the control-plane identity.
            // TenantAwareDBClient will fail if called with this context, enforcing that
            // control-plane operations only use the raw DBProvider pool.
            tenant: { businessId: '' },
        };
        this.cachedContext = context;
        this.handlingAuth = null;
        return context;
    }
    async handleJwtAuth() {
        const token = this.rawAuth.token;
        if (!token) {
            return null;
        }
        try {
            if (!this.env.auth0) {
                throw new Error('Auth0 configuration is missing');
            }
            // 1. Verify JWT signature using Auth0 JWKS
            const domain = this.env.auth0.domain;
            let JWKS = jwksCache.get(domain);
            if (!JWKS) {
                JWKS = createRemoteJWKSet(new URL(`https://${domain}/.well-known/jwks.json`));
                jwksCache.set(domain, JWKS);
            }
            const { payload } = await jwtVerify(token, JWKS, {
                issuer: `https://${domain}/`,
                audience: this.env.auth0.audience,
            });
            // 2. Extract Auth0 user ID
            const auth0UserId = payload.sub;
            if (!auth0UserId) {
                console.error('AuthContext: Missing sub claim in JWT');
                this.handlingAuth = null;
                this.cachedContext = null;
                return null;
            }
            const jwtEmailClaim = payload['email'];
            const jwtEmailVerifiedClaim = payload['email_verified'];
            const jwtEmail = typeof jwtEmailClaim === 'string' ? jwtEmailClaim : null;
            const jwtEmailVerified = jwtEmailVerifiedClaim === true;
            // 3. Map to local user and business (with verified-email fallback relinking).
            const userContext = await this.mapAuth0UserToLocal(auth0UserId, jwtEmail, jwtEmailVerified);
            if (!userContext) {
                console.warn(`AuthContext: User not found/linked in local DB: ${auth0UserId}`);
                this.handlingAuth = null;
                this.cachedContext = null;
                return null;
            }
            // 4. Construct AuthContext
            const authContext = {
                authType: 'jwt', // Will be treated as AuthType
                token,
                user: {
                    userId: userContext.userId,
                    roleId: userContext.roleId,
                    email: jwtEmail ?? '',
                    auth0UserId,
                    permissions: payload['permissions'] ?? [],
                    emailVerified: jwtEmailVerified,
                    permissionsVersion: 0,
                },
                tenant: {
                    businessId: userContext.businessId,
                    roleId: userContext.roleId,
                },
                memberships: userContext.memberships,
                accessTokenExpiresAt: payload.exp,
            };
            const scoped = this.applyRequestedReadScope(authContext);
            this.cachedContext = scoped;
            this.handlingAuth = null;
            return scoped;
        }
        catch (error) {
            this.handlingAuth = null;
            if (error instanceof GraphQLError) {
                throw error;
            }
            console.error('AuthContext: Failed to process authentication token', error);
            return null;
        }
    }
    async mapAuth0UserToLocal(auth0UserId, email, emailVerified) {
        // Resolve ALL memberships for the auth0 subject (previously LIMIT 1).
        const byAuth0Query = `
      SELECT bu.user_id, bu.business_id, bu.role_id
      FROM accounter_schema.business_users bu
      WHERE bu.auth0_user_id = $1
      ORDER BY bu.updated_at DESC
    `;
        // Identify the local user by a verified, accepted invitation email.
        const byVerifiedEmailQuery = `
      SELECT bu.user_id
      FROM accounter_schema.invitations i
      JOIN accounter_schema.business_users bu
        ON bu.user_id = i.user_id
       AND bu.business_id = i.business_id
      WHERE LOWER(i.email) = LOWER($1)
        AND i.accepted_at IS NOT NULL
      ORDER BY i.accepted_at DESC
      LIMIT 1
    `;
        const relinkAuth0IdQuery = `
      UPDATE accounter_schema.business_users
      SET auth0_user_id = $1, updated_at = NOW()
      WHERE user_id = $2
    `;
        // All memberships for a local user id, after relinking.
        const byUserIdQuery = `
      SELECT bu.user_id, bu.business_id, bu.role_id
      FROM accounter_schema.business_users bu
      WHERE bu.user_id = $1
      ORDER BY bu.updated_at DESC
    `;
        const toResult = (rows) => {
            const primary = rows[0];
            const memberships = rows.map(row => ({
                businessId: row.business_id,
                roleId: row.role_id,
            }));
            return {
                userId: primary.user_id,
                businessId: primary.business_id,
                roleId: primary.role_id,
                memberships,
            };
        };
        try {
            const byAuth0Result = await this.db.query(byAuth0Query, [auth0UserId]);
            if (byAuth0Result.rowCount && byAuth0Result.rowCount > 0) {
                return toResult(byAuth0Result.rows);
            }
            if (!email || !emailVerified) {
                return null;
            }
            const byEmailResult = await this.db.query(byVerifiedEmailQuery, [email]);
            if (!byEmailResult.rowCount || byEmailResult.rowCount === 0) {
                return null;
            }
            const userId = byEmailResult.rows[0].user_id;
            // Sync newly authenticated Auth0 subject across all memberships of this local user.
            await this.db.query(relinkAuth0IdQuery, [auth0UserId, userId]);
            const membershipsResult = await this.db.query(byUserIdQuery, [userId]);
            if (!membershipsResult.rowCount || membershipsResult.rowCount === 0) {
                return null;
            }
            return toResult(membershipsResult.rows);
        }
        catch (error) {
            console.error('AuthContext: DB lookup failed', error);
            throw error;
        }
    }
};
AuthContextProvider = __decorate([
    Injectable({
        scope: Scope.Operation,
        global: true,
    }),
    __param(0, Inject(ENVIRONMENT)),
    __param(1, Inject(RAW_AUTH)),
    __param(2, Inject(DBProvider)),
    __metadata("design:paramtypes", [Object, Object, DBProvider])
], AuthContextProvider);
export { AuthContextProvider };
//# sourceMappingURL=auth-context.provider.js.map