diff --git a/simple-proxy-api.yaml b/simple-proxy-api.yaml index c6c283a..1a7a97a 100644 --- a/simple-proxy-api.yaml +++ b/simple-proxy-api.yaml @@ -52,6 +52,23 @@ paths: requestTemplates: application/json: "{\"statusCode\": 200}" type: mock + /locations/catalog: + x-amazon-apigateway-any-method: + produces: + - application/json + responses: {} + x-amazon-apigateway-integration: + uri: arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:710263499164:function:${stageVariables.ServerlessExpressLambdaFunctionName}/invocations + httpMethod: POST + type: aws_proxy + options: + produces: + - application/json + responses: {} + x-amazon-apigateway-integration: + uri: arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:710263499164:function:${stageVariables.ServerlessExpressLambdaFunctionName}/invocations + httpMethod: POST + type: aws_proxy /{proxy+}: x-amazon-apigateway-any-method: produces: diff --git a/src/app.js b/src/app.js index 5d09dd5..1f98edb 100644 --- a/src/app.js +++ b/src/app.js @@ -1,15 +1,24 @@ import express from 'express'; import bodyParser from 'body-parser'; -import cors from 'cors'; import morgan from 'morgan'; import awsServerlessExpressMiddleware from 'aws-serverless-express/middleware'; import setupRoutes from './routes'; +import { publicApiCors, internalLocationCatalogCors } from './services/internal-location-catalog-cors'; const app = express(); app.use(morgan('dev')); -app.use(cors()); +function isInternalLocationCatalogPath(path) { + return path === '/locations/catalog' || path.startsWith('/locations/catalog/'); +} + +app.use((req, res, next) => { + if (isInternalLocationCatalogPath(req.path)) { + return internalLocationCatalogCors(req, res, next); + } + return publicApiCors(req, res, next); +}); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); diff --git a/src/config.js b/src/config.js index 56b51df..cd13d91 100644 --- a/src/config.js +++ b/src/config.js @@ -1,9 +1,71 @@ import { parseBoolean, parseNumber } from './utils/strings'; +const DEFAULT_INTERNAL_CATALOG_ALLOWED_HOSTS = ['sheets.doobneek.org']; +const DEFAULT_INTERNAL_CATALOG_ALLOWED_ORIGIN_PATTERNS = ['chrome-extension://*']; +const DEFAULT_INTERNAL_CATALOG_ALLOWED_GROUPS = []; +const DEFAULT_INTERNAL_CATALOG_MAX_RADIUS_METERS = Math.round(20 * 1609.344); +const DEFAULT_INTERNAL_CATALOG_DEFAULT_PAGE_SIZE = 500; +const DEFAULT_INTERNAL_CATALOG_MAX_PAGE_SIZE = 1000; + +const parseCsv = (value, fallback = []) => { + if (typeof value !== 'string') return fallback; + const parsed = value + .split(',') + .map(entry => entry.trim()) + .filter(Boolean); + return parsed.length ? parsed : fallback; +}; + +const cognitoUserPoolId = process.env.COGNITO_USER_POOL_ID || null; +const cognitoUserPoolRegion = process.env.COGNITO_USER_POOL_REGION + || (cognitoUserPoolId && cognitoUserPoolId.includes('_') ? cognitoUserPoolId.split('_')[0] : null); +const cognitoUserPoolIssuer = process.env.COGNITO_USER_POOL_ISSUER || (cognitoUserPoolId + ? `https://cognito-idp.${cognitoUserPoolRegion}.amazonaws.com/${cognitoUserPoolId}` + : null); +const internalCatalogMaxPageSize = parseNumber( + process.env.INTERNAL_LOCATION_CATALOG_MAX_PAGE_SIZE, + DEFAULT_INTERNAL_CATALOG_MAX_PAGE_SIZE, +); + export default { port: process.env.PORT || 3000, slackWebhookUrl: process.env.SLACK_WEBHOOK_URL, adminGroupName: process.env.ADMIN_GROUP_NAME || 'StreetlivesAdmins', + cognito: { + userPoolId: cognitoUserPoolId, + userPoolRegion: cognitoUserPoolRegion, + userPoolIssuer: cognitoUserPoolIssuer, + }, + internalLocationCatalog: { + allowedOriginHosts: parseCsv( + process.env.INTERNAL_LOCATION_CATALOG_ALLOWED_HOSTS, + DEFAULT_INTERNAL_CATALOG_ALLOWED_HOSTS, + ), + allowedOriginPatterns: parseCsv( + process.env.INTERNAL_LOCATION_CATALOG_ALLOWED_ORIGIN_PATTERNS, + DEFAULT_INTERNAL_CATALOG_ALLOWED_ORIGIN_PATTERNS, + ), + allowedClientIds: parseCsv( + process.env.INTERNAL_LOCATION_CATALOG_ALLOWED_CLIENT_IDS, + [], + ), + allowedGroupNames: parseCsv( + process.env.INTERNAL_LOCATION_CATALOG_ALLOWED_GROUP_NAMES, + DEFAULT_INTERNAL_CATALOG_ALLOWED_GROUPS, + ), + maxRadiusMeters: parseNumber( + process.env.INTERNAL_LOCATION_CATALOG_MAX_RADIUS_METERS, + DEFAULT_INTERNAL_CATALOG_MAX_RADIUS_METERS, + ), + defaultPageSize: Math.min( + parseNumber( + process.env.INTERNAL_LOCATION_CATALOG_DEFAULT_PAGE_SIZE, + DEFAULT_INTERNAL_CATALOG_DEFAULT_PAGE_SIZE, + ), + internalCatalogMaxPageSize, + ), + maxPageSize: internalCatalogMaxPageSize, + }, db: { database: process.env.DATABASE_NAME || 'streetlives', username: process.env.DATABASE_USER, diff --git a/src/controllers/locations.js b/src/controllers/locations.js index 4e1aff5..b485b71 100644 --- a/src/controllers/locations.js +++ b/src/controllers/locations.js @@ -1,6 +1,7 @@ import Joi from 'joi'; import locationSchemas from './validation/locations'; import models from '../models'; +import config from '../config'; import { updateInstance, createInstance, destroyInstance } from '../services/data-changes'; import { getMetadataForLocation, @@ -12,10 +13,68 @@ import geometry from '../utils/geometry'; import { parseBoolean } from '../utils/strings'; import { convertKeyValueArrayToObject } from '../utils/api-params'; import { NotFoundError, ValidationError } from '../utils/errors'; +import authorizeInternalLocationCatalogRequest from '../services/internal-location-catalog-auth'; const DEFAULT_MAX_LOCATIONS_RETURNED = 1000; const MAX_TAXONOMY_IDS = 200; +const setPaginationHeaders = (res, { + totalNumLocations, + pageNumber, + pageSize, +}) => { + const paginationCount = pageSize > 0 + ? Math.ceil(totalNumLocations / pageSize) + : 1; + const hasMore = pageNumber + 1 < paginationCount; + res.setHeader('Pagination-Count', paginationCount); + res.setHeader('Total-Count', totalNumLocations); + res.setHeader('Page-Number', pageNumber); + res.setHeader('Page-Size', pageSize); + res.setHeader('Has-More', hasMore ? 'true' : 'false'); + if (hasMore) { + res.setHeader('Next-Page', pageNumber + 1); + } else { + res.removeHeader('Next-Page'); + } +}; + +const getCoordinatesFromPosition = (position) => { + const coordinates = position && position.coordinates; + if (!Array.isArray(coordinates) || coordinates.length < 2) { + return { + latitude: null, + longitude: null, + }; + } + return { + latitude: Number(coordinates[1]), + longitude: Number(coordinates[0]), + }; +}; + +const formatCatalogLocation = (location) => { + const plainLocation = location.get({ plain: true }); + const { + latitude, + longitude, + } = getCoordinatesFromPosition(plainLocation.position); + return { + id: plainLocation.id, + name: plainLocation.name, + slug: plainLocation.slug, + description: plainLocation.description, + additional_info: plainLocation.additional_info, + last_validated_at: plainLocation.last_validated_at, + position: plainLocation.position, + latitude, + longitude, + org: plainLocation.Organization ? plainLocation.Organization.name : null, + Organization: plainLocation.Organization || null, + PhysicalAddresses: plainLocation.PhysicalAddresses || [], + }; +}; + const isLocationClosed = (occasion, eventRelatedInfos, services) => { if (!occasion) { return false; @@ -292,6 +351,45 @@ export default { } }, + findCatalog: async (req, res, next) => { + try { + await Joi.validate(req, locationSchemas.findCatalog, { allowUnknown: true }); + await authorizeInternalLocationCatalogRequest(req); + + const { + latitude, + longitude, + radius, + pageNumber: rawPageNumber, + pageSize: rawPageSize, + sortBy, + } = req.query; + + const pageSize = rawPageSize != null + ? parseInt(rawPageSize, 10) + : config.internalLocationCatalog.defaultPageSize; + const pageNumber = rawPageNumber != null ? parseInt(rawPageNumber, 10) : 0; + const { + locations, + totalNumLocations, + } = await models.Location.findCatalog({ + position: geometry.createPoint(Number(longitude), Number(latitude)), + radius: Number(radius), + limit: pageSize, + offset: pageNumber * pageSize, + sortBy, + }); + + setPaginationHeaders(res, { + totalNumLocations, + pageNumber, + pageSize, + }); + res.send(locations.map(formatCatalogLocation)); + } catch (err) { + next(err); + } + }, getInfo: async (req, res, next) => { try { await Joi.validate(req, locationSchemas.getInfo, { allowUnknown: true }); diff --git a/src/controllers/validation/locations.js b/src/controllers/validation/locations.js index 255df3c..ac0a950 100644 --- a/src/controllers/validation/locations.js +++ b/src/controllers/validation/locations.js @@ -1,5 +1,6 @@ import Joi from 'joi'; import { SORT_OPTIONS, SORT_ORDER } from '../sort-by'; +import config from '../../config'; const updateMetadataSchema = Joi.object().keys({ source: Joi.string(), @@ -60,6 +61,29 @@ export default { }).required(), }, + findCatalog: { + query: Joi.object().keys({ + latitude: Joi.number().required(), + longitude: Joi.number().required(), + radius: Joi.number() + .integer() + .positive() + .max(config.internalLocationCatalog.maxRadiusMeters) + .required(), + pageNumber: Joi.number() + .integer() + .min(0), + pageSize: Joi.number() + .integer() + .positive() + .max(config.internalLocationCatalog.maxPageSize), + sortBy: Joi.string().valid( + SORT_ORDER.NEARBY, + SORT_ORDER.MOST_RECENTLY_VALIDATED, + ), + }).required(), + }, + create: { body: Joi.object().keys({ name: Joi.string(), diff --git a/src/models/location.js b/src/models/location.js index d5b516b..d520064 100644 --- a/src/models/location.js +++ b/src/models/location.js @@ -668,5 +668,105 @@ module.exports = (sequelize, DataTypes, Op) => { }; }; + Location.findCatalog = async ({ + position, + radius, + limit, + offset, + sortBy, + }) => { + let distance; + const whereConditions = [{ + hidden_from_search: { [Op.or]: [false, null] }, + }]; + + if (position) { + distance = sequelize.fn( + 'ST_DistanceSphere', + sequelize.col('position'), + sequelize.literal(`ST_GeomFromGeoJSON('${JSON.stringify(position)}')`), + ); + } + + if (position && radius) { + whereConditions.push(sequelize.where(distance, { [Op.lte]: radius })); + } + + let order; + if (position && (!sortBy || sortBy === SORT_ORDER.NEARBY)) { + order = [[distance, 'ASC'], ['id', 'ASC']]; + } else { + order = [['last_validated_at', 'DESC'], ['id', 'ASC']]; + } + + const where = sequelize.and(...whereConditions); + const include = [ + { + model: sequelize.models.Organization, + attributes: ['id', 'name'], + }, + { + model: sequelize.models.PhysicalAddress, + attributes: [ + 'id', + 'address_1', + 'city', + 'region', + 'state_province', + 'postal_code', + 'country', + ], + }, + ]; + + const totalNumLocations = await Location.count({ + where, + distinct: true, + col: 'Location.id', + }); + + const pageRows = await Location.findAll({ + attributes: ['id'], + where, + order, + limit, + offset, + raw: true, + }); + + const locationIds = pageRows.map(location => location.id); + if (!locationIds.length) { + return { + locations: [], + totalNumLocations, + }; + } + + const locations = await Location.findAll({ + attributes: [ + 'id', + 'name', + 'slug', + 'description', + 'additional_info', + 'last_validated_at', + 'position', + ], + where: { + id: { [Op.in]: locationIds }, + }, + include, + }); + + const locationPositionById = new Map(locationIds.map((id, index) => [id, index])); + locations.sort((left, right) => + locationPositionById.get(left.id) - locationPositionById.get(right.id)); + + return { + locations, + totalNumLocations, + }; + }; + return Location; }; diff --git a/src/routes.js b/src/routes.js index 7c3b231..ec3c1cf 100644 --- a/src/routes.js +++ b/src/routes.js @@ -23,6 +23,7 @@ export default (app) => { app.get('/organizations/:organizationId/locations', organizations.getLocations); app.get('/locations', locations.find); + app.get('/locations/catalog', locations.findCatalog); app.post('/locations/suggestions', locations.suggestNew); app.get('/locations-by-slug/:slug', locations.getInfoBySlug); diff --git a/src/services/internal-location-catalog-auth.js b/src/services/internal-location-catalog-auth.js new file mode 100644 index 0000000..18137e5 --- /dev/null +++ b/src/services/internal-location-catalog-auth.js @@ -0,0 +1,205 @@ +import crypto from 'crypto'; +import axios from 'axios'; +import config from '../config'; +import { AuthError, ForbiddenError } from '../utils/errors'; + +const JWKS_CACHE_TTL_MS = 10 * 60 * 1000; +const COGNITO_ISSUER_RE = /^https:\/\/cognito-idp\.[a-z0-9-]+\.amazonaws\.com\/[a-z0-9_-]+$/i; +const SUPPORTED_TOKEN_USES = new Set(['id', 'access']); +const jwksCache = new Map(); + +function decodeBase64UrlBuffer(value) { + const normalized = String(value || '') + .replace(/-/g, '+') + .replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + return Buffer.from(padded, 'base64'); +} + +function decodeBase64UrlJson(value) { + try { + return JSON.parse(decodeBase64UrlBuffer(value).toString('utf8')); + } catch (error) { + throw new AuthError('Malformed bearer token'); + } +} + +function parseJwt(token) { + const parts = String(token || '').split('.'); + if (parts.length !== 3) { + throw new AuthError('Malformed bearer token'); + } + return { + header: decodeBase64UrlJson(parts[0]), + payload: decodeBase64UrlJson(parts[1]), + signingInput: `${parts[0]}.${parts[1]}`, + signature: decodeBase64UrlBuffer(parts[2]), + }; +} + +function normalizeValues(values) { + return values + .map(value => String(value || '').trim()) + .filter(Boolean); +} + +function getClaimValues(value) { + if (Array.isArray(value)) { + return normalizeValues(value); + } + if (value == null) { + return []; + } + if (typeof value === 'string' && value.includes(',')) { + return normalizeValues(value.split(',')); + } + return normalizeValues([value]); +} + +// Internal catalog access is granted only from verified Cognito claims. +function assertCatalogAuthorizationConfigured() { + const allowedClientIds = normalizeValues( + config.internalLocationCatalog.allowedClientIds || [], + ); + const allowedGroupNames = normalizeValues( + config.internalLocationCatalog.allowedGroupNames || [], + ); + + if ( + !config.cognito.userPoolIssuer + || !allowedClientIds.length + || !allowedGroupNames.length + ) { + throw new ForbiddenError('Internal location catalog authorization is not configured'); + } + + return { + allowedClientIds, + allowedGroupNames, + }; +} + +function assertCatalogAuthorizationClaims(payload) { + const { + allowedClientIds, + allowedGroupNames, + } = assertCatalogAuthorizationConfigured(); + + const tokenClientIds = getClaimValues(payload.client_id) + .concat(getClaimValues(payload.aud)) + .concat(getClaimValues(payload.azp)); + const hasAllowedClientId = tokenClientIds.some(tokenClientId => + allowedClientIds.includes(tokenClientId)); + if (!hasAllowedClientId) { + throw new ForbiddenError('Bearer token client is not allowed for internal location catalog'); + } + + const tokenGroups = getClaimValues(payload['cognito:groups']); + const hasAllowedGroup = tokenGroups.some(groupName => + allowedGroupNames.includes(groupName)); + if (!hasAllowedGroup) { + throw new ForbiddenError('Bearer token group is not allowed for internal location catalog'); + } +} + +async function fetchIssuerJwks(issuer) { + const cached = jwksCache.get(issuer); + if (cached && cached.keysByKid && Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS) { + return cached.keysByKid; + } + if (cached && cached.promise) { + return cached.promise; + } + const request = axios + .get(`${issuer}/.well-known/jwks.json`, { timeout: 5000 }) + .then(({ data }) => { + if (!data || !Array.isArray(data.keys)) { + throw new AuthError('Unable to load Cognito signing keys'); + } + const keysByKid = new Map(); + data.keys.forEach((key) => { + if (key && key.kid) { + keysByKid.set(key.kid, key); + } + }); + jwksCache.set(issuer, { + fetchedAt: Date.now(), + keysByKid, + promise: null, + }); + return keysByKid; + }) + .catch((error) => { + jwksCache.delete(issuer); + if (error instanceof AuthError) { + throw error; + } + throw new AuthError('Unable to verify bearer token'); + }); + jwksCache.set(issuer, { fetchedAt: 0, keysByKid: null, promise: request }); + return request; +} + +function verifyJwtSignature(parsedToken, jwk) { + if (!jwk || jwk.kty !== 'RSA') { + throw new AuthError('Unsupported Cognito signing key'); + } + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(parsedToken.signingInput); + verifier.end(); + return verifier.verify( + crypto.createPublicKey({ key: jwk, format: 'jwk' }), + parsedToken.signature, + ); +} + +function assertTokenClaims(payload) { + const expiresAtMs = Number(payload.exp) * 1000; + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { + throw new AuthError('Bearer token has expired'); + } + const issuer = String(payload.iss || '').trim(); + if (!issuer || !COGNITO_ISSUER_RE.test(issuer)) { + throw new AuthError('Bearer token issuer is not allowed'); + } + if (config.cognito.userPoolIssuer && issuer !== config.cognito.userPoolIssuer) { + throw new AuthError('Bearer token issuer is not allowed'); + } + const tokenUse = String(payload.token_use || '').trim(); + if (!SUPPORTED_TOKEN_USES.has(tokenUse)) { + throw new AuthError('Unsupported Cognito token type'); + } + return expiresAtMs; +} + +function getBearerToken(req) { + const authorization = req.headers.authorization || req.headers.Authorization; + if (typeof authorization !== 'string') return null; + const match = authorization.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : null; +} + +async function verifyCognitoJwt(token) { + const parsedToken = parseJwt(token); + assertTokenClaims(parsedToken.payload); + const keysByKid = await fetchIssuerJwks(parsedToken.payload.iss); + const jwk = keysByKid.get(parsedToken.header.kid); + if (!jwk) { + throw new AuthError('Unable to verify bearer token'); + } + if (!verifyJwtSignature(parsedToken, jwk)) { + throw new AuthError('Unable to verify bearer token'); + } + + return parsedToken.payload; +} + +export default async function authorizeInternalLocationCatalogRequest(req) { + const bearerToken = getBearerToken(req); + if (!bearerToken) { + throw new AuthError('Missing bearer token'); + } + const payload = await verifyCognitoJwt(bearerToken); + assertCatalogAuthorizationClaims(payload); + return payload; +} diff --git a/src/services/internal-location-catalog-cors.js b/src/services/internal-location-catalog-cors.js new file mode 100644 index 0000000..97174fb --- /dev/null +++ b/src/services/internal-location-catalog-cors.js @@ -0,0 +1,99 @@ +import cors from 'cors'; +import config from '../config'; + +export const exposedHeaders = [ + 'Pagination-Count', + 'Total-Count', + 'Page-Number', + 'Page-Size', + 'Has-More', + 'Next-Page', +]; + +function normalizeHost(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/:\d+$/, ''); +} + +function normalizeOriginPattern(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\/$/, ''); +} + +function extractRequestOrigin(value) { + if (!value) return null; + const rawValue = String(value).trim(); + if (!rawValue) return null; + if (rawValue.toLowerCase() === 'null') return 'null'; + try { + const parsedUrl = new URL(rawValue); + if (!parsedUrl.protocol || !parsedUrl.host) { + return null; + } + return `${parsedUrl.protocol}//${parsedUrl.host}`.toLowerCase(); + } catch (error) { + return normalizeOriginPattern(rawValue); + } +} + +function isAllowedHost(host, allowedHosts) { + return allowedHosts.some((allowedHost) => { + const normalizedAllowedHost = normalizeHost(allowedHost); + if (!normalizedAllowedHost) return false; + if (normalizedAllowedHost.startsWith('*.')) { + const suffix = normalizedAllowedHost.slice(1); + return host.endsWith(suffix); + } + return host === normalizedAllowedHost; + }); +} + +function isAllowedOrigin(origin, allowedOriginPatterns) { + return allowedOriginPatterns.some((allowedOriginPattern) => { + const normalizedAllowedOriginPattern = normalizeOriginPattern(allowedOriginPattern); + if (!normalizedAllowedOriginPattern) return false; + if (normalizedAllowedOriginPattern.endsWith('*')) { + return origin.startsWith(normalizedAllowedOriginPattern.slice(0, -1)); + } + return origin === normalizedAllowedOriginPattern; + }); +} + +// Browser origin allowlists are only used for CORS responses. +export function isAllowedInternalLocationCatalogOrigin(value) { + const allowedHosts = config.internalLocationCatalog.allowedOriginHosts || []; + const allowedOriginPatterns = config.internalLocationCatalog.allowedOriginPatterns || []; + if (!allowedHosts.length && !allowedOriginPatterns.length) return true; + + const requestOrigin = extractRequestOrigin(value); + if (!requestOrigin) return false; + + try { + const parsedOrigin = new URL(requestOrigin); + if (['http:', 'https:'].includes(parsedOrigin.protocol)) { + return isAllowedHost(parsedOrigin.hostname, allowedHosts) + || isAllowedOrigin(requestOrigin, allowedOriginPatterns); + } + } catch (error) { + return isAllowedOrigin(requestOrigin, allowedOriginPatterns); + } + + return isAllowedOrigin(requestOrigin, allowedOriginPatterns); +} + +export const publicApiCors = cors({ exposedHeaders }); + +export const internalLocationCatalogCors = cors({ + exposedHeaders, + origin(origin, callback) { + if (!origin) { + callback(null, true); + return; + } + callback(null, isAllowedInternalLocationCatalogOrigin(origin)); + }, +}); diff --git a/test/controllers/locations-validation.test.js b/test/controllers/locations-validation.test.js new file mode 100644 index 0000000..c32b29b --- /dev/null +++ b/test/controllers/locations-validation.test.js @@ -0,0 +1,38 @@ +const Joi = require('joi'); + +const validateFindCatalog = (query) => { + jest.resetModules(); + const locationSchemas = require('../../src/controllers/validation/locations').default; + return Joi.validate({ query }, locationSchemas.findCatalog, { allowUnknown: true }); +}; + +describe('location catalog validation', () => { + it('accepts supported sortBy values', () => { + const nearbyResult = validateFindCatalog({ + latitude: 40.7, + longitude: -73.9, + radius: 1000, + sortBy: 'nearby', + }); + const recentlyUpdatedResult = validateFindCatalog({ + latitude: 40.7, + longitude: -73.9, + radius: 1000, + sortBy: 'recentlyUpdated', + }); + + expect(nearbyResult.error).toBeNull(); + expect(recentlyUpdatedResult.error).toBeNull(); + }); + + it('rejects unsupported sortBy values', () => { + const result = validateFindCatalog({ + latitude: 40.7, + longitude: -73.9, + radius: 1000, + sortBy: 'mostServices', + }); + + expect(result.error).not.toBeNull(); + }); +}); diff --git a/test/integration/find-locations.test.js b/test/integration/find-locations.test.js index 66c5976..bc74dc2 100644 --- a/test/integration/find-locations.test.js +++ b/test/integration/find-locations.test.js @@ -2,6 +2,15 @@ * @jest-environment node */ +import { AuthError } from '../../src/utils/errors'; + +const mockAuthorizeInternalLocationCatalogRequest = jest.fn(); + +jest.mock('../../src/services/internal-location-catalog-auth', () => ({ + __esModule: true, + default: (...args) => mockAuthorizeInternalLocationCatalogRequest(...args), +})); + import request from 'supertest'; import qs from 'qs'; import app from '../../src/app'; @@ -195,7 +204,18 @@ describe('find locations', () => { expect(returnedLocations).toEqual([]); }; - beforeEach(setupData); + beforeEach(async () => { + mockAuthorizeInternalLocationCatalogRequest.mockReset(); + mockAuthorizeInternalLocationCatalogRequest.mockImplementation(async (req) => { + const authorization = req.headers.authorization || req.headers.Authorization; + if (typeof authorization !== 'string' || !authorization.match(/^Bearer\s+.+$/i)) { + throw new AuthError('Missing bearer token'); + } + return { sub: 'test-user', aud: 'allowed-client-id', 'cognito:groups': ['InternalCatalogUsers'] }; + }); + + await setupData(); + }); afterAll(clearData); it('should return locations within a given radius of a given position', () => @@ -1093,4 +1113,160 @@ describe('find locations', () => { .query(qs.stringify({ zipcodes: [] })) .then(res => expect(res.body.length).toBeGreaterThan(0))); }); + + describe('internal location catalog', () => { + it('should require a bearer token', () => + request(app) + .get('/locations/catalog') + .query({ + latitude: originLatitude, + longitude: originLongitude, + radius: 20000, + }) + .expect(401)); + + it('should allow authenticated requests with no browser origin context', () => + request(app) + .get('/locations/catalog') + .set('Authorization', 'Bearer mocked-internal-token') + .query({ + latitude: originLatitude, + longitude: originLongitude, + radius: 20000, + pageNumber: 0, + pageSize: 1, + }) + .expect(200) + .then((res) => { + expect(res.headers['total-count']).toBe('3'); + expect(res.body).toHaveLength(1); + })); + + it('should allow authenticated extension requests from allowed extension origins', () => + request(app) + .get('/locations/catalog') + .set('Authorization', 'Bearer mocked-internal-token') + .set('Origin', 'chrome-extension://abcdefghijklmnop') + .query({ + latitude: originLatitude, + longitude: originLongitude, + radius: 20000, + pageNumber: 0, + pageSize: 1, + }) + .expect(200) + .then((res) => { + expect(res.headers['total-count']).toBe('3'); + expect(res.body).toHaveLength(1); + })); + + it('should return a slim paginated catalog for authenticated internal users', () => + request(app) + .get('/locations/catalog') + .set('Authorization', 'Bearer mocked-internal-token') + .set('Origin', 'https://sheets.doobneek.org') + .query({ + latitude: originLatitude, + longitude: originLongitude, + radius: 20000, + pageNumber: 0, + pageSize: 2, + }) + .expect(200) + .then((res) => { + expect(res.headers['total-count']).toBe('3'); + expect(res.headers['pagination-count']).toBe('2'); + expect(res.headers['page-number']).toBe('0'); + expect(res.headers['page-size']).toBe('2'); + expect(res.headers['has-more']).toBe('true'); + expect(res.headers['next-page']).toBe('1'); + expect(res.body).toHaveLength(2); + expect(res.body[0]).toEqual(expect.objectContaining({ + id: primaryLocation.id, + name: primaryLocation.name, + org: organization.name, + Organization: expect.objectContaining({ + id: organization.id, + name: organization.name, + }), + })); + expect(res.body[0]).not.toHaveProperty('Services'); + expect(res.body[0]).not.toHaveProperty('EventRelatedInfos'); + expect(res.body[0]).toHaveProperty('PhysicalAddresses'); + expect(Array.isArray(res.body[0].PhysicalAddresses)).toBe(true); + })); + + it('should paginate distinct locations when a location has multiple physical addresses', async () => { + await primaryLocation.createPhysicalAddress({ + address_1: '124 W 50th St.', + city: 'New York', + state_province: 'NY', + postal_code: '10001', + country: 'US', + }); + + const firstPage = await request(app) + .get('/locations/catalog') + .set('Authorization', 'Bearer mocked-internal-token') + .set('Origin', 'https://sheets.doobneek.org') + .query({ + latitude: originLatitude, + longitude: originLongitude, + radius: 20000, + pageNumber: 0, + pageSize: 1, + }) + .expect(200); + + const secondPage = await request(app) + .get('/locations/catalog') + .set('Authorization', 'Bearer mocked-internal-token') + .set('Origin', 'https://sheets.doobneek.org') + .query({ + latitude: originLatitude, + longitude: originLongitude, + radius: 20000, + pageNumber: 1, + pageSize: 1, + }) + .expect(200); + + expect(firstPage.headers['total-count']).toBe('3'); + expect(secondPage.headers['total-count']).toBe('3'); + expect(firstPage.body).toHaveLength(1); + expect(secondPage.body).toHaveLength(1); + expect(firstPage.body[0].id).toBe(primaryLocation.id); + expect(firstPage.body[0].PhysicalAddresses).toHaveLength(2); + expect(secondPage.body[0].id).toBe(otherServiceLocation.id); + expect(secondPage.body[0].id).not.toBe(firstPage.body[0].id); + }); + + it('should return the final page without a next-page header', () => + request(app) + .get('/locations/catalog') + .set('Authorization', 'Bearer mocked-internal-token') + .set('Origin', 'https://sheets.doobneek.org') + .query({ + latitude: originLatitude, + longitude: originLongitude, + radius: 20000, + pageNumber: 1, + pageSize: 2, + }) + .expect(200) + .then((res) => { + expect(res.headers['total-count']).toBe('3'); + expect(res.headers['pagination-count']).toBe('2'); + expect(res.headers['page-number']).toBe('1'); + expect(res.headers['page-size']).toBe('2'); + expect(res.headers['has-more']).toBe('false'); + expect(res.headers['next-page']).toBeUndefined(); + expect(res.body).toHaveLength(1); + expect(res.body[0]).toEqual(expect.objectContaining({ + id: farLocation.id, + name: farLocation.name, + org: organization.name, + })); + })); + }); }); diff --git a/test/models/location-find-catalog.test.js b/test/models/location-find-catalog.test.js new file mode 100644 index 0000000..a25ffbc --- /dev/null +++ b/test/models/location-find-catalog.test.js @@ -0,0 +1,57 @@ +/** + * @jest-environment node + */ + +import models from '../../src/models'; +import geometry from '../../src/utils/geometry'; + +describe('Location.findCatalog', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('paginates location ids before hydrating physical addresses', async () => { + jest.spyOn(models.Location, 'count').mockResolvedValue(2); + const findAllSpy = jest.spyOn(models.Location, 'findAll') + .mockResolvedValueOnce([ + { id: 'location-1' }, + { id: 'location-2' }, + ]) + .mockResolvedValueOnce([ + { + id: 'location-2', + PhysicalAddresses: [{ id: 'addr-2' }], + }, + { + id: 'location-1', + PhysicalAddresses: [{ id: 'addr-1a' }, { id: 'addr-1b' }], + }, + ]); + + const result = await models.Location.findCatalog({ + position: geometry.createPoint(-73.981452, 40.763765), + radius: 20000, + limit: 2, + offset: 0, + }); + + const firstQuery = findAllSpy.mock.calls[0][0]; + const secondQuery = findAllSpy.mock.calls[1][0]; + const idFilterSymbols = Object.getOwnPropertySymbols(secondQuery.where.id); + + expect(firstQuery).toEqual(expect.objectContaining({ + attributes: ['id'], + limit: 2, + offset: 0, + raw: true, + })); + expect(firstQuery.include).toBeUndefined(); + expect(secondQuery.limit).toBeUndefined(); + expect(secondQuery.offset).toBeUndefined(); + expect(secondQuery.include).toEqual(expect.any(Array)); + expect(secondQuery.where.id[idFilterSymbols[0]]).toEqual(['location-1', 'location-2']); + expect(result.totalNumLocations).toBe(2); + expect(result.locations.map(location => location.id)).toEqual(['location-1', 'location-2']); + expect(result.locations[0].PhysicalAddresses).toHaveLength(2); + }); +}); diff --git a/test/services/internal-location-catalog-auth.test.js b/test/services/internal-location-catalog-auth.test.js new file mode 100644 index 0000000..54d2066 --- /dev/null +++ b/test/services/internal-location-catalog-auth.test.js @@ -0,0 +1,335 @@ +const encodeBase64Url = value => + Buffer.from(JSON.stringify(value)) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + +const buildJwt = (payload, header = { alg: 'RS256', kid: 'kid-1' }) => [ + encodeBase64Url(header), + encodeBase64Url(payload), + 'signature', +].join('.'); + +const ORIGINAL_ENV = process.env; + +const loadAuthorizeWithMocks = ({ + allowedClientIds = 'allowed-client-id', + allowedGroups = 'InternalCatalogUsers', + verifyResult = true, +} = {}) => { + jest.resetModules(); + process.env = { + ...ORIGINAL_ENV, + NODE_ENV: 'test', + COGNITO_USER_POOL_ID: 'us-east-1_testPool', + }; + + if (allowedClientIds == null) { + delete process.env.INTERNAL_LOCATION_CATALOG_ALLOWED_CLIENT_IDS; + } else { + process.env.INTERNAL_LOCATION_CATALOG_ALLOWED_CLIENT_IDS = allowedClientIds; + } + + if (allowedGroups == null) { + delete process.env.INTERNAL_LOCATION_CATALOG_ALLOWED_GROUP_NAMES; + } else { + process.env.INTERNAL_LOCATION_CATALOG_ALLOWED_GROUP_NAMES = allowedGroups; + } + + const axios = require('axios'); + const crypto = require('crypto'); + const verify = { + update: jest.fn(), + end: jest.fn(), + verify: jest.fn(() => verifyResult), + }; + + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { + keys: [{ kid: 'kid-1', kty: 'RSA' }], + }, + }); + jest.spyOn(crypto, 'createPublicKey').mockReturnValue('mock-public-key'); + jest.spyOn(crypto, 'createVerify').mockReturnValue(verify); + + const authorizeInternalLocationCatalogRequest = + require('../../src/services/internal-location-catalog-auth').default; + const config = require('../../src/config').default; + + return { + authorizeInternalLocationCatalogRequest, + config, + verify, + axios, + }; +}; + +describe('internal location catalog auth', () => { + afterEach(() => { + jest.restoreAllMocks(); + process.env = ORIGINAL_ENV; + }); + + it('accepts a signed token from an allowed client and group', async () => { + const { + authorizeInternalLocationCatalogRequest, + config, + verify, + axios, + } = loadAuthorizeWithMocks(); + + const token = buildJwt({ + iss: config.cognito.userPoolIssuer, + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'id', + aud: 'allowed-client-id', + 'cognito:groups': ['InternalCatalogUsers'], + sub: 'user-123', + }); + + await expect(authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + })).resolves.toEqual(expect.objectContaining({ + aud: 'allowed-client-id', + sub: 'user-123', + })); + + expect(axios.get).toHaveBeenCalled(); + expect(verify.verify).toHaveBeenCalled(); + }); + + it('rejects a token from a disallowed client', async () => { + const { + authorizeInternalLocationCatalogRequest, + config, + } = loadAuthorizeWithMocks(); + + const token = buildJwt({ + iss: config.cognito.userPoolIssuer, + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'id', + aud: 'some-other-client', + 'cognito:groups': ['InternalCatalogUsers'], + sub: 'user-123', + }); + + await expect(authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + })).rejects.toThrow('Bearer token client is not allowed for internal location catalog'); + }); + + it('rejects a token from a disallowed group', async () => { + const { + authorizeInternalLocationCatalogRequest, + config, + } = loadAuthorizeWithMocks(); + + const token = buildJwt({ + iss: config.cognito.userPoolIssuer, + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'id', + aud: 'allowed-client-id', + 'cognito:groups': ['AnotherGroup'], + sub: 'user-123', + }); + + await expect(authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + })).rejects.toThrow('Bearer token group is not allowed for internal location catalog'); + }); + + it('fails closed when the allowed group configuration is missing', async () => { + const { + authorizeInternalLocationCatalogRequest, + config, + } = loadAuthorizeWithMocks({ allowedGroups: null }); + + const token = buildJwt({ + iss: config.cognito.userPoolIssuer, + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'id', + aud: 'allowed-client-id', + 'cognito:groups': ['InternalCatalogUsers'], + sub: 'user-123', + }); + + await expect(authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + })).rejects.toThrow('Internal location catalog authorization is not configured'); + }); + + it('fails closed when the allowed client configuration is missing', async () => { + const { + authorizeInternalLocationCatalogRequest, + config, + } = loadAuthorizeWithMocks({ allowedClientIds: null }); + + const token = buildJwt({ + iss: config.cognito.userPoolIssuer, + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'id', + aud: 'allowed-client-id', + 'cognito:groups': ['InternalCatalogUsers'], + sub: 'user-123', + }); + + await expect(authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + })).rejects.toThrow('Internal location catalog authorization is not configured'); + }); + + it('rejects a token with an invalid signature', async () => { + const { + authorizeInternalLocationCatalogRequest, + config, + } = loadAuthorizeWithMocks({ verifyResult: false }); + + const token = buildJwt({ + iss: config.cognito.userPoolIssuer, + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'access', + client_id: 'allowed-client-id', + 'cognito:groups': ['InternalCatalogUsers'], + sub: 'user-123', + }); + + await expect(authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + })).rejects.toThrow('Unable to verify bearer token'); + }); + + it('rejects a token with an unsupported Cognito token type', async () => { + const { + authorizeInternalLocationCatalogRequest, + config, + } = loadAuthorizeWithMocks(); + + const token = buildJwt({ + iss: config.cognito.userPoolIssuer, + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'refresh', + aud: 'allowed-client-id', + 'cognito:groups': ['InternalCatalogUsers'], + sub: 'user-123', + }); + + await expect(authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + })).rejects.toThrow('Unsupported Cognito token type'); + }); + + it('accepts an access token when both the client id and group are allowed', async () => { + const { + authorizeInternalLocationCatalogRequest, + config, + } = loadAuthorizeWithMocks(); + + const token = buildJwt({ + iss: config.cognito.userPoolIssuer, + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'access', + client_id: 'allowed-client-id', + 'cognito:groups': ['InternalCatalogUsers'], + sub: 'user-123', + }); + + await expect(authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + })).resolves.toEqual(expect.objectContaining({ + client_id: 'allowed-client-id', + })); + }); + + it('re-verifies repeated requests instead of caching raw bearer tokens', async () => { + const { + authorizeInternalLocationCatalogRequest, + config, + verify, + axios, + } = loadAuthorizeWithMocks(); + + const token = buildJwt({ + iss: config.cognito.userPoolIssuer, + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'id', + aud: 'allowed-client-id', + 'cognito:groups': ['InternalCatalogUsers'], + sub: 'user-123', + }); + + await authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + }); + await authorizeInternalLocationCatalogRequest({ + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(verify.verify).toHaveBeenCalledTimes(2); + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + it('fails closed when the Cognito issuer is not configured', async () => { + const { + authorizeInternalLocationCatalogRequest, + } = loadAuthorizeWithMocks(); + process.env = { + ...process.env, + }; + delete process.env.COGNITO_USER_POOL_ID; + delete process.env.COGNITO_USER_POOL_ISSUER; + jest.resetModules(); + + const axios = require('axios'); + const crypto = require('crypto'); + jest.spyOn(axios, 'get').mockResolvedValue({ + data: { keys: [{ kid: 'kid-1', kty: 'RSA' }] }, + }); + jest.spyOn(crypto, 'createPublicKey').mockReturnValue('mock-public-key'); + jest.spyOn(crypto, 'createVerify').mockReturnValue({ + update: jest.fn(), + end: jest.fn(), + verify: jest.fn(() => true), + }); + + const reloadedAuthorize = + require('../../src/services/internal-location-catalog-auth').default; + + const token = buildJwt({ + iss: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_testPool', + exp: Math.floor(Date.now() / 1000) + 3600, + token_use: 'id', + aud: 'allowed-client-id', + 'cognito:groups': ['InternalCatalogUsers'], + sub: 'user-123', + }); + + await expect(reloadedAuthorize({ + headers: { + authorization: `Bearer ${token}`, + }, + })).rejects.toThrow('Internal location catalog authorization is not configured'); + + expect(authorizeInternalLocationCatalogRequest).toBeDefined(); + }); +}); diff --git a/test/services/internal-location-catalog-cors.test.js b/test/services/internal-location-catalog-cors.test.js new file mode 100644 index 0000000..eb3eff4 --- /dev/null +++ b/test/services/internal-location-catalog-cors.test.js @@ -0,0 +1,100 @@ +import express from 'express'; +import request from 'supertest'; + +const ORIGINAL_ENV = process.env; + +const buildCorsSelectorApp = ({ + allowedHosts = 'sheets.doobneek.org', + allowedOriginPatterns = 'chrome-extension://*', +} = {}) => { + jest.resetModules(); + process.env = { + ...ORIGINAL_ENV, + INTERNAL_LOCATION_CATALOG_ALLOWED_HOSTS: allowedHosts, + INTERNAL_LOCATION_CATALOG_ALLOWED_ORIGIN_PATTERNS: allowedOriginPatterns, + }; + + const { + publicApiCors, + internalLocationCatalogCors, + } = require('../../src/services/internal-location-catalog-cors'); + + const app = express(); + app.use((req, res, next) => { + const useInternalCors = req.path === '/locations/catalog' + || req.path.startsWith('/locations/catalog/'); + if (useInternalCors) { + return internalLocationCatalogCors(req, res, next); + } + return publicApiCors(req, res, next); + }); + app.options('/locations/catalog', (req, res) => res.sendStatus(204)); + app.options('/locations/catalog/', (req, res) => res.sendStatus(204)); + app.get('/locations/catalog', (req, res) => res.sendStatus(200)); + app.get('/locations/catalog/', (req, res) => res.sendStatus(200)); + app.get('/locations/other', (req, res) => res.sendStatus(200)); + + return app; +}; + +describe('internal location catalog CORS', () => { + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + it('allows preflight requests from sheets.doobneek.org', async () => { + const app = buildCorsSelectorApp(); + + await request(app) + .options('/locations/catalog') + .set('Origin', 'https://sheets.doobneek.org') + .set('Access-Control-Request-Method', 'GET') + .expect(204) + .expect('Access-Control-Allow-Origin', 'https://sheets.doobneek.org'); + }); + + it('allows preflight requests from configured extension origins', async () => { + const app = buildCorsSelectorApp(); + + await request(app) + .options('/locations/catalog') + .set('Origin', 'chrome-extension://abcdefghijklmnop') + .set('Access-Control-Request-Method', 'GET') + .expect(204) + .expect('Access-Control-Allow-Origin', 'chrome-extension://abcdefghijklmnop'); + }); + + it('does not emit allow-origin headers for disallowed browser origins', async () => { + const app = buildCorsSelectorApp(); + + const res = await request(app) + .options('/locations/catalog') + .set('Origin', 'https://example.com') + .set('Access-Control-Request-Method', 'GET') + .expect(204); + + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('keeps the restricted catalog CORS policy for trailing-slash variants', async () => { + const app = buildCorsSelectorApp(); + + const res = await request(app) + .options('/locations/catalog/') + .set('Origin', 'https://example.com') + .set('Access-Control-Request-Method', 'GET') + .expect(204); + + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('still leaves unrelated routes on the public CORS policy', async () => { + const app = buildCorsSelectorApp(); + + await request(app) + .get('/locations/other') + .set('Origin', 'https://example.com') + .expect(200) + .expect('Access-Control-Allow-Origin', '*'); + }); +}); diff --git a/test/setup.js b/test/setup.js index 05f38c1..a8180f3 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,6 +1,14 @@ const util = require('util'); const exec = util.promisify(require('child_process').exec); +if (!global.TextEncoder) { + global.TextEncoder = util.TextEncoder; +} + +if (!global.TextDecoder) { + global.TextDecoder = util.TextDecoder; +} + jest.setTimeout(10000); process.env.DATABASE_NAME = 'test';