Skip to content

Commit

Permalink
feat(api): allow retrieve lti platform registration
Browse files Browse the repository at this point in the history
Co-authored-by: Nicolas Lepage <[email protected]>
Co-authored-by: Vincent Hardouin <[email protected]>
  • Loading branch information
3 people committed Mar 7, 2025
1 parent f79ad4e commit 90d0366
Show file tree
Hide file tree
Showing 7 changed files with 330 additions and 0 deletions.
78 changes: 78 additions & 0 deletions api/db/database-builder/factory/build-lti-platform-registration.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const TABLE_NAME = 'lti_platform_registrations';
const COLUMN_NAME = 'encryptedPrivateKey';

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
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<void> }
*/
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 };
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 });
},
};
Original file line number Diff line number Diff line change
@@ -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;
});
});
});
Original file line number Diff line number Diff line change
@@ -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,
};
}
2 changes: 2 additions & 0 deletions api/tests/tooling/domain-builder/factory/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -282,6 +283,7 @@ const prescription = {

const identityAccessManagement = {
buildUserLogin,
buildLtiPlatformRegistration,
};

export {
Expand Down

0 comments on commit 90d0366

Please sign in to comment.