UNPKG

@accounter/server

Version:
317 lines • 11.4 kB
import { __decorate, __metadata, __param } from "tslib"; import { createHash } from 'node:crypto'; import { Inject, Injectable, Scope } from 'graphql-modules'; import { createRemoteJWKSet, jwtVerify } from 'jose'; 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; } 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 LIMIT 1`, [normalizedUserId]); if (!rows.length) { return null; } const user = rows[0]; return { authType: 'devBypass', token: normalizedUserId, user: { userId: user.user_id, auth0UserId: user.auth0_user_id, email: 'dev-user', roleId: user.role_id, permissions: [], emailVerified: true, permissionsVersion: 0, }, tenant: { businessId: user.business_id, roleId: user.role_id, }, }; } 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; } return null; } async handleDevBypassAuth() { const userId = this.rawAuth.token; if (!userId) { return null; } try { const context = await handleDevBypassAuth(this.db, userId); this.cachedContext = context; this.handlingAuth = null; return context; } catch (error) { console.error('AuthContext: Failed to process dev bypass auth', error); this.handlingAuth = null; return null; } } async handleApiKeyAuth() { const token = this.rawAuth.token; if (!token) { return null; } try { const context = await this.resolveApiKeyContext(token); this.cachedContext = context; this.handlingAuth = null; return context; } catch (error) { console.error('AuthContext: Failed to process API key', error); this.handlingAuth = null; 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, }, }; } 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, }, accessTokenExpiresAt: payload.exp, }; this.cachedContext = authContext; this.handlingAuth = null; return authContext; } catch (error) { console.error('AuthContext: Failed to process authentication token', error); this.handlingAuth = null; return null; } } async mapAuth0UserToLocal(auth0UserId, email, emailVerified) { const byAuth0Query = ` SELECT bu.user_id, bu.business_id, bu.role_id FROM accounter_schema.business_users bu WHERE bu.auth0_user_id = $1 LIMIT 1 `; const byVerifiedEmailQuery = ` SELECT bu.user_id, bu.business_id, bu.role_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 `; try { const byAuth0Result = await this.db.query(byAuth0Query, [auth0UserId]); if (byAuth0Result.rowCount && byAuth0Result.rowCount > 0) { const row = byAuth0Result.rows[0]; return { userId: row.user_id, businessId: row.business_id, roleId: row.role_id, }; } if (!email || !emailVerified) { return null; } const byEmailResult = await this.db.query(byVerifiedEmailQuery, [email]); if (!byEmailResult.rowCount || byEmailResult.rowCount === 0) { return null; } const row = byEmailResult.rows[0]; // Sync newly authenticated Auth0 subject across all memberships of this local user. await this.db.query(relinkAuth0IdQuery, [auth0UserId, row.user_id]); return { userId: row.user_id, businessId: row.business_id, roleId: row.role_id, }; } 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