Skip to content

Commit dccfb9f

Browse files
feat: added LtiMockTool
1 parent 03ee496 commit dccfb9f

3 files changed

Lines changed: 372 additions & 10 deletions

File tree

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
#if DEBUG
2+
using System;
3+
using System.Collections.Generic;
4+
using System.IdentityModel.Tokens.Jwt;
5+
using System.Linq;
6+
using System.Net.Http;
7+
using System.Security.Claims;
8+
using System.Security.Cryptography;
9+
using System.Text.Json;
10+
using System.Threading.Tasks;
11+
using LtiAdvantage.AssignmentGradeServices;
12+
using Microsoft.AspNetCore.Mvc;
13+
using Microsoft.IdentityModel.Tokens;
14+
15+
namespace HwProj.APIGateway.API.Lti.Controllers;
16+
17+
[Route("api/mocktool")]
18+
[ApiController]
19+
public class MockToolController(IHttpClientFactory httpClientFactory) : ControllerBase
20+
{
21+
private static readonly RsaSecurityKey SigningKey;
22+
23+
private const string ToolIss = "Local Mock Tool";
24+
private const string ToolNameId = "mock-tool-client-id";
25+
26+
private record MockTask(string Id, string Title, string Description, int Score);
27+
private static readonly List<MockTask> AvailableTasks =
28+
[
29+
new MockTask("1", "Integrals (Mock)", "Calculate definite integral", 10),
30+
new MockTask("2", "Derivatives (Mock)", "Find the derivative of a complex function", 5),
31+
new MockTask("3", "Limits (Mock)", "Calculate sequence limit", 8),
32+
new MockTask("4", "Series (Mock)", "Investigate series for convergence", 12),
33+
new MockTask("5", "Diff. Eqs (Mock)", "Solve linear equation", 15)
34+
];
35+
36+
static MockToolController()
37+
{
38+
var rsa = RSA.Create(2048);
39+
var keyId = "mock-tool-key-id";
40+
SigningKey = new RsaSecurityKey(rsa) { KeyId = keyId };
41+
}
42+
43+
[HttpGet("jwks")]
44+
public IActionResult GetJwks()
45+
{
46+
var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(SigningKey);
47+
return Ok(new { keys = new[] { jwk } });
48+
}
49+
50+
[HttpPost("login")]
51+
public IActionResult Login([FromForm] string iss, [FromForm] string login_hint, [FromForm] string lti_message_hint)
52+
{
53+
var callbackUrl = $"{iss}/api/lti/authorize?" +
54+
$"client_id={ToolNameId}&" +
55+
$"response_type=id_token&" +
56+
$"redirect_uri=http://localhost:5000/api/mocktool/callback&" +
57+
$"login_hint={login_hint}&" +
58+
$"lti_message_hint={lti_message_hint}&" +
59+
$"scope=openid&state=xyz&nonce={Guid.NewGuid()}";
60+
61+
return Redirect(callbackUrl);
62+
}
63+
64+
[HttpPost("callback")]
65+
public async Task<IActionResult> Callback([FromForm] string id_token)
66+
{
67+
var handler = new JwtSecurityTokenHandler();
68+
if (!handler.CanReadToken(id_token)) return BadRequest("Invalid Token");
69+
var unverifiedToken = handler.ReadJwtToken(id_token);
70+
71+
var issuer = unverifiedToken.Issuer;
72+
var platformJwksUrl = $"{issuer}/api/lti/jwks";
73+
74+
var client = httpClientFactory.CreateClient();
75+
string jwksJson;
76+
try {
77+
jwksJson = await client.GetStringAsync(platformJwksUrl);
78+
} catch {
79+
return BadRequest($"Failed to download HwProj keys from {platformJwksUrl}");
80+
}
81+
82+
var platformKeySet = new JsonWebKeySet(jwksJson);
83+
84+
try {
85+
handler.ValidateToken(id_token, new TokenValidationParameters
86+
{
87+
ValidateIssuer = true,
88+
ValidIssuer = issuer,
89+
ValidateAudience = true,
90+
ValidAudience = ToolNameId,
91+
ValidateLifetime = true,
92+
ValidateIssuerSigningKey = true,
93+
IssuerSigningKeys = platformKeySet.Keys
94+
}, out _);
95+
} catch (Exception ex) {
96+
return Unauthorized($"HwProj signature validation error: {ex.Message}");
97+
}
98+
99+
var messageType = unverifiedToken.Claims.FirstOrDefault(c => c.Type == "https://purl.imsglobal.org/spec/lti/claim/message_type")?.Value;
100+
101+
return messageType switch
102+
{
103+
"LtiDeepLinkingRequest" => RenderDeepLinkingSelectionUI(unverifiedToken),
104+
"LtiResourceLinkRequest" => HandleResourceLink(unverifiedToken),
105+
_ => BadRequest($"Unknown message type: {messageType}")
106+
};
107+
}
108+
109+
private IActionResult RenderDeepLinkingSelectionUI(JwtSecurityToken token)
110+
{
111+
var settingsClaim = token.Claims.FirstOrDefault(c => c.Type == "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings");
112+
if (settingsClaim == null) return BadRequest("No deep linking settings found");
113+
114+
var settings = JsonDocument.Parse(settingsClaim.Value);
115+
var returnUrl = settings.RootElement.GetProperty("deep_link_return_url").GetString();
116+
var dataPayload = settings.RootElement.TryGetProperty("data", out var dataEl) ? dataEl.GetString() : "";
117+
118+
var tasksHtml = string.Join("", AvailableTasks.Select(t => $@"
119+
<div class='task-item'>
120+
<label>
121+
<input type='checkbox' name='selectedIds' value='{t.Id}' />
122+
<span class='title'>{t.Title}</span>
123+
<span class='score'>({t.Score} points)</span>
124+
</label>
125+
</div>"));
126+
127+
var html = $@"
128+
<html>
129+
<body style='font-family: sans-serif; padding: 20px;'>
130+
<h2>Select Tasks for HwProj</h2>
131+
<form action='/api/mocktool/submit-selection' method='POST'>
132+
<input type='hidden' name='returnUrl' value='{returnUrl}' />
133+
<input type='hidden' name='data' value='{dataPayload}' />
134+
<input type='hidden' name='platformIssuer' value='{token.Issuer}' />
135+
{tasksHtml}
136+
<br/><button type='submit' style='padding: 10px 20px; background: #007bff; color: white; border: none; cursor: pointer;'>Import</button>
137+
</form>
138+
</body>
139+
</html>";
140+
141+
return Content(html, "text/html");
142+
}
143+
144+
[HttpPost("submit-selection")]
145+
public IActionResult SubmitDeepLinkingSelection(
146+
[FromForm] List<string> selectedIds,
147+
[FromForm] string returnUrl,
148+
[FromForm] string? data,
149+
[FromForm] string platformIssuer)
150+
{
151+
var selectedTasks = AvailableTasks.Where(t => selectedIds.Contains(t.Id)).ToList();
152+
153+
var contentItems = selectedTasks.Select(t => new Dictionary<string, object>
154+
{
155+
["type"] = "ltiResourceLink",
156+
["title"] = t.Title,
157+
["text"] = t.Description,
158+
["url"] = $"http://localhost:5000/mock/task/{t.Id}",
159+
160+
["lineItem"] = new Dictionary<string, object>
161+
{
162+
["scoreMaximum"] = t.Score,
163+
["label"] = t.Title
164+
},
165+
166+
["custom"] = new Dictionary<string, string>
167+
{
168+
{ "internal_task_id", t.Id }
169+
}
170+
171+
}).ToList();
172+
173+
var payload = new JwtPayload
174+
{
175+
{ "iss", ToolIss },
176+
{ "sub", ToolNameId },
177+
{ "aud", platformIssuer },
178+
{ "iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
179+
{ "exp", DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds() },
180+
{ "nonce", Guid.NewGuid().ToString() },
181+
{ "https://purl.imsglobal.org/spec/lti-dl/claim/message_type", "LtiDeepLinkingResponse" },
182+
{ "https://purl.imsglobal.org/spec/lti-dl/claim/version", "1.3.0" },
183+
{ "https://purl.imsglobal.org/spec/lti-dl/claim/content_items", contentItems }
184+
};
185+
186+
if (!string.IsNullOrEmpty(data))
187+
payload.Add("https://purl.imsglobal.org/spec/lti-dl/claim/data", data);
188+
189+
var credentials = new SigningCredentials(SigningKey, SecurityAlgorithms.RsaSha256);
190+
var header = new JwtHeader(credentials);
191+
var responseToken = new JwtSecurityToken(header, payload);
192+
var responseString = new JwtSecurityTokenHandler().WriteToken(responseToken);
193+
194+
var html = $@"
195+
<html>
196+
<body onload='document.forms[0].submit()'>
197+
<form method='POST' action='{returnUrl}'>
198+
<input type='hidden' name='JWT' value='{responseString}' />
199+
</form>
200+
</body>
201+
</html>";
202+
203+
return Content(html, "text/html");
204+
}
205+
206+
private IActionResult HandleResourceLink(JwtSecurityToken token)
207+
{
208+
var presentationClaim = token.Claims.FirstOrDefault(c => c.Type == "https://purl.imsglobal.org/spec/lti/claim/launch_presentation");
209+
var presentationJson = JsonDocument.Parse(presentationClaim?.Value ?? "{}");
210+
var returnUrl = presentationJson.RootElement.TryGetProperty("return_url", out var rProp) ? rProp.GetString() : "";
211+
212+
var customClaim = token.Claims.FirstOrDefault(c => c.Type == "https://purl.imsglobal.org/spec/lti/claim/custom");
213+
var customJson = JsonDocument.Parse(customClaim?.Value ?? "{}");
214+
215+
string toolTaskId = null;
216+
if (customJson.RootElement.TryGetProperty("internal_task_id", out var idProp))
217+
{
218+
toolTaskId = idProp.GetString();
219+
}
220+
221+
if (string.IsNullOrEmpty(toolTaskId))
222+
{
223+
var resourceLinkClaim = token.Claims.FirstOrDefault(c => c.Type == "https://purl.imsglobal.org/spec/lti/claim/resource_link");
224+
toolTaskId = JsonDocument.Parse(resourceLinkClaim?.Value ?? "{}").RootElement.GetProperty("id").GetString();
225+
}
226+
227+
var currentTask = AvailableTasks.FirstOrDefault(t => t.Id == toolTaskId);
228+
229+
var scoreToDisplay = currentTask?.Score ?? 0;
230+
var titleToDisplay = currentTask?.Title ?? $"Task ID: {toolTaskId} (Not Found)";
231+
var descToDisplay = currentTask?.Description ?? "Description not available";
232+
233+
var agsClaim = token.Claims.FirstOrDefault(c => c.Type == "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint");
234+
var lineItemUrl = JsonDocument.Parse(agsClaim?.Value ?? "{}").RootElement.GetProperty("lineitem").GetString();
235+
236+
var html = $@"
237+
<html>
238+
<body style='text-align: center; padding: 50px; font-family: sans-serif;'>
239+
<h1>Performing: {titleToDisplay}</h1>
240+
<p>{descToDisplay}</p>
241+
<form action='/api/mocktool/send-score' method='POST'>
242+
<input type='hidden' name='lineItemUrl' value='{lineItemUrl}' />
243+
<input type='hidden' name='userId' value='{token.Subject}' />
244+
<input type='hidden' name='platformIss' value='{token.Issuer}' />
245+
<!-- Отправляем именно наш внутренний ID -->
246+
<input type='hidden' name='taskId' value='{toolTaskId}' />
247+
<input type='hidden' name='returnUrl' value='{returnUrl}' />
248+
249+
<button type='submit' style='background: #007bff; color: white; padding: 15px 30px; font-size: 18px; border: none; border-radius: 5px; cursor: pointer;'>
250+
Submit solution for {scoreToDisplay} points
251+
</button>
252+
</form>
253+
</body>
254+
</html>";
255+
256+
return Content(html, "text/html");
257+
}
258+
259+
[HttpPost("send-score")]
260+
public async Task<IActionResult> SendScore(
261+
[FromForm] string lineItemUrl, [FromForm] string userId,
262+
[FromForm] string platformIss, [FromForm] string taskId, [FromForm] string returnUrl)
263+
{
264+
var currentTask = AvailableTasks.FirstOrDefault(t => t.Id == taskId);
265+
266+
if (currentTask == null)
267+
{
268+
return BadRequest($"Task with internal ID '{taskId}' not found in the tool database. (Check if DeepLinking passed custom params correctly)");
269+
}
270+
271+
var client = httpClientFactory.CreateClient();
272+
var clientAssertion = CreateClientAssertion(platformIss);
273+
274+
var tokenRequest = new Dictionary<string, string> {
275+
["grant_type"] = "client_credentials",
276+
["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
277+
["client_assertion"] = clientAssertion,
278+
["scope"] = "https://purl.imsglobal.org/spec/lti-ags/scope/score"
279+
};
280+
281+
var tokenResponse = await client.PostAsync($"{platformIss}/api/lti/token", new FormUrlEncodedContent(tokenRequest));
282+
if (!tokenResponse.IsSuccessStatusCode) return BadRequest($"Error retrieving token from {platformIss}");
283+
284+
var tokenContent = await tokenResponse.Content.ReadAsStringAsync();
285+
var accessToken = JsonDocument.Parse(tokenContent).RootElement.GetProperty("access_token").GetString();
286+
287+
var scoreObj = new Score {
288+
UserId = userId,
289+
ScoreGiven = currentTask.Score,
290+
ScoreMaximum = currentTask.Score,
291+
Comment = $"Excellent! Task '{currentTask.Title}' completed.",
292+
GradingProgress = GradingProgress.FullyGraded,
293+
ActivityProgress = ActivityProgress.Completed,
294+
TimeStamp = DateTime.UtcNow
295+
};
296+
297+
var scoreRequest = new HttpRequestMessage(HttpMethod.Post, $"{lineItemUrl}/scores") {
298+
Content = new StringContent(JsonSerializer.Serialize(scoreObj), System.Text.Encoding.UTF8, "application/vnd.ims.lti-ags.v1.score+json")
299+
};
300+
scoreRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
301+
302+
var scoreResponse = await client.SendAsync(scoreRequest);
303+
304+
var statusColor = scoreResponse.IsSuccessStatusCode ? "green" : "red";
305+
var statusText = scoreResponse.IsSuccessStatusCode
306+
? $"Score of {currentTask.Score} successfully submitted!"
307+
: $"Error submitting score: {scoreResponse.StatusCode}";
308+
309+
var html = $@"
310+
<html>
311+
<head>
312+
<style>
313+
body {{ font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f4f7f6; }}
314+
.card {{ background: white; padding: 40px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }}
315+
h2 {{ margin: 0 0 10px; color: {statusColor}; }}
316+
p {{ color: #666; margin-bottom: 30px; }}
317+
.btn {{ text-decoration: none; background: #007bff; color: white; padding: 12px 24px; border-radius: 6px; font-weight: bold; }}
318+
</style>
319+
<meta http-equiv='refresh' content='3;url={returnUrl}'>
320+
</head>
321+
<body>
322+
<div class='card'>
323+
<h2>{statusText}</h2>
324+
<p>You will be redirected back to HwProj in 3 seconds...</p>
325+
<a href='{returnUrl}' class='btn'>Return Now</a>
326+
</div>
327+
</body>
328+
</html>";
329+
330+
return Content(html, "text/html");
331+
}
332+
333+
private static string CreateClientAssertion(string platformIssuer)
334+
{
335+
var claims = new List<Claim> {
336+
new(JwtRegisteredClaimNames.Iss, ToolIss),
337+
new(JwtRegisteredClaimNames.Sub, ToolNameId),
338+
new(JwtRegisteredClaimNames.Aud, $"{platformIssuer}/api/lti/token"),
339+
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
340+
new(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
341+
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
342+
};
343+
344+
var jwt = new JwtSecurityToken(
345+
header: new JwtHeader(new SigningCredentials(SigningKey, SecurityAlgorithms.RsaSha256)),
346+
payload: new JwtPayload(claims)
347+
);
348+
349+
return new JwtSecurityTokenHandler().WriteToken(jwt);
350+
}
351+
}
352+
#endif

HwProj.APIGateway/HwProj.APIGateway.API/appsettings.json

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,30 @@
2525
"AssignmentsGradesEndpoint": "http://localhost:5000/api/lti/lineItem",
2626
"AccessTokenUrl": "http://localhost:5000/api/lti/token",
2727
"SigningKey": {
28-
"KeyId": "",
29-
"PrivateKeyPem": ""
28+
"KeyId": "2026-03-07",
29+
"PrivateKeyPem": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAz5uLGcWFsKz5o+wWVvZ2NZXsScVY9GFn3G9/iaccueGwpVJB\nfU+J/cE9hfNPiM/8+BIoaYH9OsyiChGldqWY8WmmnHakvN/zEB9vdLaxHdgIAxC4\nZ+kHF5O5VBqitydBLZvOYMuBaswqMxf4MABJeXX9e+uTT1mZnbNDpRZAnhTFByJ+\nuwKs83l2VYbKG3XLwZZxVH3RCOi/TNwybRdtnOfAhiOSsTiYlOH2IYucc7Aakwuu\nDgwvv+pJC7N3ekyZQwuYf3A0zc2kiFdyLdwyZ3cosd94Vdgj3TD0YhZZ9esAXisC\nMS4diRpm0hNWNO0jn74pdLIjFHaE6Xp+/G6rEQIDAQABAoIBABhzwotzh06K4RBc\nkzkE6GFhWiZKNzL5cgk1nLjy1OBT48FlEc+XmbIom312bey4SpxRTy82H0RYq9Ex\nyOJTmNL+VaBiHP8eBXvlp/QAPJY+RptN0dpzSOGPBaoRRQ77caLUkhc2gPS6PVIt\nRY8pyX2j1wpMcdpLvFslrRb5qvyzUT8KlAwwoFk0mwQSilQCYhrX+lxXU6Y08yVw\nU7Ejj9EP+O9ReBdVMZDJxZbL42bTl99QW9X1BZ0OXPoBW5CxksFZLSs6ydJx0kr0\n3JQTjTExssiiybBHGGl3DhFs4t/SST/TuX8AB7Tmr1e12zR56IU9vWbsY5Jpo5rB\nopFGfW0CgYEA5p7ZkJ7IZjGUhrwPhnOcfvm0GnYm/G2pMthpKblkFQIO8I0Gf9k5\n31s6t9Dms0z2NoCfKjJi/RUr1kar/YSURTEP3ecA1oyVPrVrV9bH3FMBYJLJh7Yz\nLITDMRAD+ApYA3VL/hVxsq+agsXfyUYlLZOJ8o8lmlkeafzT6RZQqUMCgYEA5nRc\n2/oLoPA6KZUaUqlAVuv2/ootTu8it3T23DZcp4zCQqXStEwQEnj8smgTcV55/foS\nu5CMD2j6isTRsnieDAFnMwD45sD7v3FhDs0Cc5JskAdJmaE+VMCeEQsRNjfymZwx\n2e6DyAuZSXxsw0dtWsw4XzQGKqZHBlDd/k4WWxsCgYEA3Sz5kXaG0WO5g2J3LUZR\nj4FhloM8LpnpTKc6bFatwmwf8dn+orytgSXYcZP6vXkRJQJEI88BGqGkUjOjHVd6\nb8V25yV1q05WkDajxTFqqkY9KuZ8OxiliYumO7aVZ9xbvq1O/VaJnYpGkCa/0iPg\n4g3+nc9li9rujU152rCZGUsCgYADh8zUYeRDtuptMKeSlJ0zt7G0/JDtIKS7gsM1\nZG/O8U0YkEnGEVQ9tDTK1uVVW0krJuWakgBTTBxqe9FqloZ1UKAwG9e0UUiKCkae\nX22mL5wSKMpr3BiEW98QC8dbuUeyKr5oxEqoieTzR0CzTSjTt0U10Co4BQwZgKul\n9bRJ+QKBgBpT52mX802Bb3Pyd3dQ/Y1TMeRLEmOBHra1WX2ZkKHBT4D3462lML0g\nNihS9Nby/UzGxkoU1xyX+nvSNh63vTBJXlrSqK3SsgT20E1eiEJuvSbc6YSKsOhn\nocDKBaq1cSlVTxh3ukHTp2hSdArgDELjbfdOPgijOo83Auu6guFC\n-----END RSA PRIVATE KEY-----"
3030
}
3131
},
3232
"LtiTools": [
33+
{
34+
"id": 2,
35+
"name": "Miminet",
36+
"Issuer": "http://localhost:80",
37+
"clientId": "miminet123",
38+
"JwksEndpoint": "http://localhost:80/lti/jwks",
39+
"initiateLoginUri": "http://localhost:80/lti/login",
40+
"launchUrl": "http://localhost:80/lti/launch",
41+
"deepLinking": "http://localhost:80/lti/launch"
42+
},
3343
{
3444
"id": 1,
35-
"name": "",
36-
"Issuer": "",
37-
"clientId": "",
38-
"JwksEndpoint": "",
39-
"initiateLoginUri": "",
40-
"launchUrl": "",
41-
"deepLinking": ""
45+
"name": "Local Mock Tool",
46+
"Issuer": "Local Mock Tool",
47+
"clientId": "mock-tool-client-id",
48+
"JwksEndpoint": "http://localhost:5000/api/mocktool/jwks",
49+
"initiateLoginUri": "http://localhost:5000/api/mocktool/login",
50+
"launchUrl": "http://localhost:5000/api/mocktool/callback",
51+
"deepLinking": "http://localhost:5000/api/mocktool/callback"
4252
}
4353
]
4454
}

0 commit comments

Comments
 (0)