diff --git a/api/db/database-builder/factory/build-lti-platform-registration.js b/api/db/database-builder/factory/build-lti-platform-registration.js new file mode 100644 index 00000000000..3e9f6982c32 --- /dev/null +++ b/api/db/database-builder/factory/build-lti-platform-registration.js @@ -0,0 +1,78 @@ +import { randomUUID, subtle } from 'node:crypto'; + +import { cryptoService } from '../../../src/shared/domain/services/crypto-service.js'; +import { databaseBuffer } from '../database-buffer.js'; + +const defaultKeyPair = await generateJWKPair(); + +export function buildLtiPlatformRegistration({ + clientId = 'AbCD1234', + encryptedPrivateKey = defaultKeyPair.encryptedPrivateKey, + platformOpenIdConfigUrl = 'https://moodle.example.net/mod/lti/openid-configuration.php', + platformOrigin = 'https://moodle.example.net', + publicKey = defaultKeyPair.publicKey, + status = 'active', + toolConfig = { + client_id: 'AbCD1234', + response_types: ['id_token'], + jwks_uri: 'https://pix.example.net/api/lti/keys', + initiate_login_uri: 'https://pix.example.net/api/lti/init', + grant_types: ['client_credentials', 'implicit'], + redirect_uris: ['https://pix.example.net/api/lti/launch'], + application_type: 'web', + token_endpoint_auth_method: 'private_key_jwt', + client_name: 'Pix', + logo_uri: '', + scope: + 'https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', + 'https://purl.imsglobal.org/spec/lti-tool-configuration': { + version: '1.3.0', + deployment_id: '123', + target_link_uri: 'https://pix.example.net/api/lti', + domain: 'pix.example.net', + description: '', + messages: [ + { + type: 'LtiDeepLinkingRequest', + target_link_uri: 'https://pix.example.net/api/lti/content-selection', + }, + ], + claims: ['sub', 'iss', 'name', 'family_name', 'given_name', 'email'], + }, + }, +} = {}) { + return databaseBuffer.pushInsertable({ + tableName: 'lti_platform_registrations', + values: { + clientId, + encryptedPrivateKey, + platformOpenIdConfigUrl, + platformOrigin, + publicKey, + status, + toolConfig, + }, + }); +} + +async function generateJWKPair() { + const keyPair = await subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 4096, + hash: 'SHA-256', + publicExponent: new Uint8Array([1, 0, 1]), + }, + true, + ['sign', 'verify'], + ); + const privateKey = await subtle.exportKey('jwk', keyPair.privateKey); + const encryptedPrivateKey = await cryptoService.encrypt(JSON.stringify(privateKey)); + const publicKey = await subtle.exportKey('jwk', keyPair.publicKey); + publicKey.kid = randomUUID(); + return { + privateKey, + encryptedPrivateKey, + publicKey, + }; +} diff --git a/api/db/migrations/20250307144507_fix-lti-platform-registration-encrypted-key-length.js b/api/db/migrations/20250307144507_fix-lti-platform-registration-encrypted-key-length.js new file mode 100644 index 00000000000..1fd9d614ede --- /dev/null +++ b/api/db/migrations/20250307144507_fix-lti-platform-registration-encrypted-key-length.js @@ -0,0 +1,24 @@ +const TABLE_NAME = 'lti_platform_registrations'; +const COLUMN_NAME = 'encryptedPrivateKey'; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +const up = async function (knex) { + await knex.schema.alterTable(TABLE_NAME, function (table) { + table.text(COLUMN_NAME).alter({ alterNullable: false, alterType: true }); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +const down = async function (knex) { + await knex.schema.alterTable(TABLE_NAME, function (table) { + table.string(COLUMN_NAME).alter({ alterNullable: false, alterType: true }); + }); +}; + +export { down, up }; diff --git a/api/src/identity-access-management/domain/models/LtiPlatformRegistration.js b/api/src/identity-access-management/domain/models/LtiPlatformRegistration.js new file mode 100644 index 00000000000..bd263c735a4 --- /dev/null +++ b/api/src/identity-access-management/domain/models/LtiPlatformRegistration.js @@ -0,0 +1,11 @@ +export class LtiPlatformRegistration { + constructor({ clientId, platformOrigin, status, toolConfig, encryptedPrivateKey, publicKey, platformOpenIdConfig }) { + this.clientId = clientId; + this.platformOrigin = platformOrigin; + this.status = status; + this.toolConfig = toolConfig; + this.encryptedPrivateKey = encryptedPrivateKey; + this.publicKey = publicKey; + this.platformOpenIdConfig = platformOpenIdConfig; + } +} diff --git a/api/src/identity-access-management/infrastructure/repositories/lti-platform-registration.repository.js b/api/src/identity-access-management/infrastructure/repositories/lti-platform-registration.repository.js new file mode 100644 index 00000000000..b741a346a17 --- /dev/null +++ b/api/src/identity-access-management/infrastructure/repositories/lti-platform-registration.repository.js @@ -0,0 +1,23 @@ +import { knex } from '../../../../db/knex-database-connection.js'; +import { httpAgent } from '../../../shared/infrastructure/http-agent.js'; +import { LtiPlatformRegistration } from '../../domain/models/LtiPlatformRegistration.js'; + +export const ltiPlatformRegistrationRepository = { + async getByClientId(clientId) { + const ltiPlatformRegistrationDTO = await knex + .select('*') + .from('lti_platform_registrations') + .where('clientId', clientId) + .first(); + + if (!ltiPlatformRegistrationDTO) { + return undefined; + } + + const { data: platformOpenIdConfig } = await httpAgent.get({ + url: ltiPlatformRegistrationDTO.platformOpenIdConfigUrl, + }); + + return new LtiPlatformRegistration({ ...ltiPlatformRegistrationDTO, platformOpenIdConfig }); + }, +}; diff --git a/api/tests/identity-access-management/integration/infrastructure/repositories/lti-platform-registration.repository.test.js b/api/tests/identity-access-management/integration/infrastructure/repositories/lti-platform-registration.repository.test.js new file mode 100644 index 00000000000..3201ab1644e --- /dev/null +++ b/api/tests/identity-access-management/integration/infrastructure/repositories/lti-platform-registration.repository.test.js @@ -0,0 +1,40 @@ +import { ltiPlatformRegistrationRepository } from '../../../../../src/identity-access-management/infrastructure/repositories/lti-platform-registration.repository.js'; +import { databaseBuilder, domainBuilder, expect, nock } from '../../../../test-helper.js'; + +describe('Integration | Identity Access Management | Infrastructure | Repository | lti-platform-registration', function () { + describe('#getByClientId', function () { + it('should return LTI platform registration information', async function () { + // given + const clientId = 'AbCD1234'; + const expectedLtiPlatformRegistration = domainBuilder.identityAccessManagement.buildLtiPlatformRegistration(); + + const savedLtiPlatformRegistration = databaseBuilder.factory.buildLtiPlatformRegistration( + expectedLtiPlatformRegistration, + ); + await databaseBuilder.commit(); + + const platformOpenIdConfigUrl = new URL(savedLtiPlatformRegistration.platformOpenIdConfigUrl); + const platformOpenIdConfigCall = nock(platformOpenIdConfigUrl.origin) + .get(platformOpenIdConfigUrl.pathname) + .reply(200, expectedLtiPlatformRegistration.platformOpenIdConfig); + + // when + const registration = await ltiPlatformRegistrationRepository.getByClientId(clientId); + + // then + expect(platformOpenIdConfigCall.isDone()).to.be.true; + expect(registration).to.deepEqualInstance(expectedLtiPlatformRegistration); + }); + + it('should return undefined when any LTI platform are found', async function () { + // given + const clientId = 'NOT-FOUND'; + + // when + const registration = await ltiPlatformRegistrationRepository.getByClientId(clientId); + + // then + expect(registration).to.be.undefined; + }); + }); +}); diff --git a/api/tests/tooling/domain-builder/factory/identity-access-management/build-lti-platform-registration.js b/api/tests/tooling/domain-builder/factory/identity-access-management/build-lti-platform-registration.js new file mode 100644 index 00000000000..68da2244fc7 --- /dev/null +++ b/api/tests/tooling/domain-builder/factory/identity-access-management/build-lti-platform-registration.js @@ -0,0 +1,152 @@ +import { randomUUID, subtle } from 'node:crypto'; + +import { LtiPlatformRegistration } from '../../../../../src/identity-access-management/domain/models/LtiPlatformRegistration.js'; +import { cryptoService } from '../../../../../src/shared/domain/services/crypto-service.js'; + +const defaultKeyPair = await generateJWKPair(); + +export function buildLtiPlatformRegistration({ + clientId = 'AbCD1234', + encryptedPrivateKey = defaultKeyPair.encryptedPrivateKey, + platformOpenIdConfig = { + issuer: 'https://moodle.example.net', + token_endpoint: 'https://moodle.example.net/mod/lti/token.php', + token_endpoint_auth_methods_supported: ['private_key_jwt'], + token_endpoint_auth_signing_alg_values_supported: ['RS256'], + jwks_uri: 'https://moodle.example.net/mod/lti/certs.php', + authorization_endpoint: 'https://moodle.example.net/mod/lti/auth.php', + registration_endpoint: 'https://moodle.example.net/mod/lti/openid-registration.php', + scopes_supported: [ + 'https://purl.imsglobal.org/spec/lti-bo/scope/basicoutcome', + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/score', + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', + 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting', + 'openid', + ], + response_types_supported: ['id_token'], + subject_types_supported: ['public', 'pairwise'], + id_token_signing_alg_values_supported: ['RS256'], + claims_supported: ['sub', 'iss', 'name', 'given_name', 'family_name', 'email'], + 'https://purl.imsglobal.org/spec/lti-platform-configuration': { + product_family_code: 'moodle', + version: '4.5.2 (Build: 20250210)', + messages_supported: [ + { + type: 'LtiResourceLinkRequest', + }, + { + type: 'LtiDeepLinkingRequest', + placements: ['ContentArea'], + }, + ], + variables: [ + 'basic-lti-launch-request', + 'ContentItemSelectionRequest', + 'ToolProxyRegistrationRequest', + 'Context.id', + 'Context.title', + 'Context.label', + 'Context.id.history', + 'Context.sourcedId', + 'Context.longDescription', + 'Context.timeFrame.begin', + 'CourseSection.title', + 'CourseSection.label', + 'CourseSection.sourcedId', + 'CourseSection.longDescription', + 'CourseSection.timeFrame.begin', + 'CourseSection.timeFrame.end', + 'ResourceLink.id', + 'ResourceLink.title', + 'ResourceLink.description', + 'User.id', + 'User.username', + 'Person.name.full', + 'Person.name.given', + 'Person.name.family', + 'Person.email.primary', + 'Person.sourcedId', + 'Person.name.middle', + 'Person.address.street1', + 'Person.address.locality', + 'Person.address.country', + 'Person.address.timezone', + 'Person.phone.primary', + 'Person.phone.mobile', + 'Person.webaddress', + 'Membership.role', + 'Result.sourcedId', + 'Result.autocreate', + 'BasicOutcome.sourcedId', + 'BasicOutcome.url', + 'Moodle.Person.userGroupIds', + ], + }, + }, + platformOrigin = 'https://moodle.example.net', + publicKey = defaultKeyPair.publicKey, + status = 'active', + toolConfig = { + client_id: clientId, + response_types: ['id_token'], + jwks_uri: 'https://pix.example.net/api/lti/keys', + initiate_login_uri: 'https://pix.example.net/api/lti/init', + grant_types: ['client_credentials', 'implicit'], + redirect_uris: ['https://pix.example.net/api/lti/launch'], + application_type: 'web', + token_endpoint_auth_method: 'private_key_jwt', + client_name: 'Pix', + logo_uri: '', + scope: + 'https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', + 'https://purl.imsglobal.org/spec/lti-tool-configuration': { + version: '1.3.0', + deployment_id: '123', + target_link_uri: 'https://pix.example.net/api/lti', + domain: 'pix.example.net', + description: '', + messages: [ + { + type: 'LtiDeepLinkingRequest', + target_link_uri: 'https://pix.example.net/api/lti/content-selection', + }, + ], + claims: ['sub', 'iss', 'name', 'family_name', 'given_name', 'email'], + }, + }, +} = {}) { + return new LtiPlatformRegistration({ + clientId, + encryptedPrivateKey, + platformOpenIdConfig, + platformOrigin, + publicKey, + status, + toolConfig, + }); +} + +async function generateJWKPair() { + const keyPair = await subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 4096, + hash: 'SHA-256', + publicExponent: new Uint8Array([1, 0, 1]), + }, + true, + ['sign', 'verify'], + ); + const privateKey = await subtle.exportKey('jwk', keyPair.privateKey); + const encryptedPrivateKey = await cryptoService.encrypt(JSON.stringify(privateKey)); + const publicKey = await subtle.exportKey('jwk', keyPair.publicKey); + publicKey.kid = randomUUID(); + return { + privateKey, + encryptedPrivateKey, + publicKey, + }; +} diff --git a/api/tests/tooling/domain-builder/factory/index.js b/api/tests/tooling/domain-builder/factory/index.js index 8c3f815a5fd..28c39511c5a 100644 --- a/api/tests/tooling/domain-builder/factory/index.js +++ b/api/tests/tooling/domain-builder/factory/index.js @@ -203,6 +203,7 @@ import { buildSessionManagement } from './certification/session-management/build import { buildCompetenceForScoring } from './certification/shared/build-competence-for-scoring.js'; import { buildJuryComment } from './certification/shared/build-jury-comment.js'; import { buildV3CertificationScoring } from './certification/shared/build-v3-certification-scoring.js'; +import { buildLtiPlatformRegistration } from './identity-access-management/build-lti-platform-registration.js'; import { buildUserLogin } from './identity-access-management/build-user-login.js'; import { buildCampaign as boundedContextCampaignBuildCampaign } from './prescription/campaign/build-campaign.js'; import { buildCampaignParticipation as boundedContextCampaignParticipationBuildCampaignParticipation } from './prescription/campaign-participation/build-campaign-participation.js'; @@ -282,6 +283,7 @@ const prescription = { const identityAccessManagement = { buildUserLogin, + buildLtiPlatformRegistration, }; export {