diff --git a/CHANGELOG.md b/CHANGELOG.md index 374e45d..d86375a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # @longsightgroup/lti-tool +## 0.1.8 + +### Patch Changes + +- Accept absolute HTTPS URI-named extension properties in LTI Deep Linking + settings while continuing to reject ordinary unknown, insecure, and + malformed property names. + ## 0.1.7 ### Breaking Changes diff --git a/package-lock.json b/package-lock.json index ab666d9..4aa9539 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@longsightgroup/lti-tool", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@longsightgroup/lti-tool", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "dependencies": { "jose": "^6.2.3", diff --git a/package.json b/package.json index 3108980..67ce097 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@longsightgroup/lti-tool", - "version": "0.1.7", + "version": "0.1.8", "description": "LTI 1.3 implementation for Node.js with framework and storage adapters", "type": "module", "author": "Longsight", diff --git a/packages/core/src/schemas/lti13/claims/serviceClaims.schema.ts b/packages/core/src/schemas/lti13/claims/serviceClaims.schema.ts index 49c489b..a5f7903 100644 --- a/packages/core/src/schemas/lti13/claims/serviceClaims.schema.ts +++ b/packages/core/src/schemas/lti13/claims/serviceClaims.schema.ts @@ -15,17 +15,37 @@ export const NrpsServiceSchema = z }) .optional(); +const DeepLinkingSettingsShape = { + deep_link_return_url: z.string(), + accept_types: z.array(z.string()), + accept_presentation_document_targets: z.array(z.string()), + accept_media_types: z.string().optional(), + accept_multiple: z.boolean().optional(), + accept_lineitem: z.boolean().optional(), + auto_create: z.boolean().optional(), + title: z.string().optional(), + text: z.string().optional(), + data: z.string().optional(), +}; + +const DeepLinkingSettingsKnownKeys = new Set(Object.keys(DeepLinkingSettingsShape)); +const DeepLinkingSettingsExtensionKeySchema = z.url({ protocol: /^https$/ }); + export const DeepLinkingSettingsSchema = z - .strictObject({ - deep_link_return_url: z.string(), - accept_types: z.array(z.string()), - accept_presentation_document_targets: z.array(z.string()), - accept_media_types: z.string().optional(), - accept_multiple: z.boolean().optional(), - accept_lineitem: z.boolean().optional(), - auto_create: z.boolean().optional(), - title: z.string().optional(), - text: z.string().optional(), - data: z.string().optional(), + .object(DeepLinkingSettingsShape) + .catchall(z.unknown()) + .superRefine((settings, context) => { + const unrecognizedKeys = Object.keys(settings).filter( + (key) => + !DeepLinkingSettingsKnownKeys.has(key) && + !DeepLinkingSettingsExtensionKeySchema.safeParse(key).success, + ); + + if (unrecognizedKeys.length > 0) { + context.addIssue({ + code: 'unrecognized_keys', + keys: unrecognizedKeys, + }); + } }) .optional(); diff --git a/packages/core/test/schemas.test.ts b/packages/core/test/schemas.test.ts index 0f88afd..f732e4a 100644 --- a/packages/core/test/schemas.test.ts +++ b/packages/core/test/schemas.test.ts @@ -472,6 +472,81 @@ describe('Schema Validation Tests', () => { expect(() => LTI13JwtPayloadSchema.parse(validPayload)).not.toThrow(); }); + it('preserves Sakai HTTPS URI extensions in Deep Linking settings', () => { + const sakaiPlacementKey = 'https://www.sakailms.org/spec/lti-dl/placement'; + const sakaiAcceptLineItemKey = + 'https://www.sakailms.org/spec/lti-dl/accept_lineitem'; + const sakaiAcceptAvailableKey = + 'https://www.sakailms.org/spec/lti-dl/accept_available'; + const sakaiAcceptSubmissionKey = + 'https://www.sakailms.org/spec/lti-dl/accept_submission'; + const parsed = LTI13JwtPayloadSchema.parse({ + iss: 'https://platform.example.com', + aud: 'client123', + exp: Math.floor(Date.now() / 1000) + 300, + iat: Math.floor(Date.now() / 1000), + nonce: 'test-nonce', + [LTI_CLAIM_MESSAGE_TYPE]: LTI_MESSAGE_TYPE_DEEP_LINKING_REQUEST, + [LTI_CLAIM_VERSION]: LTI_VERSION_1P3P0, + [LTI_CLAIM_DEPLOYMENT_ID]: 'deployment1', + [LTI_CLAIM_TARGET_LINK_URI]: 'https://tool.example.com/content', + [LTI_CLAIM_DEEP_LINKING_SETTINGS]: { + deep_link_return_url: 'https://platform.example.com/deep_links', + accept_types: ['ltiResourceLink'], + accept_presentation_document_targets: ['iframe'], + [sakaiPlacementKey]: 'lessons', + [sakaiAcceptLineItemKey]: true, + [sakaiAcceptAvailableKey]: false, + [sakaiAcceptSubmissionKey]: false, + }, + }); + + const settings = parsed[LTI_CLAIM_DEEP_LINKING_SETTINGS]; + expect(settings).toMatchObject({ + [sakaiPlacementKey]: 'lessons', + [sakaiAcceptLineItemKey]: true, + [sakaiAcceptAvailableKey]: false, + [sakaiAcceptSubmissionKey]: false, + }); + }); + + it.each([ + ['ordinary unknown key', 'accept_mulitple'], + ['HTTP URL key', 'http://www.sakailms.org/spec/lti-dl/placement'], + ['malformed URL-like key', 'https//www.sakailms.org/spec/lti-dl/placement'], + ])('rejects a Deep Linking settings %s', (_description, unknownKey) => { + const invalidPayload = { + iss: 'https://platform.example.com', + aud: 'client123', + exp: Math.floor(Date.now() / 1000) + 300, + iat: Math.floor(Date.now() / 1000), + nonce: 'test-nonce', + [LTI_CLAIM_MESSAGE_TYPE]: LTI_MESSAGE_TYPE_DEEP_LINKING_REQUEST, + [LTI_CLAIM_VERSION]: LTI_VERSION_1P3P0, + [LTI_CLAIM_DEPLOYMENT_ID]: 'deployment1', + [LTI_CLAIM_TARGET_LINK_URI]: 'https://tool.example.com/content', + [LTI_CLAIM_DEEP_LINKING_SETTINGS]: { + deep_link_return_url: 'https://platform.example.com/deep_links', + accept_types: ['ltiResourceLink'], + accept_presentation_document_targets: ['iframe'], + [unknownKey]: true, + }, + }; + + const result = LTI13JwtPayloadSchema.safeParse(invalidPayload); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + code: 'unrecognized_keys', + keys: [unknownKey], + path: [LTI_CLAIM_DEEP_LINKING_SETTINGS], + }), + ); + } + }); + it('rejects payload with invalid message type', () => { const invalidPayload = { iss: 'https://platform.example.com',