UNPKG

@accounter/server

Version:
474 lines • 19.1 kB
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