Skip to content

Commit 90d0366

Browse files
feat(api): allow retrieve lti platform registration
Co-authored-by: Nicolas Lepage <[email protected]> Co-authored-by: Vincent Hardouin <[email protected]>
1 parent f79ad4e commit 90d0366

File tree

7 files changed

+330
-0
lines changed

7 files changed

+330
-0
lines changed
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { randomUUID, subtle } from 'node:crypto';
2+
3+
import { cryptoService } from '../../../src/shared/domain/services/crypto-service.js';
4+
import { databaseBuffer } from '../database-buffer.js';
5+
6+
const defaultKeyPair = await generateJWKPair();
7+
8+
export function buildLtiPlatformRegistration({
9+
clientId = 'AbCD1234',
10+
encryptedPrivateKey = defaultKeyPair.encryptedPrivateKey,
11+
platformOpenIdConfigUrl = 'https://moodle.example.net/mod/lti/openid-configuration.php',
12+
platformOrigin = 'https://moodle.example.net',
13+
publicKey = defaultKeyPair.publicKey,
14+
status = 'active',
15+
toolConfig = {
16+
client_id: 'AbCD1234',
17+
response_types: ['id_token'],
18+
jwks_uri: 'https://pix.example.net/api/lti/keys',
19+
initiate_login_uri: 'https://pix.example.net/api/lti/init',
20+
grant_types: ['client_credentials', 'implicit'],
21+
redirect_uris: ['https://pix.example.net/api/lti/launch'],
22+
application_type: 'web',
23+
token_endpoint_auth_method: 'private_key_jwt',
24+
client_name: 'Pix',
25+
logo_uri: '',
26+
scope:
27+
'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',
28+
'https://purl.imsglobal.org/spec/lti-tool-configuration': {
29+
version: '1.3.0',
30+
deployment_id: '123',
31+
target_link_uri: 'https://pix.example.net/api/lti',
32+
domain: 'pix.example.net',
33+
description: '',
34+
messages: [
35+
{
36+
type: 'LtiDeepLinkingRequest',
37+
target_link_uri: 'https://pix.example.net/api/lti/content-selection',
38+
},
39+
],
40+
claims: ['sub', 'iss', 'name', 'family_name', 'given_name', 'email'],
41+
},
42+
},
43+
} = {}) {
44+
return databaseBuffer.pushInsertable({
45+
tableName: 'lti_platform_registrations',
46+
values: {
47+
clientId,
48+
encryptedPrivateKey,
49+
platformOpenIdConfigUrl,
50+
platformOrigin,
51+
publicKey,
52+
status,
53+
toolConfig,
54+
},
55+
});
56+
}
57+
58+
async function generateJWKPair() {
59+
const keyPair = await subtle.generateKey(
60+
{
61+
name: 'RSASSA-PKCS1-v1_5',
62+
modulusLength: 4096,
63+
hash: 'SHA-256',
64+
publicExponent: new Uint8Array([1, 0, 1]),
65+
},
66+
true,
67+
['sign', 'verify'],
68+
);
69+
const privateKey = await subtle.exportKey('jwk', keyPair.privateKey);
70+
const encryptedPrivateKey = await cryptoService.encrypt(JSON.stringify(privateKey));
71+
const publicKey = await subtle.exportKey('jwk', keyPair.publicKey);
72+
publicKey.kid = randomUUID();
73+
return {
74+
privateKey,
75+
encryptedPrivateKey,
76+
publicKey,
77+
};
78+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const TABLE_NAME = 'lti_platform_registrations';
2+
const COLUMN_NAME = 'encryptedPrivateKey';
3+
4+
/**
5+
* @param { import("knex").Knex } knex
6+
* @returns { Promise<void> }
7+
*/
8+
const up = async function (knex) {
9+
await knex.schema.alterTable(TABLE_NAME, function (table) {
10+
table.text(COLUMN_NAME).alter({ alterNullable: false, alterType: true });
11+
});
12+
};
13+
14+
/**
15+
* @param { import("knex").Knex } knex
16+
* @returns { Promise<void> }
17+
*/
18+
const down = async function (knex) {
19+
await knex.schema.alterTable(TABLE_NAME, function (table) {
20+
table.string(COLUMN_NAME).alter({ alterNullable: false, alterType: true });
21+
});
22+
};
23+
24+
export { down, up };
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export class LtiPlatformRegistration {
2+
constructor({ clientId, platformOrigin, status, toolConfig, encryptedPrivateKey, publicKey, platformOpenIdConfig }) {
3+
this.clientId = clientId;
4+
this.platformOrigin = platformOrigin;
5+
this.status = status;
6+
this.toolConfig = toolConfig;
7+
this.encryptedPrivateKey = encryptedPrivateKey;
8+
this.publicKey = publicKey;
9+
this.platformOpenIdConfig = platformOpenIdConfig;
10+
}
11+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { knex } from '../../../../db/knex-database-connection.js';
2+
import { httpAgent } from '../../../shared/infrastructure/http-agent.js';
3+
import { LtiPlatformRegistration } from '../../domain/models/LtiPlatformRegistration.js';
4+
5+
export const ltiPlatformRegistrationRepository = {
6+
async getByClientId(clientId) {
7+
const ltiPlatformRegistrationDTO = await knex
8+
.select('*')
9+
.from('lti_platform_registrations')
10+
.where('clientId', clientId)
11+
.first();
12+
13+
if (!ltiPlatformRegistrationDTO) {
14+
return undefined;
15+
}
16+
17+
const { data: platformOpenIdConfig } = await httpAgent.get({
18+
url: ltiPlatformRegistrationDTO.platformOpenIdConfigUrl,
19+
});
20+
21+
return new LtiPlatformRegistration({ ...ltiPlatformRegistrationDTO, platformOpenIdConfig });
22+
},
23+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { ltiPlatformRegistrationRepository } from '../../../../../src/identity-access-management/infrastructure/repositories/lti-platform-registration.repository.js';
2+
import { databaseBuilder, domainBuilder, expect, nock } from '../../../../test-helper.js';
3+
4+
describe('Integration | Identity Access Management | Infrastructure | Repository | lti-platform-registration', function () {
5+
describe('#getByClientId', function () {
6+
it('should return LTI platform registration information', async function () {
7+
// given
8+
const clientId = 'AbCD1234';
9+
const expectedLtiPlatformRegistration = domainBuilder.identityAccessManagement.buildLtiPlatformRegistration();
10+
11+
const savedLtiPlatformRegistration = databaseBuilder.factory.buildLtiPlatformRegistration(
12+
expectedLtiPlatformRegistration,
13+
);
14+
await databaseBuilder.commit();
15+
16+
const platformOpenIdConfigUrl = new URL(savedLtiPlatformRegistration.platformOpenIdConfigUrl);
17+
const platformOpenIdConfigCall = nock(platformOpenIdConfigUrl.origin)
18+
.get(platformOpenIdConfigUrl.pathname)
19+
.reply(200, expectedLtiPlatformRegistration.platformOpenIdConfig);
20+
21+
// when
22+
const registration = await ltiPlatformRegistrationRepository.getByClientId(clientId);
23+
24+
// then
25+
expect(platformOpenIdConfigCall.isDone()).to.be.true;
26+
expect(registration).to.deepEqualInstance(expectedLtiPlatformRegistration);
27+
});
28+
29+
it('should return undefined when any LTI platform are found', async function () {
30+
// given
31+
const clientId = 'NOT-FOUND';
32+
33+
// when
34+
const registration = await ltiPlatformRegistrationRepository.getByClientId(clientId);
35+
36+
// then
37+
expect(registration).to.be.undefined;
38+
});
39+
});
40+
});
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { randomUUID, subtle } from 'node:crypto';
2+
3+
import { LtiPlatformRegistration } from '../../../../../src/identity-access-management/domain/models/LtiPlatformRegistration.js';
4+
import { cryptoService } from '../../../../../src/shared/domain/services/crypto-service.js';
5+
6+
const defaultKeyPair = await generateJWKPair();
7+
8+
export function buildLtiPlatformRegistration({
9+
clientId = 'AbCD1234',
10+
encryptedPrivateKey = defaultKeyPair.encryptedPrivateKey,
11+
platformOpenIdConfig = {
12+
issuer: 'https://moodle.example.net',
13+
token_endpoint: 'https://moodle.example.net/mod/lti/token.php',
14+
token_endpoint_auth_methods_supported: ['private_key_jwt'],
15+
token_endpoint_auth_signing_alg_values_supported: ['RS256'],
16+
jwks_uri: 'https://moodle.example.net/mod/lti/certs.php',
17+
authorization_endpoint: 'https://moodle.example.net/mod/lti/auth.php',
18+
registration_endpoint: 'https://moodle.example.net/mod/lti/openid-registration.php',
19+
scopes_supported: [
20+
'https://purl.imsglobal.org/spec/lti-bo/scope/basicoutcome',
21+
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
22+
'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly',
23+
'https://purl.imsglobal.org/spec/lti-ags/scope/score',
24+
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
25+
'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly',
26+
'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting',
27+
'openid',
28+
],
29+
response_types_supported: ['id_token'],
30+
subject_types_supported: ['public', 'pairwise'],
31+
id_token_signing_alg_values_supported: ['RS256'],
32+
claims_supported: ['sub', 'iss', 'name', 'given_name', 'family_name', 'email'],
33+
'https://purl.imsglobal.org/spec/lti-platform-configuration': {
34+
product_family_code: 'moodle',
35+
version: '4.5.2 (Build: 20250210)',
36+
messages_supported: [
37+
{
38+
type: 'LtiResourceLinkRequest',
39+
},
40+
{
41+
type: 'LtiDeepLinkingRequest',
42+
placements: ['ContentArea'],
43+
},
44+
],
45+
variables: [
46+
'basic-lti-launch-request',
47+
'ContentItemSelectionRequest',
48+
'ToolProxyRegistrationRequest',
49+
'Context.id',
50+
'Context.title',
51+
'Context.label',
52+
'Context.id.history',
53+
'Context.sourcedId',
54+
'Context.longDescription',
55+
'Context.timeFrame.begin',
56+
'CourseSection.title',
57+
'CourseSection.label',
58+
'CourseSection.sourcedId',
59+
'CourseSection.longDescription',
60+
'CourseSection.timeFrame.begin',
61+
'CourseSection.timeFrame.end',
62+
'ResourceLink.id',
63+
'ResourceLink.title',
64+
'ResourceLink.description',
65+
'User.id',
66+
'User.username',
67+
'Person.name.full',
68+
'Person.name.given',
69+
'Person.name.family',
70+
'Person.email.primary',
71+
'Person.sourcedId',
72+
'Person.name.middle',
73+
'Person.address.street1',
74+
'Person.address.locality',
75+
'Person.address.country',
76+
'Person.address.timezone',
77+
'Person.phone.primary',
78+
'Person.phone.mobile',
79+
'Person.webaddress',
80+
'Membership.role',
81+
'Result.sourcedId',
82+
'Result.autocreate',
83+
'BasicOutcome.sourcedId',
84+
'BasicOutcome.url',
85+
'Moodle.Person.userGroupIds',
86+
],
87+
},
88+
},
89+
platformOrigin = 'https://moodle.example.net',
90+
publicKey = defaultKeyPair.publicKey,
91+
status = 'active',
92+
toolConfig = {
93+
client_id: clientId,
94+
response_types: ['id_token'],
95+
jwks_uri: 'https://pix.example.net/api/lti/keys',
96+
initiate_login_uri: 'https://pix.example.net/api/lti/init',
97+
grant_types: ['client_credentials', 'implicit'],
98+
redirect_uris: ['https://pix.example.net/api/lti/launch'],
99+
application_type: 'web',
100+
token_endpoint_auth_method: 'private_key_jwt',
101+
client_name: 'Pix',
102+
logo_uri: '',
103+
scope:
104+
'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',
105+
'https://purl.imsglobal.org/spec/lti-tool-configuration': {
106+
version: '1.3.0',
107+
deployment_id: '123',
108+
target_link_uri: 'https://pix.example.net/api/lti',
109+
domain: 'pix.example.net',
110+
description: '',
111+
messages: [
112+
{
113+
type: 'LtiDeepLinkingRequest',
114+
target_link_uri: 'https://pix.example.net/api/lti/content-selection',
115+
},
116+
],
117+
claims: ['sub', 'iss', 'name', 'family_name', 'given_name', 'email'],
118+
},
119+
},
120+
} = {}) {
121+
return new LtiPlatformRegistration({
122+
clientId,
123+
encryptedPrivateKey,
124+
platformOpenIdConfig,
125+
platformOrigin,
126+
publicKey,
127+
status,
128+
toolConfig,
129+
});
130+
}
131+
132+
async function generateJWKPair() {
133+
const keyPair = await subtle.generateKey(
134+
{
135+
name: 'RSASSA-PKCS1-v1_5',
136+
modulusLength: 4096,
137+
hash: 'SHA-256',
138+
publicExponent: new Uint8Array([1, 0, 1]),
139+
},
140+
true,
141+
['sign', 'verify'],
142+
);
143+
const privateKey = await subtle.exportKey('jwk', keyPair.privateKey);
144+
const encryptedPrivateKey = await cryptoService.encrypt(JSON.stringify(privateKey));
145+
const publicKey = await subtle.exportKey('jwk', keyPair.publicKey);
146+
publicKey.kid = randomUUID();
147+
return {
148+
privateKey,
149+
encryptedPrivateKey,
150+
publicKey,
151+
};
152+
}

api/tests/tooling/domain-builder/factory/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ import { buildSessionManagement } from './certification/session-management/build
203203
import { buildCompetenceForScoring } from './certification/shared/build-competence-for-scoring.js';
204204
import { buildJuryComment } from './certification/shared/build-jury-comment.js';
205205
import { buildV3CertificationScoring } from './certification/shared/build-v3-certification-scoring.js';
206+
import { buildLtiPlatformRegistration } from './identity-access-management/build-lti-platform-registration.js';
206207
import { buildUserLogin } from './identity-access-management/build-user-login.js';
207208
import { buildCampaign as boundedContextCampaignBuildCampaign } from './prescription/campaign/build-campaign.js';
208209
import { buildCampaignParticipation as boundedContextCampaignParticipationBuildCampaignParticipation } from './prescription/campaign-participation/build-campaign-participation.js';
@@ -282,6 +283,7 @@ const prescription = {
282283

283284
const identityAccessManagement = {
284285
buildUserLogin,
286+
buildLtiPlatformRegistration,
285287
};
286288

287289
export {

0 commit comments

Comments
 (0)