import { createContextCache, isThenable, type MaybePromise, type Path, PothosValidationError, type SchemaTypes, } from '@pothos/core'; import type { GraphQLResolveInfo } from 'graphql'; import { type AuthFailure, AuthScopeFailureType, type AuthScopeMap, type ScopeLoaderMap, type TypeAuthScopesFunction, } from './types'; import { cacheKey, canCache } from './util'; const contextCache = createContextCache( (ctx, builder: PothosSchemaTypes.SchemaBuilder) => new RequestCache(builder, ctx), ); export default class RequestCache { builder; context; mapCache = new Map<{}, MaybePromise>(); scopeCache = new Map>>(); typeCache = new Map>>(); typeGrants = new Map>>(); grantCache = new Map>(); scopes?: MaybePromise>; cacheKey?: (value: unknown) => unknown; treatErrorsAsUnauthorized: boolean; defaultStrategy: 'all' | 'any'; constructor(builder: PothosSchemaTypes.SchemaBuilder, context: Types['Context']) { this.builder = builder; this.context = context; this.cacheKey = builder.options.scopeAuth?.cacheKey; this.treatErrorsAsUnauthorized = builder.options.scopeAuth?.treatErrorsAsUnauthorized ?? false; this.defaultStrategy = builder.options.scopeAuth?.defaultStrategy ?? 'any'; } static fromContext( context: T['Context'], builder: PothosSchemaTypes.SchemaBuilder, ): RequestCache { return contextCache(context, builder as never) as never; } static clearForContext(context: T['Context']): void { contextCache.delete(context); } getScopes(): MaybePromise> { if (!this.scopes) { const scopes = this.builder.options.scopeAuth.authScopes(this.context); this.scopes = isThenable(scopes) ? scopes.then((resolved) => { this.scopes = resolved; return resolved; }) : scopes; } return this.scopes; } withScopes(cb: (scopes: ScopeLoaderMap) => MaybePromise): MaybePromise { const scopes = this.getScopes(); if (isThenable(scopes)) { return scopes.then((resolvedScopes) => cb(resolvedScopes)); } return cb(scopes); } saveGrantedScopes(scopes: readonly string[], path: Path | undefined) { const key = cacheKey(path); if (this.grantCache.has(key)) { const set = this.grantCache.get(key)!; for (const scope of scopes) { set.add(scope); } } else { this.grantCache.set(key, new Set(scopes)); } return null; } testGrantedScopes(scope: string, path: Path) { if (this.grantCache.get(cacheKey(path.prev))?.has(scope)) { return true; } if ( typeof path.prev?.key === 'number' && this.grantCache.get(cacheKey(path.prev.prev))?.has(scope) ) { return true; } return false; } grantTypeScopes( type: string, parent: unknown, path: Path | undefined, cb: () => MaybePromise, ) { if (!this.typeGrants.has(type)) { this.typeGrants.set(type, new Map>()); } const cache = this.typeGrants.get(type)!; if (!cache.has(parent)) { const result = cb(); if (isThenable(result)) { cache.set( parent, result.then((resolved) => this.saveGrantedScopes(resolved, path)), ); } else { cache.set(parent, this.saveGrantedScopes(result, path)); } } return cache.get(parent)!; } evaluateScopeLoader( scopes: ScopeLoaderMap, name: T, arg: Types['AuthScopes'][T], ) { if (!this.scopeCache.has(name)) { this.scopeCache.set(name, new Map()); } const cache = this.scopeCache.get(name)!; const key = this.cacheKey ? this.cacheKey(arg) : arg; if (!cache.has(key)) { let loader = scopes[name]; if (typeof loader !== 'function') { throw new PothosValidationError( `Attempted to evaluate scope ${String(name)} as scope loader, but it is not a function`, ); } loader = loader.bind(scopes); let result: MaybePromise; if (this.treatErrorsAsUnauthorized) { try { result = (loader as (param: Types['AuthScopes'][T]) => MaybePromise)(arg); } catch (error: unknown) { cache.set(key, { kind: AuthScopeFailureType.AuthScope, scope: name as string, parameter: arg, error: error as Error, }); return cache.get(key)!; } } else { result = (loader as (param: Types['AuthScopes'][T]) => MaybePromise)(arg); } if (isThenable(result)) { let promise: Promise = result.then((r) => r ? null : { kind: AuthScopeFailureType.AuthScope, scope: name as string, parameter: arg, error: null, }, ); if (this.treatErrorsAsUnauthorized) { promise = promise.catch((error: unknown) => ({ kind: AuthScopeFailureType.AuthScope, scope: name as string, parameter: arg, error: error as Error, })); } cache.set(key, promise); } else { cache.set( key, result ? null : { kind: AuthScopeFailureType.AuthScope, scope: name as string, parameter: arg, error: null, }, ); } } return cache.get(key)!; } evaluateScopeMapWithScopes( { $all, $any, $granted, ...map }: AuthScopeMap, scopes: ScopeLoaderMap, info: GraphQLResolveInfo | undefined, forAll: boolean, ): MaybePromise { const scopeNames = Object.keys(map) as (keyof typeof map)[]; const problems: AuthFailure[] = []; const failure: AuthFailure = { kind: forAll ? AuthScopeFailureType.AllAuthScopes : AuthScopeFailureType.AnyAuthScopes, failures: problems, }; const loaderList: [ keyof Types['AuthScopes'], Types['AuthScopes'][keyof Types['AuthScopes']], ][] = []; for (const scopeName of scopeNames) { if (scopes[scopeName] == null || scopes[scopeName] === false) { problems.push({ kind: AuthScopeFailureType.AuthScope, scope: scopeName as string, parameter: map[scopeName], error: null, }); if (forAll) { return failure; } continue; } const scope: | boolean | ((arg: Types['AuthScopes'][typeof scopeName]) => MaybePromise) = scopes[scopeName]; if (typeof scope === 'function') { loaderList.push([scopeName, map[scopeName]]); } else if (scope && !forAll) { return null; } else if (!scope) { problems.push({ kind: AuthScopeFailureType.AuthScope, scope: scopeName as string, parameter: map[scopeName], error: null, }); if (forAll) { return failure; } } } const promises: Promise[] = []; if ($granted) { const result = !!info && this.testGrantedScopes($granted, info.path); if (result && !forAll) { return null; } if (!result) { problems.push({ kind: AuthScopeFailureType.GrantedScope, scope: $granted, }); if (forAll) { return failure; } } } if ($any) { const anyResult = this.evaluateScopeMap($any, info, false); if (isThenable(anyResult)) { promises.push(anyResult); } else if (anyResult === null && !forAll) { return null; } else if (anyResult) { problems.push(anyResult); if (forAll) { return failure; } } } if ($all) { const allResult = this.evaluateScopeMap($all, info, true); if (isThenable(allResult)) { promises.push(allResult); } else if (allResult === null && !forAll) { return resolveAndReturn(null); } else if (allResult) { problems.push(allResult); if (forAll) { return resolveAndReturn(failure); } } } for (const [loaderName, arg] of loaderList) { const result = this.evaluateScopeLoader(scopes, loaderName, arg); if (isThenable(result)) { promises.push(result); } else if (result === null && !forAll) { return resolveAndReturn(null); } else if (result) { problems.push(result); if (forAll) { return resolveAndReturn(failure); } } } if (promises.length === 0) { return forAll && problems.length === 0 ? null : failure; } return Promise.all(promises).then((results) => { let hasSuccess = false; for (const result of results) { if (result) { problems.push(result); } else { hasSuccess = true; } } if (forAll) { return problems.length > 0 ? failure : null; } return hasSuccess ? null : failure; }); function resolveAndReturn(val: AuthFailure | null) { if (promises.length > 0) { return Promise.all(promises).then(() => val); } return val; } } evaluateScopeMap( map: AuthScopeMap | boolean, info?: GraphQLResolveInfo, forAll = this.defaultStrategy === 'all', ): MaybePromise { if (typeof map === 'boolean') { return map ? null : { kind: AuthScopeFailureType.AuthScopeFunction, error: null, }; } if (!this.mapCache.has(map)) { const result = this.withScopes((scopes) => this.evaluateScopeMapWithScopes(map, scopes, info, forAll), ); if (canCache(map)) { this.mapCache.set(map, result); } return result; } return this.mapCache.get(map)!; } evaluateTypeScopeFunction( authScopes: TypeAuthScopesFunction, type: string, parent: unknown, info: GraphQLResolveInfo, ) { const { typeCache } = this; if (!typeCache.has(type)) { typeCache.set(type, new Map()); } const cache = typeCache.get(type)!; if (!cache.has(parent)) { let result: ReturnType>; if (this.treatErrorsAsUnauthorized) { try { result = authScopes(parent, this.context); } catch (error: unknown) { cache.set(parent, { kind: AuthScopeFailureType.AuthScopeFunction, error: error as Error, }); return cache.get(parent)!; } } else { result = authScopes(parent, this.context); } if (isThenable(result)) { let promise: Promise = result.then((resolved) => this.evaluateScopeMap(resolved, info), ); if (this.treatErrorsAsUnauthorized) { promise = promise.catch((error: unknown) => ({ kind: AuthScopeFailureType.AuthScopeFunction, error: error as Error, })); } cache.set(parent, promise); } else { cache.set(parent, this.evaluateScopeMap(result, info)); } } return cache.get(parent)!; } }