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
0 commit comments