diff --git a/Meadowlark-js/backends/meadowlark-elasticsearch-backend/package.json b/Meadowlark-js/backends/meadowlark-elasticsearch-backend/package.json index ebe7597b..9e315647 100644 --- a/Meadowlark-js/backends/meadowlark-elasticsearch-backend/package.json +++ b/Meadowlark-js/backends/meadowlark-elasticsearch-backend/package.json @@ -1,7 +1,7 @@ { "name": "@edfi/meadowlark-elasticsearch-backend", "main": "dist/index.js", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "description": "Meadowlark backend plugin for elasticsearch", "license": "Apache-2.0", "publishConfig": { @@ -19,8 +19,8 @@ "build:copy-non-ts": "copyfiles -u 1 -e \"**/*.ts\" \"src/**/*\" dist --verbose" }, "dependencies": { - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@elastic/elasticsearch": "^8.10.0", "@elastic/transport": "^8.3.4" }, diff --git a/Meadowlark-js/backends/meadowlark-mongodb-backend/package.json b/Meadowlark-js/backends/meadowlark-mongodb-backend/package.json index ab1bbc2e..dfd90ca6 100644 --- a/Meadowlark-js/backends/meadowlark-mongodb-backend/package.json +++ b/Meadowlark-js/backends/meadowlark-mongodb-backend/package.json @@ -1,7 +1,7 @@ { "name": "@edfi/meadowlark-mongodb-backend", "main": "dist/index.js", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "description": "Meadowlark backend plugin for MongoDB", "license": "Apache-2.0", "publishConfig": { @@ -19,9 +19,9 @@ "build:copy-non-ts": "copyfiles -u 1 -e \"**/*.ts\" \"src/**/*\" dist --verbose" }, "dependencies": { - "@edfi/meadowlark-authz-server": "0.4.0-pre.5", - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-authz-server": "0.4.0-pre.6", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "async-retry": "^1.3.3", "mongodb": "^5.9.0", "ramda": "0.29.1" diff --git a/Meadowlark-js/backends/meadowlark-opensearch-backend/package.json b/Meadowlark-js/backends/meadowlark-opensearch-backend/package.json index bc10f6be..34c35a5a 100644 --- a/Meadowlark-js/backends/meadowlark-opensearch-backend/package.json +++ b/Meadowlark-js/backends/meadowlark-opensearch-backend/package.json @@ -1,7 +1,7 @@ { "name": "@edfi/meadowlark-opensearch-backend", "main": "dist/index.js", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "description": "Meadowlark backend plugin for OpenSearch", "license": "Apache-2.0", "publishConfig": { @@ -19,8 +19,8 @@ "build:copy-non-ts": "copyfiles -u 1 -e \"**/*.ts\" \"src/**/*\" dist --verbose" }, "dependencies": { - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@opensearch-project/opensearch": "^2.4.0" }, "devDependencies": { diff --git a/Meadowlark-js/backends/meadowlark-postgresql-backend/package.json b/Meadowlark-js/backends/meadowlark-postgresql-backend/package.json index 5c60622a..b5236533 100644 --- a/Meadowlark-js/backends/meadowlark-postgresql-backend/package.json +++ b/Meadowlark-js/backends/meadowlark-postgresql-backend/package.json @@ -1,7 +1,7 @@ { "name": "@edfi/meadowlark-postgresql-backend", "main": "dist/index.js", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "description": "Meadowlark backend plugin for PostgreSQL", "license": "Apache-2.0", "publishConfig": { @@ -19,9 +19,9 @@ "build:copy-non-ts": "copyfiles -u 1 -e \"**/*.ts\" \"src/**/*\" dist --verbose" }, "dependencies": { - "@edfi/meadowlark-authz-server": "0.4.0-pre.5", - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-authz-server": "0.4.0-pre.6", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "pg": "^8.11.3", "pg-format": "^1.0.4", "ramda": "0.29.1" diff --git a/Meadowlark-js/lerna.json b/Meadowlark-js/lerna.json index a2c8f30c..c57ecb21 100644 --- a/Meadowlark-js/lerna.json +++ b/Meadowlark-js/lerna.json @@ -3,7 +3,7 @@ "packages": [ "packages/*" ], - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "npmClient": "npm", "useWorkspaces": true } diff --git a/Meadowlark-js/package-lock.json b/Meadowlark-js/package-lock.json index ba43d21f..a7bca19c 100644 --- a/Meadowlark-js/package-lock.json +++ b/Meadowlark-js/package-lock.json @@ -18,7 +18,7 @@ "@shelf/jest-mongodb": "^4.1.7", "@types/autocannon": "^7.12.1", "@types/eslint": "^8.44.3", - "@types/jest": "^29.5.5", + "@types/jest": "^29.5.11", "@types/node": "18.18.9", "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "5.62.0", @@ -52,11 +52,11 @@ }, "backends/meadowlark-elasticsearch-backend": { "name": "@edfi/meadowlark-elasticsearch-backend", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "license": "Apache-2.0", "dependencies": { - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@elastic/elasticsearch": "^8.10.0", "@elastic/transport": "^8.3.4" }, @@ -70,12 +70,12 @@ }, "backends/meadowlark-mongodb-backend": { "name": "@edfi/meadowlark-mongodb-backend", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "license": "Apache-2.0", "dependencies": { - "@edfi/meadowlark-authz-server": "0.4.0-pre.5", - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-authz-server": "0.4.0-pre.6", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "async-retry": "^1.3.3", "mongodb": "^5.9.0", "ramda": "0.29.1" @@ -88,11 +88,11 @@ }, "backends/meadowlark-opensearch-backend": { "name": "@edfi/meadowlark-opensearch-backend", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "license": "Apache-2.0", "dependencies": { - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@opensearch-project/opensearch": "^2.4.0" }, "devDependencies": { @@ -105,12 +105,12 @@ }, "backends/meadowlark-postgresql-backend": { "name": "@edfi/meadowlark-postgresql-backend", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "license": "Apache-2.0", "dependencies": { - "@edfi/meadowlark-authz-server": "0.4.0-pre.5", - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-authz-server": "0.4.0-pre.6", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "pg": "^8.11.3", "pg-format": "^1.0.4", "ramda": "0.29.1" @@ -4921,9 +4921,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.5", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.5.tgz", - "integrity": "sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==", + "version": "29.5.11", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz", + "integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -4937,9 +4937,10 @@ "dev": true }, "node_modules/@types/json-schema": { - "version": "7.0.11", - "dev": true, - "license": "MIT" + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true }, "node_modules/@types/json5": { "version": "0.0.29", @@ -22676,12 +22677,13 @@ }, "packages/meadowlark-authz-server": { "name": "@edfi/meadowlark-authz-server", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "license": "Apache-2.0", "dependencies": { "@apideck/better-ajv-errors": "^0.3.6", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "ajv": "^8.12.0", + "didyoumean2": "^6.0.1", "dotenv": "^16.3.1", "fast-memoize": "^2.5.2", "njwt": "^2.0.0", @@ -22729,11 +22731,11 @@ }, "packages/meadowlark-core": { "name": "@edfi/meadowlark-core", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "license": "Apache-2.0", "dependencies": { "@apideck/better-ajv-errors": "^0.3.6", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@isaacs/ttlcache": "^1.4.1", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", @@ -22822,7 +22824,7 @@ }, "packages/meadowlark-utilities": { "name": "@edfi/meadowlark-utilities", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "license": "Apache-2.0", "dependencies": { "pino": "^8.15.4", @@ -22869,12 +22871,12 @@ }, "services/meadowlark-fastify": { "name": "@edfi/meadowlark-fastify", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "license": "Apache-2.0", "dependencies": { - "@edfi/meadowlark-authz-server": "0.4.0-pre.5", - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-authz-server": "0.4.0-pre.6", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@fastify/rate-limit": "^6.0.1", "dotenv": "^16.3.1", "fastify": "^3.29.5" @@ -22887,10 +22889,10 @@ }, "tests/e2e": { "name": "@edfi/meadowlark-e2e-tests", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "license": "Apache-2.0", "devDependencies": { - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@testcontainers/mongodb": "^10.3.1", "@testcontainers/postgresql": "^10.3.1", "@types/chance": "^1.1.6", diff --git a/Meadowlark-js/package.json b/Meadowlark-js/package.json index 2fce85d0..0f0e5fc6 100644 --- a/Meadowlark-js/package.json +++ b/Meadowlark-js/package.json @@ -7,7 +7,7 @@ "@shelf/jest-mongodb": "^4.1.7", "@types/autocannon": "^7.12.1", "@types/eslint": "^8.44.3", - "@types/jest": "^29.5.5", + "@types/jest": "^29.5.11", "@types/node": "18.18.9", "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "5.62.0", diff --git a/Meadowlark-js/packages/meadowlark-authz-server/package.json b/Meadowlark-js/packages/meadowlark-authz-server/package.json index 4b127eac..7866c5bb 100644 --- a/Meadowlark-js/packages/meadowlark-authz-server/package.json +++ b/Meadowlark-js/packages/meadowlark-authz-server/package.json @@ -1,7 +1,7 @@ { "name": "@edfi/meadowlark-authz-server", "main": "dist/index.js", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "description": "Meadowlark authorization server", "license": "Apache-2.0", "publishConfig": { @@ -14,8 +14,9 @@ ], "dependencies": { "@apideck/better-ajv-errors": "^0.3.6", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "ajv": "^8.12.0", + "didyoumean2": "^6.0.1", "dotenv": "^16.3.1", "fast-memoize": "^2.5.2", "njwt": "^2.0.0", diff --git a/Meadowlark-js/packages/meadowlark-authz-server/src/handler/CreateClient.ts b/Meadowlark-js/packages/meadowlark-authz-server/src/handler/CreateClient.ts index df768a3f..00e24c47 100644 --- a/Meadowlark-js/packages/meadowlark-authz-server/src/handler/CreateClient.ts +++ b/Meadowlark-js/packages/meadowlark-authz-server/src/handler/CreateClient.ts @@ -13,7 +13,7 @@ import { AuthorizationRequest, extractAuthorizationHeader } from './Authorizatio import { AuthorizationResponse } from './AuthorizationResponse'; import { CreateClientResponseBody } from '../model/CreateClientResponseBody'; import { writeDebugObject, writeDebugStatusToLog, writeErrorToLog, writeRequestToLog } from '../Logger'; -import { BodyValidation, validateCreateClientBody } from '../validation/BodyValidation'; +import { BodyValidation, applySuggestions, validateCreateClientBody } from '../validation/BodyValidation'; import { hashClientSecretBuffer } from '../security/HashClientSecret'; import { TryCreateBootstrapAuthorizationAdminResult } from '../message/TryCreateBootstrapAuthorizationAdminResult'; import { ClientId } from '../Utility'; @@ -119,7 +119,21 @@ export async function createClient(authorizationRequest: AuthorizationRequest): return { body: { error }, statusCode: 400 }; } - const validation: BodyValidation = validateCreateClientBody(parsedBody); + let validation: BodyValidation = validateCreateClientBody(parsedBody); + // The validation will return suggestions for properties not found that have a similar match, + // here will apply it and validate once more, if this does not pass validation will return failure with the error message. + if (!validation.isValid && validation.suggestions) { + writeDebugStatusToLog( + moduleName, + authorizationRequest, + 'createClient', + 400, + 'Invalid request body, checking for suggestions', + ); + parsedBody = applySuggestions(parsedBody, validation.suggestions); + validation = validateCreateClientBody(parsedBody); + } + if (!validation.isValid) { writeDebugObject(moduleName, authorizationRequest, 'createClient', 400, validation.failureMessage); return { diff --git a/Meadowlark-js/packages/meadowlark-authz-server/src/handler/RequestToken.ts b/Meadowlark-js/packages/meadowlark-authz-server/src/handler/RequestToken.ts index faa9c8ae..6c73b991 100644 --- a/Meadowlark-js/packages/meadowlark-authz-server/src/handler/RequestToken.ts +++ b/Meadowlark-js/packages/meadowlark-authz-server/src/handler/RequestToken.ts @@ -5,17 +5,18 @@ import querystring from 'node:querystring'; import { create as createJwt } from 'njwt'; -import { Config, Logger, isDebugEnabled } from '@edfi/meadowlark-utilities'; +import { Config } from '@edfi/meadowlark-utilities'; import { admin1, verifyOnly1 } from '../security/HardcodedCredential'; import type { Jwt } from '../security/Jwt'; import type { AuthorizationResponse } from './AuthorizationResponse'; import { AuthorizationRequest, extractAuthorizationHeader } from './AuthorizationRequest'; import type { RequestTokenBody } from '../model/RequestTokenBody'; -import { BodyValidation, validateRequestTokenBody } from '../validation/BodyValidation'; +import { BodyValidation, applySuggestions, validateRequestTokenBody } from '../validation/BodyValidation'; import type { GetAuthorizationClientResult } from '../message/GetAuthorizationClientResult'; import { ensurePluginsLoaded, getAuthorizationStore } from '../plugin/AuthorizationPluginLoader'; import { hashClientSecretHexString } from '../security/HashClientSecret'; import { TokenSuccessResponse } from '../model/TokenResponse'; +import { writeDebugStatusToLog, writeRequestToLog } from '../Logger'; const moduleName = 'authz.handler.RequestToken'; @@ -63,61 +64,78 @@ function maskClientSecret(body: RequestTokenBody): string { function parseRequestTokenBody(authorizationRequest: AuthorizationRequest): ParsedRequestTokenBody { if (authorizationRequest.body == null) return { isValid: false, failureMessage: { message: 'Request body is empty' } }; - let unvalidatedBody: any; + let parsedBody: any; // startsWith accounts for possibility of the content-type being with or without encoding if (authorizationRequest.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) { try { - unvalidatedBody = querystring.parse(authorizationRequest.body); + parsedBody = querystring.parse(authorizationRequest.body); } catch (error) { - Logger.debug(`${moduleName}.parseRequestTokenBody: Malformed body - ${error.message}`, authorizationRequest.traceId); + writeDebugStatusToLog(moduleName, authorizationRequest, 'parseRequestTokenBody', 400, error.message); return { isValid: false, failureMessage: { message: `Malformed body: ${error.message}` } }; } } else { try { - unvalidatedBody = JSON.parse(authorizationRequest.body); + parsedBody = JSON.parse(authorizationRequest.body); } catch (error) { - Logger.debug(`${moduleName}.parseRequestTokenBody: Malformed body - ${error.message}`, authorizationRequest.traceId); + writeDebugStatusToLog(moduleName, authorizationRequest, 'parseRequestTokenBody', 400, error.message); return { isValid: false, failureMessage: { message: `Malformed body: ${error.message}` } }; } } - const bodyValidation: BodyValidation = validateRequestTokenBody(unvalidatedBody); - if (!bodyValidation.isValid) { - Logger.debug(`${moduleName}.parseRequestTokenBody: Invalid request body`, authorizationRequest.traceId); - return { isValid: false, failureMessage: bodyValidation.failureMessage }; + let validation: BodyValidation = validateRequestTokenBody(parsedBody); + // The validation will return suggestions for properties not found that have a similar match, + // here will apply it and validate once more, if this does not pass validation will return failure with the error message. + if (!validation.isValid && validation.suggestions) { + writeDebugStatusToLog( + moduleName, + authorizationRequest, + 'parseRequestTokenBody', + 400, + 'Invalid request body, checking for suggestions', + ); + parsedBody = applySuggestions(parsedBody, validation.suggestions); + validation = validateRequestTokenBody(parsedBody); } - const validatedBody: RequestTokenBody = unvalidatedBody as RequestTokenBody; + if (!validation.isValid) { + writeDebugStatusToLog(moduleName, authorizationRequest, 'parseRequestTokenBody', 400, 'Invalid request body'); + return { isValid: false, failureMessage: validation.failureMessage }; + } + + const requestTokenBody: RequestTokenBody = parsedBody as RequestTokenBody; // client_id and client_secret can either be directly in the payload or encoded as an Authorization header. // Validation ensures that if one is in the body then both are. - if (validatedBody.client_id != null) { - return { isValid: true, requestTokenBody: validatedBody }; + if (requestTokenBody.client_id != null) { + return { isValid: true, requestTokenBody }; } const authorizationHeader: string | undefined = extractAuthorizationHeader(authorizationRequest); if (authorizationHeader == null) { - Logger.debug(`${moduleName}.parseRequestTokenBody: Missing authorization header`, authorizationRequest.traceId); - return { isValid: false, failureMessage: { message: 'Missing authorization header' } }; + const message = 'Missing authorization header'; + writeDebugStatusToLog(moduleName, authorizationRequest, 'parseRequestTokenBody', 400, message); + return { isValid: false, failureMessage: { message } }; } if (!authorizationHeader.startsWith('Basic ')) { - Logger.debug(`${moduleName}.parseRequestTokenBody: Invalid authorization header`, authorizationRequest.traceId); - return { isValid: false, failureMessage: { message: 'Invalid authorization header' } }; + const message = 'Invalid authorization header'; + writeDebugStatusToLog(moduleName, authorizationRequest, 'parseRequestTokenBody', 400, message); + return { isValid: false, failureMessage: { message } }; } // Extract from "Basic " where encoded is the Base64 form of "client_id:client_secret" const split = Buffer.from(authorizationHeader.slice(6), 'base64').toString('binary').split(':'); if (split.length !== 2) { - Logger.debug(`${moduleName}.parseRequestTokenBody: Invalid authorization header`, authorizationRequest.traceId); - return { isValid: false, failureMessage: { message: 'Invalid authorization header' } }; + const message = 'Invalid authorization header'; + writeDebugStatusToLog(moduleName, authorizationRequest, 'parseRequestTokenBody', 400, message); + return { isValid: false, failureMessage: { message } }; } - [validatedBody.client_id, validatedBody.client_secret] = split; + [requestTokenBody.client_id, requestTokenBody.client_secret] = split; - return { isValid: true, requestTokenBody: validatedBody }; + return { isValid: true, requestTokenBody }; } /* @@ -125,7 +143,7 @@ function parseRequestTokenBody(authorizationRequest: AuthorizationRequest): Pars */ export async function requestToken(authorizationRequest: AuthorizationRequest): Promise { try { - Logger.info(`${moduleName}.requestToken`, authorizationRequest.traceId); + writeRequestToLog(moduleName, authorizationRequest, 'requestToken'); await ensurePluginsLoaded(); const parsedRequest: ParsedRequestTokenBody = parseRequestTokenBody(authorizationRequest); @@ -138,9 +156,7 @@ export async function requestToken(authorizationRequest: AuthorizationRequest): const { requestTokenBody } = parsedRequest; - if (isDebugEnabled()) { - Logger.debug(`${moduleName}.requestToken ${maskClientSecret(requestTokenBody)}`, authorizationRequest.traceId); - } + writeDebugStatusToLog(moduleName, authorizationRequest, 'requestToken', 200); if (requestTokenBody.grant_type === 'client_credentials') { // Check hardcoded credentials first @@ -149,24 +165,20 @@ export async function requestToken(authorizationRequest: AuthorizationRequest): const enableHardCoded = Config.get('OAUTH_HARD_CODED_CREDENTIALS_ENABLED'); if (!enableHardCoded && (clientId === admin1.key || clientId === verifyOnly1.key)) { - if (isDebugEnabled()) { - Logger.debug( - `${moduleName}.requestToken: ${maskClientSecret(requestTokenBody)} Hard-coded credentials are not enabled`, - authorizationRequest.traceId, - ); - } + writeDebugStatusToLog(moduleName, authorizationRequest, 'requestToken', 401); + return { statusCode: 401 }; } if (clientId === admin1.key && clientSecret === admin1.secret) { - Logger.debug(`${moduleName}.requestToken: 200 - Hardcoded admin1`, authorizationRequest.traceId); + writeDebugStatusToLog(moduleName, authorizationRequest, 'requestToken', 200, 'Hardcoded admin1'); return { body: tokenResponseFrom(createToken(admin1.key, admin1.vendor, admin1.role)), statusCode: 200, }; } if (clientId === verifyOnly1.key && clientSecret === verifyOnly1.secret) { - Logger.debug(`${moduleName}.requestToken: 200 - Hardcoded verifyOnly1`, authorizationRequest.traceId); + writeDebugStatusToLog(moduleName, authorizationRequest, 'requestToken', 200, 'Hardcoded verifyOnly1'); return { body: tokenResponseFrom(createToken(verifyOnly1.key, verifyOnly1.vendor, verifyOnly1.role)), statusCode: 200, @@ -180,46 +192,44 @@ export async function requestToken(authorizationRequest: AuthorizationRequest): }); if (result.response === 'UNKNOWN_FAILURE') { - if (isDebugEnabled()) { - Logger.debug( - `${moduleName}.requestToken: ${maskClientSecret(requestTokenBody)} 500`, - authorizationRequest.traceId, - ); - } + writeDebugStatusToLog(moduleName, authorizationRequest, 'requestToken', 500); return { statusCode: 500 }; } if (result.response === 'GET_FAILURE_NOT_EXISTS') { - if (isDebugEnabled()) { - Logger.debug( - `${moduleName}.requestToken: ${maskClientSecret(requestTokenBody)} Client does not exist`, - authorizationRequest.traceId, - ); - } + writeDebugStatusToLog( + moduleName, + authorizationRequest, + 'requestToken', + 401, + `${maskClientSecret(requestTokenBody)} Client does not exist`, + ); return { statusCode: 401 }; } if (!result.active) { - if (isDebugEnabled()) { - Logger.debug( - `${moduleName}.requestToken: ${maskClientSecret(requestTokenBody)} Client deactivated `, - authorizationRequest.traceId, - ); - } + writeDebugStatusToLog( + moduleName, + authorizationRequest, + 'requestToken', + 403, + `${maskClientSecret(requestTokenBody)} Client deactivated`, + ); return { statusCode: 403 }; } if (hashClientSecretHexString(requestTokenBody.client_secret) !== result.clientSecretHashed) { - if (isDebugEnabled()) { - Logger.debug( - `${moduleName}.requestToken: ${maskClientSecret(requestTokenBody)} 401`, - authorizationRequest.traceId, - ); - } + writeDebugStatusToLog( + moduleName, + authorizationRequest, + 'requestToken', + 401, + `${maskClientSecret(requestTokenBody)} 401`, + ); return { statusCode: 401 }; } - Logger.debug(`${moduleName}.requestToken authorized 200`, authorizationRequest.traceId); + writeDebugStatusToLog(moduleName, authorizationRequest, 'requestToken', 200); return { body: tokenResponseFrom(createToken(clientId, result.clientName, result.roles)), @@ -229,7 +239,7 @@ export async function requestToken(authorizationRequest: AuthorizationRequest): return { statusCode: 401 }; } catch (e) { - Logger.debug(`${moduleName}.requestToken: 500`, authorizationRequest.traceId, e); + writeDebugStatusToLog(moduleName, authorizationRequest, 'requestToken', 500, e); return { statusCode: 500 }; } } diff --git a/Meadowlark-js/packages/meadowlark-authz-server/src/handler/UpdateClient.ts b/Meadowlark-js/packages/meadowlark-authz-server/src/handler/UpdateClient.ts index f8312811..a923a32b 100644 --- a/Meadowlark-js/packages/meadowlark-authz-server/src/handler/UpdateClient.ts +++ b/Meadowlark-js/packages/meadowlark-authz-server/src/handler/UpdateClient.ts @@ -11,7 +11,7 @@ import { validateAdminTokenForAccess } from '../security/TokenValidator'; import { AuthorizationRequest, extractAuthorizationHeader } from './AuthorizationRequest'; import { AuthorizationResponse } from './AuthorizationResponse'; import { writeDebugObject, writeDebugStatusToLog, writeErrorToLog, writeRequestToLog } from '../Logger'; -import { BodyValidation, validateUpdateClientBody } from '../validation/BodyValidation'; +import { BodyValidation, applySuggestions, validateUpdateClientBody } from '../validation/BodyValidation'; import { clientIdFrom } from '../Utility'; const moduleName = 'authz.handler.UpdateClient'; @@ -57,7 +57,19 @@ export async function updateClient(authorizationRequest: AuthorizationRequest): return { body: { error }, statusCode: 400 }; } - const validation: BodyValidation = validateUpdateClientBody(parsedBody); + let validation: BodyValidation = validateUpdateClientBody(parsedBody); + if (!validation.isValid && validation.suggestions) { + writeDebugStatusToLog( + moduleName, + authorizationRequest, + 'updateClient', + 400, + 'Invalid request body, checking for suggestions', + ); + parsedBody = applySuggestions(parsedBody, validation.suggestions); + validation = validateUpdateClientBody(parsedBody); + } + if (!validation.isValid) { writeDebugObject(moduleName, authorizationRequest, 'updateClient', 400, validation.failureMessage); return { diff --git a/Meadowlark-js/packages/meadowlark-authz-server/src/handler/VerifyToken.ts b/Meadowlark-js/packages/meadowlark-authz-server/src/handler/VerifyToken.ts index 0af4aa21..654d6a0f 100644 --- a/Meadowlark-js/packages/meadowlark-authz-server/src/handler/VerifyToken.ts +++ b/Meadowlark-js/packages/meadowlark-authz-server/src/handler/VerifyToken.ts @@ -4,11 +4,10 @@ // See the LICENSE and NOTICES files in the project root for more information. import querystring from 'node:querystring'; -import { Logger } from '@edfi/meadowlark-utilities'; import type { AuthorizationResponse } from './AuthorizationResponse'; import { AuthorizationRequest, extractAuthorizationHeader } from './AuthorizationRequest'; import type { VerifyTokenBody } from '../model/VerifyTokenBody'; -import { BodyValidation, validateVerifyTokenBody } from '../validation/BodyValidation'; +import { BodyValidation, applySuggestions, validateVerifyTokenBody } from '../validation/BodyValidation'; import { ensurePluginsLoaded } from '../plugin/AuthorizationPluginLoader'; import { hasAdminOrVerifyOnlyRole, @@ -17,6 +16,7 @@ import { ValidateTokenResult, } from '../security/TokenValidator'; import { IntrospectionResponse } from '../model/TokenResponse'; +import { writeDebugStatusToLog, writeRequestToLog } from '../Logger'; const moduleName = 'authz.handler.VerifyToken'; @@ -34,30 +34,42 @@ type ParsedVerifyTokenBody = function parseVerifyTokenBody(authorizationRequest: AuthorizationRequest): ParsedVerifyTokenBody { if (authorizationRequest.body == null) return { isValid: false, failureMessage: 'Request body is empty' }; - let unvalidatedBody: any; + let parsedBody: any; // startsWith accounts for possibility of the content-type being with or without encoding if (!authorizationRequest.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) { const error = 'Requires application/x-www-form-urlencoded content type'; - Logger.debug(`${moduleName}.parseVerifyTokenBody: ${error}`, authorizationRequest.traceId); + writeDebugStatusToLog(moduleName, authorizationRequest, 'verifyToken', 400, error); return { isValid: false, failureMessage: error }; } try { - unvalidatedBody = querystring.parse(authorizationRequest.body); + parsedBody = querystring.parse(authorizationRequest.body); } catch (e) { const error = `Malformed body: ${e.message}`; - Logger.debug(`${moduleName}.parseRequestTokenBody: ${error}`, authorizationRequest.traceId); + writeDebugStatusToLog(moduleName, authorizationRequest, 'parseRequestTokenBody', 400, error); return { isValid: false, failureMessage: { error } }; } - const bodyValidation: BodyValidation = validateVerifyTokenBody(unvalidatedBody); - if (!bodyValidation.isValid) { - Logger.debug(`${moduleName}.parseRequestTokenBody: ${bodyValidation.failureMessage}`, authorizationRequest.traceId); - return { isValid: false, failureMessage: bodyValidation.failureMessage }; + let validation: BodyValidation = validateVerifyTokenBody(parsedBody); + if (!validation.isValid && validation.suggestions) { + writeDebugStatusToLog( + moduleName, + authorizationRequest, + 'parseRequestTokenBody', + 400, + 'Invalid request body, checking for suggestions', + ); + parsedBody = applySuggestions(parsedBody, validation.suggestions); + validation = validateVerifyTokenBody(parsedBody); + } + + if (!validation.isValid) { + writeDebugStatusToLog(moduleName, authorizationRequest, 'verifyToken', 400, 'Invalid request body'); + return { isValid: false, failureMessage: validation.failureMessage }; } - const validatedBody: VerifyTokenBody = unvalidatedBody as VerifyTokenBody; + const validatedBody: VerifyTokenBody = parsedBody as VerifyTokenBody; return { isValid: true, verifyTokenBody: validatedBody }; } @@ -67,7 +79,7 @@ function parseVerifyTokenBody(authorizationRequest: AuthorizationRequest): Parse */ export async function verifyToken(authorizationRequest: AuthorizationRequest): Promise { try { - Logger.info(`${moduleName}.verifyToken`, authorizationRequest.traceId); + writeRequestToLog(moduleName, authorizationRequest, 'verifyToken'); await ensurePluginsLoaded(); // Get the client id and roles for the requester @@ -86,7 +98,7 @@ export async function verifyToken(authorizationRequest: AuthorizationRequest): P // Get the token to be introspected const parsedRequest: ParsedVerifyTokenBody = parseVerifyTokenBody(authorizationRequest); if (!parsedRequest.isValid) { - Logger.debug(`${moduleName}.verifyToken: 400`, authorizationRequest.traceId, parsedRequest.failureMessage); + writeDebugStatusToLog(moduleName, authorizationRequest, 'verifyToken', 400); return { body: { error: parsedRequest.failureMessage }, statusCode: 400, @@ -101,7 +113,7 @@ export async function verifyToken(authorizationRequest: AuthorizationRequest): P if (!introspectionResponse.isValid) { const message = 'Invalid token provided for introspection'; - Logger.debug(`${moduleName}.verifyToken: 400`, authorizationRequest.traceId, message); + writeDebugStatusToLog(moduleName, authorizationRequest, 'verifyToken', 400); return { body: { error: message }, statusCode: 400, @@ -115,17 +127,17 @@ export async function verifyToken(authorizationRequest: AuthorizationRequest): P requesterTokenResult.clientId !== introspectedToken.client_id && !hasAdminOrVerifyOnlyRole(requesterTokenResult.roles) ) { - Logger.debug(`${moduleName}.verifyToken: 401`, authorizationRequest.traceId); + writeDebugStatusToLog(moduleName, authorizationRequest, 'verifyToken', 401); return { statusCode: 401 }; } - Logger.debug(`${moduleName}.verifyToken: 200`, authorizationRequest.traceId); + writeDebugStatusToLog(moduleName, authorizationRequest, 'verifyToken', 200); return { body: introspectedToken, statusCode: 200, }; } catch (e) { - Logger.error(`${moduleName}.verifyToken: 500`, authorizationRequest.traceId, e); + writeDebugStatusToLog(moduleName, authorizationRequest, 'verifyToken', 500, e); return { statusCode: 500 }; } } diff --git a/Meadowlark-js/packages/meadowlark-authz-server/src/validation/BodyValidation.ts b/Meadowlark-js/packages/meadowlark-authz-server/src/validation/BodyValidation.ts index 399859cd..6b1d7177 100644 --- a/Meadowlark-js/packages/meadowlark-authz-server/src/validation/BodyValidation.ts +++ b/Meadowlark-js/packages/meadowlark-authz-server/src/validation/BodyValidation.ts @@ -3,14 +3,17 @@ // The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. // See the LICENSE and NOTICES files in the project root for more information. +import didYouMean, { ThresholdTypeEnums } from 'didyoumean2'; import { ValidateFunction } from 'ajv'; import { betterAjvErrors } from '@apideck/better-ajv-errors'; import { ajv } from './SharedAjv'; + import { clientBodySchema } from '../model/ClientBody'; import { requestTokenBodySchema } from '../model/RequestTokenBody'; import { verifyTokenBodySchema } from '../model/VerifyTokenBody'; -export type BodyValidation = { isValid: true } | { isValid: false; failureMessage: object }; +export type Suggestion = { current: string; suggested: string }; +export type BodyValidation = { isValid: true } | { isValid: false; failureMessage: object; suggestions: Suggestion[] }; const createClientBodyValidator: ValidateFunction = ajv.compile(clientBodySchema); const requestTokenBodyValidator: ValidateFunction = ajv.compile(requestTokenBodySchema); @@ -18,7 +21,29 @@ const verifyTokenBodyValidator: ValidateFunction = ajv.compile(verifyTokenBodySc function validateBody(body: object, schema: object, validateFunction: ValidateFunction): BodyValidation { const isValid: boolean = validateFunction(body); + const suggestions: Suggestion[] = []; if (isValid) return { isValid }; + + const { errors } = validateFunction; + + // Should be typed + const requiredKeys = Object.keys((schema as any).properties); + const additionalKeys = errors + ? errors + .filter((key) => key.keyword === 'additionalProperties') + .map((property) => property.params.additionalProperty as string) + : []; + + // Check for expected values in the ajv error messages to compare with provided values. + // This will provide suggestions for properties with correct value but wrong case, allowing to have case insensitive requests + additionalKeys.forEach((current) => { + // Update the threshold for didYouMean to not allow words with similar lengths or typos. + const suggested = didYouMean(current, requiredKeys, { thresholdType: ThresholdTypeEnums.SIMILARITY, threshold: 1 }); + if (suggested) { + suggestions.push({ current, suggested }); + } + }); + return { isValid, failureMessage: betterAjvErrors({ @@ -27,9 +52,20 @@ function validateBody(body: object, schema: object, validateFunction: ValidateFu errors: validateFunction.errors, basePath: '{requestBody}', }), + suggestions, }; } +export function applySuggestions(body: object, suggestions: Suggestion[]): object { + let bodyWithSuggestions = JSON.stringify(body); + suggestions.forEach((suggestion) => { + bodyWithSuggestions = bodyWithSuggestions.replace(suggestion.current, suggestion.suggested); + }); + + const updatedBody = JSON.parse(bodyWithSuggestions); + return updatedBody; +} + export function validateCreateClientBody(body: object): BodyValidation { return validateBody(body, clientBodySchema, createClientBodyValidator); } diff --git a/Meadowlark-js/packages/meadowlark-authz-server/test/validation/BodyValidation.test.ts b/Meadowlark-js/packages/meadowlark-authz-server/test/validation/BodyValidation.test.ts new file mode 100644 index 00000000..0b206f2c --- /dev/null +++ b/Meadowlark-js/packages/meadowlark-authz-server/test/validation/BodyValidation.test.ts @@ -0,0 +1,348 @@ +import { + BodyValidation, + Suggestion, + applySuggestions, + validateCreateClientBody, + validateRequestTokenBody, +} from '../../src/validation/BodyValidation'; + +describe("given it's validating the request body", () => { + describe("given it's validating the create client body", () => { + describe("given it's valid", () => { + const expected = { + clientName: 'Hometown SIS', + roles: ['vendor', 'assessment'], + }; + let validationResult: BodyValidation; + + beforeAll(() => { + // Act + validationResult = validateCreateClientBody(expected); + }); + + it('should return valid', () => { + expect(validationResult).toBeTruthy(); + }); + }); + + describe('given it has different casing', () => { + const actual = { + clientname: 'Hometown SIS', + ROLES: ['vendor', 'assessment'], + }; + + const expected = { + clientName: 'Hometown SIS', + roles: ['vendor', 'assessment'], + }; + + let validationResult: BodyValidation; + let suggestions: Suggestion[] = []; + + beforeAll(() => { + // Act + validationResult = validateCreateClientBody(actual); + if (!validationResult.isValid) { + suggestions = validationResult.suggestions ?? []; + } + }); + + it('should return suggestions', () => { + if (validationResult.isValid) { + expect(validationResult.isValid).toBeFalsy(); + return; + } + + expect(validationResult.failureMessage).toMatchInlineSnapshot(` + [ + { + "context": { + "errorType": "required", + }, + "message": "{requestBody} must have required property 'clientName'", + "path": "{requestBody}", + }, + { + "context": { + "errorType": "required", + }, + "message": "{requestBody} must have required property 'roles'", + "path": "{requestBody}", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'clientname' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'clientName'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'ROLES' property is not expected to be here", + "path": "{requestBody}", + }, + ] + `); + expect(validationResult.suggestions).toMatchInlineSnapshot(` + [ + { + "current": "clientname", + "suggested": "clientName", + }, + { + "current": "ROLES", + "suggested": "roles", + }, + ] + `); + }); + + it('should apply suggestions correctly', () => { + expect(applySuggestions(actual, suggestions)).toMatchObject(expected); + }); + }); + + describe('given it has additional properties', () => { + const actual = { + clientName: 'Hometown SIS', + roles: ['vendor', 'assessment'], + extra: true, + }; + + let validationResult: BodyValidation; + + beforeAll(() => { + // Act + validationResult = validateCreateClientBody(actual); + }); + + it('should return error message with no suggestions', () => { + if (validationResult.isValid) { + expect(validationResult.isValid).toBeFalsy(); + return; + } + expect(validationResult.failureMessage).toMatchInlineSnapshot(` + [ + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'extra' property is not expected to be here", + "path": "{requestBody}", + }, + ] + `); + expect(validationResult.suggestions).toEqual([]); + }); + }); + + describe('given it has a typo', () => { + const actual = { + clientNane: 'Hometown SIS', + roles: ['vendor', 'assessment'], + }; + + let validationResult: BodyValidation; + + beforeAll(() => { + // Act + validationResult = validateCreateClientBody(actual); + }); + + it('should return suggestions', () => { + if (validationResult.isValid) { + expect(validationResult.isValid).toBeFalsy(); + return; + } + expect(validationResult.failureMessage).toMatchInlineSnapshot(` + [ + { + "context": { + "errorType": "required", + }, + "message": "{requestBody} must have required property 'clientName'", + "path": "{requestBody}", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'clientNane' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'clientName'?", + }, + ] + `); + expect(validationResult.suggestions).toEqual([]); + }); + }); + }); + + describe("given it's validating the request token body", () => { + describe("given it's valid", () => { + const expected = { + grant_type: 'client_credentials', + client_id: 'Hometown SIS', + client_secret: '123456', + }; + let validationResult: BodyValidation; + + beforeAll(() => { + // Act + validationResult = validateRequestTokenBody(expected); + }); + + it('should return valid', () => { + expect(validationResult).toBeTruthy(); + }); + }); + + describe('given it has different casing', () => { + const actual = { + GRANT_TYPE: 'client_credentials', + CLIENT_ID: 'Hometown SIS', + CLIENT_SECRET: '123456', + }; + + const expected = { + grant_type: 'client_credentials', + client_id: 'Hometown SIS', + client_secret: '123456', + }; + + let validationResult: BodyValidation; + let suggestions: Suggestion[] = []; + + beforeAll(() => { + // Act + validationResult = validateRequestTokenBody(actual); + if (!validationResult.isValid) { + suggestions = validationResult.suggestions ?? []; + } + }); + + it('should return suggestions', () => { + if (validationResult.isValid) { + expect(validationResult.isValid).toBeFalsy(); + return; + } + expect(validationResult.failureMessage).toMatchInlineSnapshot(` + [ + { + "context": { + "errorType": "required", + }, + "message": "{requestBody} must have required property 'grant_type'", + "path": "{requestBody}", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'GRANT_TYPE' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'grant_type'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'CLIENT_ID' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'client_id'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'CLIENT_SECRET' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'grant_type'?", + }, + ] + `); + expect(validationResult.suggestions).toMatchInlineSnapshot(` + [ + { + "current": "GRANT_TYPE", + "suggested": "grant_type", + }, + { + "current": "CLIENT_ID", + "suggested": "client_id", + }, + { + "current": "CLIENT_SECRET", + "suggested": "client_secret", + }, + ] + `); + }); + + it('should apply suggestions correctly', () => { + expect(applySuggestions(actual, suggestions)).toMatchObject(expected); + }); + }); + + describe('given it has a typo', () => { + const actual = { + grant_types: 'client_credentials', + clientid: 'Hometown SIS', + 'client-secret': '123456', + }; + + let validationResult: BodyValidation; + + beforeAll(() => { + // Act + validationResult = validateRequestTokenBody(actual); + }); + + it('should return suggestions', () => { + if (validationResult.isValid) { + expect(validationResult.isValid).toBeFalsy(); + return; + } + expect(validationResult.failureMessage).toMatchInlineSnapshot(` + [ + { + "context": { + "errorType": "required", + }, + "message": "{requestBody} must have required property 'grant_type'", + "path": "{requestBody}", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'grant_types' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'grant_type'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'clientid' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'client_id'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'client-secret' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'client_secret'?", + }, + ] + `); + expect(validationResult.suggestions).toEqual([]); + }); + }); + }); +}); diff --git a/Meadowlark-js/packages/meadowlark-core/package.json b/Meadowlark-js/packages/meadowlark-core/package.json index e29b0bec..5077f225 100644 --- a/Meadowlark-js/packages/meadowlark-core/package.json +++ b/Meadowlark-js/packages/meadowlark-core/package.json @@ -1,7 +1,7 @@ { "name": "@edfi/meadowlark-core", "main": "dist/index.js", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "description": "Meadowlark core functionality", "license": "Apache-2.0", "publishConfig": { @@ -14,7 +14,7 @@ ], "dependencies": { "@apideck/better-ajv-errors": "^0.3.6", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@isaacs/ttlcache": "^1.4.1", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", diff --git a/Meadowlark-js/packages/meadowlark-utilities/package.json b/Meadowlark-js/packages/meadowlark-utilities/package.json index 4a715969..25c7468e 100644 --- a/Meadowlark-js/packages/meadowlark-utilities/package.json +++ b/Meadowlark-js/packages/meadowlark-utilities/package.json @@ -1,7 +1,7 @@ { "name": "@edfi/meadowlark-utilities", "main": "dist/index.js", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "description": "Meadowlark shared utilities", "license": "Apache-2.0", "publishConfig": { diff --git a/Meadowlark-js/services/meadowlark-fastify/package.json b/Meadowlark-js/services/meadowlark-fastify/package.json index 9258bea5..cc9bd15f 100644 --- a/Meadowlark-js/services/meadowlark-fastify/package.json +++ b/Meadowlark-js/services/meadowlark-fastify/package.json @@ -1,6 +1,6 @@ { "name": "@edfi/meadowlark-fastify", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "description": "Meadowlark service using Fastify", "license": "Apache-2.0", "publishConfig": { @@ -12,9 +12,9 @@ "/package.json" ], "dependencies": { - "@edfi/meadowlark-authz-server": "0.4.0-pre.5", - "@edfi/meadowlark-core": "0.4.0-pre.5", - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-authz-server": "0.4.0-pre.6", + "@edfi/meadowlark-core": "0.4.0-pre.6", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@fastify/rate-limit": "^6.0.1", "dotenv": "^16.3.1", "fastify": "^3.29.5" diff --git a/Meadowlark-js/tests/e2e/package.json b/Meadowlark-js/tests/e2e/package.json index 930dc533..102a4fd8 100644 --- a/Meadowlark-js/tests/e2e/package.json +++ b/Meadowlark-js/tests/e2e/package.json @@ -1,13 +1,13 @@ { "name": "@edfi/meadowlark-e2e-tests", "main": "dist/index.js", - "version": "0.4.0-pre.5", + "version": "0.4.0-pre.6", "description": "Meadowlark Ed-Fi API end to end tests", "license": "Apache-2.0", "private": true, "files": [], "devDependencies": { - "@edfi/meadowlark-utilities": "0.4.0-pre.5", + "@edfi/meadowlark-utilities": "0.4.0-pre.6", "@testcontainers/mongodb": "^10.3.1", "@testcontainers/postgresql": "^10.3.1", "@types/chance": "^1.1.6", diff --git a/Meadowlark-js/tests/e2e/scenarios/AuthenticationValidation.test.ts b/Meadowlark-js/tests/e2e/scenarios/AuthenticationValidation.test.ts index 722b63dc..6a79fbcb 100644 --- a/Meadowlark-js/tests/e2e/scenarios/AuthenticationValidation.test.ts +++ b/Meadowlark-js/tests/e2e/scenarios/AuthenticationValidation.test.ts @@ -35,6 +35,29 @@ describe("given it's authenticating a client", () => { }); }); + describe('when providing uppercase property names', () => { + it('should be able to return access token', async () => { + await baseURLRequest() + .post('/oauth/token') + .send({ + Grant_Type: 'client_credentials', + Client_Id: client.key, + Client_Secret: client.secret, + }) + .expect(200) + .then((response) => { + expect(response.body).toEqual( + expect.objectContaining({ + access_token: expect.any(String), + expires_in: expect.any(Number), + refresh_token: 'not available', + token_type: 'bearer', + }), + ); + }); + }); + }); + describe('when providing invalid grant type', () => { it('should return error message', async () => { await baseURLRequest() @@ -95,6 +118,58 @@ describe("given it's authenticating a client", () => { }); }); + describe('when providing invalid property name', () => { + it('should return 400 and error message', async () => { + await baseURLRequest() + .post('/oauth/token') + .send({ + grand_type: 'client_credentials', + clientId: client.key, + client_secrets: 'secret', + }) + .expect(400) + .then((response) => { + expect(response.body).toMatchInlineSnapshot(` + { + "error": [ + { + "context": { + "errorType": "required", + }, + "message": "{requestBody} must have required property 'grant_type'", + "path": "{requestBody}", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'grand_type' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'grant_type'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'clientId' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'client_id'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'client_secrets' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'client_secret'?", + }, + ], + } + `); + }); + }); + }); + describe('when sending to an uppercase url', () => { it('should be able to return access token', async () => { await baseURLRequest() diff --git a/Meadowlark-js/tests/e2e/scenarios/AuthorizationValidation.test.ts b/Meadowlark-js/tests/e2e/scenarios/AuthorizationValidation.test.ts index dcdbe8cd..e618fd01 100644 --- a/Meadowlark-js/tests/e2e/scenarios/AuthorizationValidation.test.ts +++ b/Meadowlark-js/tests/e2e/scenarios/AuthorizationValidation.test.ts @@ -17,185 +17,69 @@ describe("given it's managing the client authorization", () => { roles: ['vendor'], }; - describe('when generating a client and sending it to an uppercase url', () => { - beforeAll(async () => { - adminToken = await adminAccessToken(); - await baseURLRequest() - .post(ENDPOINT.toUpperCase()) - .send(clientData) - .expect(201) - .auth(adminToken, { type: 'bearer' }) - .then((response) => { - location = response.headers.location; - responseBody = response.body; - }); - }); - - it('is created', () => { - expect(responseBody).toEqual( - expect.objectContaining({ - clientName: 'Test Vendor', - roles: ['vendor'], - }), - ); - }); - }); - - describe('given client already exists ', () => { - beforeAll(async () => { - adminToken = await adminAccessToken(); - location = await baseURLRequest() - .post(ENDPOINT) - .send(clientData) - .expect(201) - .auth(adminToken, { type: 'bearer' }) - .then((response) => response.headers.location); - }); - - describe("given it's creating a client", () => { - describe('when generating a client with invalid admin token', () => { - it('should return error message', async () => { - await baseURLRequest() - .post(ENDPOINT) - .auth('', { type: 'bearer' }) - .send({ - clientName: 'Automation Client', - roles: ['vendor'], - }) - .expect(401) - .then((response) => { - expect(response.body).toMatchInlineSnapshot(` - { - "error": "invalid_client", - "error_description": "Authorization token not provided", - } - `); - }); - }); - }); - - describe('when generating a client without admin token', () => { - it('should return error message', async () => { - await baseURLRequest() - .post(ENDPOINT) - .send({ - clientName: 'Automation Client', - roles: ['vendor'], - }) - .expect(401) - .then((response) => { - expect(response.body).toMatchInlineSnapshot(` - { - "error": "invalid_client", - "error_description": "Authorization token not provided", - } - `); - }); - }); + describe("given it's creating a client", () => { + describe('when generating a client with invalid admin token', () => { + it('should return error message', async () => { + await baseURLRequest() + .post(ENDPOINT) + .auth('', { type: 'bearer' }) + .send({ + clientName: 'Automation Client', + roles: ['vendor'], + }) + .expect(401) + .then((response) => { + expect(response.body).toMatchInlineSnapshot(` + { + "error": "invalid_client", + "error_description": "Authorization token not provided", + } + `); + }); }); + }); - describe('when generating a client with a role combination', () => { - // This should be modified when RND-452 is done - describe('when using a valid combination of roles', () => { - it.each([ - { roles: ['verify-only'] }, - { roles: ['admin'] }, - { roles: ['admin', 'assessment'] }, - { roles: ['assessment', 'host'] }, - { roles: ['assessment', 'vendor'] }, - ])('should create client with %j', async (item) => { - const { roles } = item; - - const clientInfo = { - clientName: 'Test Client', - roles, - }; - - await baseURLRequest() - .post(ENDPOINT) - .auth(await adminAccessToken(), { type: 'bearer' }) - .send(clientInfo) - .expect(201) - .then((response) => { - expect(response.body).toEqual(expect.objectContaining(clientInfo)); - }); + describe('when generating a client without admin token', () => { + it('should return error message', async () => { + await baseURLRequest() + .post(ENDPOINT) + .send({ + clientName: 'Automation Client', + roles: ['vendor'], + }) + .expect(401) + .then((response) => { + expect(response.body).toMatchInlineSnapshot(` + { + "error": "invalid_client", + "error_description": "Authorization token not provided", + } + `); }); - }); + }); + }); - describe('when generating a client with more than 2 roles', () => { - it('should return error message', async () => { - const roles = ['admin', 'vendor', 'host']; - - await baseURLRequest() - .post(ENDPOINT) - .auth(await adminAccessToken(), { type: 'bearer' }) - .send({ - clientName: 'Admin Client', - roles, - }) - .expect(400) - .then((response) => { - expect(response.body).toMatchInlineSnapshot(` - { - "error": [ - { - "context": { - "errorType": "maxItems", - }, - "message": "property 'roles' must not have more than 2 items", - "path": "{requestBody}.roles", - }, - ], - } - `); - }); + describe('when generating a client and sending it to an uppercase url', () => { + beforeAll(async () => { + adminToken = await adminAccessToken(); + await baseURLRequest() + .post(ENDPOINT.toUpperCase()) + .send(clientData) + .expect(201) + .auth(adminToken, { type: 'bearer' }) + .then((response) => { + location = response.headers.location; + responseBody = response.body; }); - }); + }); - describe('when generating a client with an invalid role', () => { - it('should return error message', async () => { - const roles = ['not-valid']; - - await baseURLRequest() - .post(ENDPOINT) - .auth(await adminAccessToken(), { type: 'bearer' }) - .send({ - clientName: 'Admin Client', - roles, - }) - .expect(400) - .then((response) => { - expect(response.body).toMatchInlineSnapshot(` - { - "error": [ - { - "context": { - "allowedValues": [ - "vendor", - "host", - "admin", - "assessment", - "verify-only", - ], - "errorType": "enum", - }, - "message": "'0' property must be equal to one of the allowed values", - "path": "{requestBody}.roles.0", - "suggestion": "Did you mean 'host'?", - }, - { - "context": { - "errorType": "contains", - }, - "message": "property 'roles' must contain at least 1 valid item(s)", - "path": "{requestBody}.roles", - }, - ], - } - `); - }); - }); - }); + it('is created', () => { + expect(responseBody).toEqual( + expect.objectContaining({ + clientName: 'Test Vendor', + roles: ['vendor'], + }), + ); }); }); @@ -286,6 +170,204 @@ describe("given it's managing the client authorization", () => { }); }); + describe('when generating a client name with key name with different casing', () => { + beforeAll(async () => { + adminToken = await adminAccessToken(); + const updatedClientData = { + clientname: 'Test Vendor', + ROLES: ['vendor'], + }; + + await baseURLRequest() + .post(ENDPOINT) + .send(updatedClientData) + .expect(201) + .auth(adminToken, { type: 'bearer' }) + .then((response) => { + location = response.headers.location; + responseBody = response.body; + }); + }); + + it('is created', () => { + expect(responseBody).toEqual( + expect.objectContaining({ + clientName: 'Test Vendor', + roles: ['vendor'], + }), + ); + }); + }); + + describe('when generating a client name with additional properties', () => { + beforeAll(async () => { + adminToken = await adminAccessToken(); + const updatedClientData = { + clientName: 'Test Vendor', + role: ['vendor'], + errors: true, + }; + + await baseURLRequest() + .post(ENDPOINT) + .send(updatedClientData) + .expect(400) + .auth(adminToken, { type: 'bearer' }) + .then((response) => { + location = response.headers.location; + responseBody = response.body; + }); + }); + + it('returns validation errors', () => { + expect(responseBody).toMatchInlineSnapshot(` + { + "error": [ + { + "context": { + "errorType": "required", + }, + "message": "{requestBody} must have required property 'roles'", + "path": "{requestBody}", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'role' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'roles'?", + }, + { + "context": { + "errorType": "additionalProperties", + }, + "message": "'errors' property is not expected to be here", + "path": "{requestBody}", + "suggestion": "Did you mean property 'roles'?", + }, + ], + } + `); + }); + }); + + describe('when generating a client with a role combination', () => { + // This should be modified when RND-452 is done + describe('when using a valid combination of roles', () => { + it.each([ + { roles: ['verify-only'] }, + { roles: ['admin'] }, + { roles: ['admin', 'assessment'] }, + { roles: ['assessment', 'host'] }, + { roles: ['assessment', 'vendor'] }, + ])('should create client with %j', async (item) => { + const { roles } = item; + + const clientInfo = { + clientName: 'Test Client', + roles, + }; + + await baseURLRequest() + .post(ENDPOINT) + .auth(await adminAccessToken(), { type: 'bearer' }) + .send(clientInfo) + .expect(201) + .then((response) => { + expect(response.body).toEqual(expect.objectContaining(clientInfo)); + }); + }); + }); + + describe('when generating a client with more than 2 roles', () => { + it('should return error message', async () => { + const roles = ['admin', 'vendor', 'host']; + + await baseURLRequest() + .post(ENDPOINT) + .auth(await adminAccessToken(), { type: 'bearer' }) + .send({ + clientName: 'Admin Client', + roles, + }) + .expect(400) + .then((response) => { + expect(response.body).toMatchInlineSnapshot(` + { + "error": [ + { + "context": { + "errorType": "maxItems", + }, + "message": "property 'roles' must not have more than 2 items", + "path": "{requestBody}.roles", + }, + ], + } + `); + }); + }); + }); + + describe('when generating a client with an invalid role', () => { + it('should return error message', async () => { + const roles = ['not-valid']; + + await baseURLRequest() + .post(ENDPOINT) + .auth(await adminAccessToken(), { type: 'bearer' }) + .send({ + clientName: 'Admin Client', + roles, + }) + .expect(400) + .then((response) => { + expect(response.body).toMatchInlineSnapshot(` + { + "error": [ + { + "context": { + "allowedValues": [ + "vendor", + "host", + "admin", + "assessment", + "verify-only", + ], + "errorType": "enum", + }, + "message": "'0' property must be equal to one of the allowed values", + "path": "{requestBody}.roles.0", + "suggestion": "Did you mean 'host'?", + }, + { + "context": { + "errorType": "contains", + }, + "message": "property 'roles' must contain at least 1 valid item(s)", + "path": "{requestBody}.roles", + }, + ], + } + `); + }); + }); + }); + }); + }); + + describe('given client already exists ', () => { + beforeAll(async () => { + adminToken = await adminAccessToken(); + location = await baseURLRequest() + .post(ENDPOINT) + .send(clientData) + .expect(201) + .auth(adminToken, { type: 'bearer' }) + .then((response) => response.headers.location); + }); + describe('when retrieving information', () => { describe("given it's an administrator user", () => { it('should be able to retrieve the client', async () => { diff --git a/Meadowlark-js/tests/http/local.RND663.http b/Meadowlark-js/tests/http/local.RND663.http new file mode 100644 index 00000000..34fe3eda --- /dev/null +++ b/Meadowlark-js/tests/http/local.RND663.http @@ -0,0 +1,123 @@ +##### START AUTHENTICATION SETUP + +@admin_client_id=meadowlark_admin_key_1 +@admin_client_secret=meadowlark_admin_secret_1 + +### Authenticate admin +# @name admin1 +POST http://localhost:3000/local/oauth/token +content-type: application/json + +{ + "GRANT_TYPE": "client_credentials", + "client_id": "{{admin_client_id}}", + "client_secret": "{{admin_client_secret}}" +} + +### +@admin_token={{admin1.response.body.$.access_token}} +@auth_header_admin_1=Authorization: bearer {{admin1.response.body.$.access_token}} + + +### Create client1 +# @name created_client1 +POST http://localhost:3000/local/oauth/clients +content-type: application/json +{{auth_header_admin_1}} + +{ + "clientname": "Hometown SIS", + "rOLEs": [ + "vendor" + ] +} + +### +@client1_client_id={{created_client1.response.body.$.client_id}} +@client1_client_secret={{created_client1.response.body.$.client_secret}} + +### Authenticate client1 +# @name client1 +POST http://localhost:3000/local/oauth/token +content-type: application/json + +{ + "Grant_type": "client_credentials", + "Client_id": "{{client1_client_id}}", + "Client_secret": "{{client1_client_secret}}" +} + +### +@authToken1 = {{client1.response.body.$.access_token}} + +### + + +### Create client4 +# This Should fail, values are not case sensitive +POST http://localhost:3000/local/oauth/clients +content-type: application/json +{{auth_header_admin_1}} + +{ + "clientName": "the-one-sis", + "roles": [ + "VENDOR", + "assessment" + ] +} + +### +# @name contentClassDescriptor +# This should fail, similar words, pluralization +POST http://localhost:3000/local/v3.3b/ed-fi/contentClassDescriptors +authorization: bearer {{authToken1}} +content-type: application/json + +{ + "goodValue": "Presentation", + "shortDescriptions": "Presentation", + "description": "Presentation", + "namespace": "uri://ed-fi.org/ContentClassDescriptor" +} + +### +# @name contentClassDescriptor +# This should fail, wrong property +POST http://localhost:3000/local/v3.3b/ed-fi/contentClassDescriptors +authorization: bearer {{authToken1}} +content-type: application/json + +{ + "codeValue": "Presentation", + "shortDescription": "Presentation", + "description": "Presentation", + "uri": "uri://ed-fi.org/ContentClassDescriptor" +} + +### +# @name contentClassDescriptor +# This should fail, missing property +POST http://localhost:3000/local/v3.3b/ed-fi/contentClassDescriptors +authorization: bearer {{authToken1}} +content-type: application/json + +{ + "codeValue": "Presentation", + "shortDescription": "Presentation", + "description": "Presentation" +} + +### +# @name contentClassDescriptor +# This should work, uppercase keys +POST http://localhost:3000/local/v3.3b/ed-fi/contentClassDescriptors +authorization: bearer {{authToken1}} +content-type: application/json + +{ + "CODEVALUE": "Presentation", + "SHORTDESCRIPTION": "Presentation", + "DESCRIPTION": "Presentation", + "NAMESPACE": "uri://ed-fi.org/ContentClassDescriptor" +} \ No newline at end of file