UNPKG

@accounter/server

Version:
577 lines (503 loc) • 18.1 kB
import { createHash, timingSafeEqual } from 'node:crypto'; import { GraphQLError } from 'graphql'; import { Inject, Injectable, Scope } from 'graphql-modules'; import { createRemoteJWKSet, jwtVerify } from 'jose'; import type { RawAuth } from '../../../plugins/auth-plugin.js'; import { narrowReadScope, readScopeFromMemberships, resolveWriteTargetBusinessId, } from '../../../shared/helpers/auth-scope.js'; import { ENVIRONMENT, RAW_AUTH } from '../../../shared/tokens.js'; import type { AuthContext, BusinessMembership } from '../../../shared/types/auth.js'; import type { Environment } from '../../../shared/types/index.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<string, ReturnType<typeof createRemoteJWKSet>>(); type QueryableDB = Pick<DBProvider, 'query'>; export async function handleDevBypassAuth( db: QueryableDB, userId: string, ): Promise<AuthContext | null> { 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<{ user_id: string; business_id: string; role_id: string; auth0_user_id: string | null; }>( `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: BusinessMembership[] = 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), }; } @Injectable({ scope: Scope.Operation, global: true, }) export class AuthContextProvider { private cachedContext: AuthContext | null | undefined = undefined; private handlingAuth: Promise<AuthContext | null> | null = null; constructor( @Inject(ENVIRONMENT) private env: Environment, @Inject(RAW_AUTH) private rawAuth: RawAuth, @Inject(DBProvider) private db: DBProvider, ) {} public async getJwtIdentity(): Promise<{ auth0UserId: string; email: string | null; emailVerified: boolean; } | null> { 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; } } public async getAuthContext(): Promise<AuthContext | null> { 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. */ private applyRequestedReadScope(context: AuthContext): AuthContext { 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 }; } private async handleDevBypassAuth(): Promise<AuthContext | null> { 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; } } private async handleApiKeyAuth(): Promise<AuthContext | null> { 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; } } private async resolveApiKeyContext(plaintextKey: string): Promise<AuthContext | null> { const keyHash = createHash('sha256').update(plaintextKey).digest('hex'); const { rows } = await this.db.query<{ id: string; business_id: string; role_id: string; }>( `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 as (typeof ALLOWED_API_KEY_ROLES)[number])) { 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 }], }; } private async handleGatewayControlPlaneAuth(): Promise<AuthContext | null> { // 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: AuthContext = { 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; } private async handleJwtAuth(): Promise<AuthContext | null> { 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: AuthContext = { authType: 'jwt', // Will be treated as AuthType token, user: { userId: userContext.userId, roleId: userContext.roleId, email: jwtEmail ?? '', auth0UserId, permissions: (payload['permissions'] as string[]) ?? [], 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; } } private async mapAuth0UserToLocal( auth0UserId: string, email: string | null, emailVerified: boolean, ): Promise<{ userId: string; businessId: string; roleId: string; memberships: BusinessMembership[]; } | null> { // 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 `; type MembershipRow = { user_id: string; business_id: string; role_id: string }; const toResult = (rows: MembershipRow[]) => { const primary = rows[0]; const memberships: BusinessMembership[] = 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<MembershipRow>(byAuth0Query, [auth0UserId]); if (byAuth0Result.rowCount && byAuth0Result.rowCount > 0) { return toResult(byAuth0Result.rows); } if (!email || !emailVerified) { return null; } const byEmailResult = await this.db.query<{ user_id: string }>(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<MembershipRow>(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; } } }