Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e67db88
fix: made it possible to run on macOS
KirillBorisovich Nov 30, 2025
e96c761
feat: made a controller for OIDC
KirillBorisovich Nov 30, 2025
fac5635
feat: added a repository for storing the connection between HomeworkT…
KirillBorisovich Dec 4, 2025
e0ce973
feat: добавил возможность выбора LtiTool при создании курса
KirillBorisovich Dec 5, 2025
6d1c5c3
Merge branch 'InteIIigeNET:master' into master
KirillBorisovich Feb 24, 2026
6dda568
feat: почти доделал deeplinking
KirillBorisovich Jan 3, 2026
aed3452
feat: made front-end support for deeplinking
KirillBorisovich Jan 4, 2026
a74f3f0
feat: migrated the database
KirillBorisovich Jan 4, 2026
47455bc
feat: debugged the deeplinking implementation and added it to the cor…
KirillBorisovich Jan 6, 2026
f02fd06
fix: fixed a bug with the presence of ltiLaunchUrl in the server resp…
KirillBorisovich Jan 6, 2026
a21edf9
refactor: deleted unnecessary folders
KirillBorisovich Jan 6, 2026
1a92b69
feat: did deeplinking
KirillBorisovich Jan 6, 2026
a83f718
feat: made it possible to take into account the maximum score when im…
KirillBorisovich Jan 7, 2026
548e1e3
feat: support test launches through LTI. Final grading is not included.
KirillBorisovich Feb 18, 2026
22b513d
feat: added functionality for LTI tools to report scores
KirillBorisovich Feb 23, 2026
8db5f7d
refactor: deleted MockToolController
KirillBorisovich Feb 23, 2026
5233e52
fix: enabled notifications
KirillBorisovich Feb 23, 2026
55c2f76
fix and refactor: fixed a bug in the signature verification in LtiDee…
KirillBorisovich Feb 23, 2026
7ec4f6a
feat: added a pop-up window to warn you before you start testing
KirillBorisovich Feb 24, 2026
16a57bc
fix: added a parameter required for the protocol
KirillBorisovich Mar 1, 2026
4075fde
refactor: added appsettings.json
KirillBorisovich Mar 1, 2026
d13ff7b
Merge branch master
KirillBorisovich Mar 2, 2026
03ee496
refactor: updated appsettings.json and deleted MockToolController
KirillBorisovich Mar 2, 2026
7d2c0fb
feat: added LtiMockTool
KirillBorisovich Mar 20, 2026
cb113d1
refactor: made almost all the necessary edits
KirillBorisovich Mar 21, 2026
cb091b0
refactor: implement ForeignKey relationship for HomeworkTaskLtiLaunch…
KirillBorisovich Mar 22, 2026
efe2b8f
refactor: update appsettings.json
KirillBorisovich Mar 22, 2026
b9efaf0
refactor: returned the appsettings.json
KirillBorisovich Mar 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,4 @@ StyleCop.Cache
swagger-codegen
hwproj.front/static_dist/
hwproj.front/dist/
.DS_Store
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ private async Task<CourseViewModel> ToCourseViewModel(CourseDTO course)
NewStudents = newStudents.ToArray(),
Homeworks = course.Homeworks,
IsCompleted = course.IsCompleted,
IsOpen = course.IsOpen
IsOpen = course.IsOpen,
LtiToolName = course.LtiToolName,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ public async Task<IActionResult> GetStudentSolution(long taskId, string studentI
return Ok(new UserTaskSolutionsPageData
{
CourseId = course.Id,
LtiToolName = course.LtiToolName,
CourseMates = accounts,
TaskSolutions = taskSolutions
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="LtiAdvantage" Version="2.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.20" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="AutoMapper" Version="15.0.1" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace HwProj.APIGateway.API.Lti.Configuration;

public class LtiPlatformConfig
Copy link
Copy Markdown
Contributor

@DedSec256 DedSec256 Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public class LtiPlatformConfig
internal class LtiPlatformConfig

Copy link
Copy Markdown
Contributor Author

@KirillBorisovich KirillBorisovich Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А нужно ли? Я в конструкторах использую часто где IOptions options. Чтобы сделать внутренним, мне нужно пренести всю логику в сервисы и сделать их (сервисы) все внутренними, нужно ли так делать? Просто больше в проекте нигде так не делается

{
public string Issuer { get; set; }
public string OidcAuthorizationEndpoint { get; set; }
public string DeepLinkReturnUrl { get; set; }
public string ResourceLinkReturnUrl { get; set; }
public string AssignmentsGradesEndpoint { get; set; }
public string AccessTokenUrl { get; set; }
public string JwksEndpoint { get; set; }
public LtiSigningKeyConfig SigningKey { get; set; }
}

public class LtiSigningKeyConfig
Copy link
Copy Markdown
Contributor

@DedSec256 DedSec256 Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public class LtiSigningKeyConfig
internal class LtiSigningKeyConfig

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

см. ответ сверху

{
public string KeyId { get; set; }
public string PrivateKeyPem { get; set; }
}
Comment thread
KirillBorisovich marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace HwProj.APIGateway.API.Lti.Configuration
{
public class LtiToolConfig
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public class LtiToolConfig
internal class LtiToolConfig

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

см. ответ сверху

{
public string Name { get; set; }
public string Issuer { get; set; }
public string ClientId { get; set; }
public string JwksEndpoint { get; set; }
public string InitiateLoginUri { get; set; }
public string LaunchUrl { get; set; }
public string DeepLink { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Security.Cryptography;
using HwProj.APIGateway.API.Lti.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace HwProj.APIGateway.API.Lti.Controllers;

[Route("api/lti")]
[ApiController]
public class JwksController(IOptions<LtiPlatformConfig> options) : ControllerBase
{
private readonly LtiPlatformConfig _config = options.Value;

[HttpGet("jwks")]
[AllowAnonymous]
public IActionResult GetJwks()
{
var keyConfig = _config.SigningKey;

if (string.IsNullOrEmpty(keyConfig?.PrivateKeyPem))
{
return StatusCode(500, "Signing key is not configured.");
}

using var rsa = RSA.Create();
try
{
rsa.ImportFromPem(keyConfig.PrivateKeyPem);
}
catch (CryptographicException)
{
return StatusCode(500, "Invalid Private Key format in configuration.");
}

var publicParams = rsa.ExportParameters(false);

var jwks = new
{
keys = new[]
{
new
{
kty = "RSA",
e = Base64UrlEncoder.Encode(publicParams.Exponent),
n = Base64UrlEncoder.Encode(publicParams.Modulus),
kid = keyConfig.KeyId,
alg = "RS256",
use = "sig"
}
}
};

return Ok(jwks);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Threading.Tasks;
using HwProj.APIGateway.API.Lti.Configuration;
using HwProj.APIGateway.API.Lti.Services;
using HwProj.APIGateway.API.LTI.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace HwProj.APIGateway.API.Lti.Controllers;

[Route("api/lti")]
[ApiController]
public class LtiAccessTokenController(
IOptions<LtiPlatformConfig> options,
ILtiToolService toolService,
ILtiKeyService ltiKeyService,
ILtiTokenService tokenService
) : ControllerBase
{
[HttpPost("token")]
[AllowAnonymous]
public async Task<IActionResult> GetTokenAsync([FromForm] IFormCollection form)
{
if (!form.TryGetValue("grant_type", out var grantType) || grantType != "client_credentials")
{
return BadRequest(new { error = "unsupported_grant_type", error_description = "Only 'client_credentials' is supported." });
}

if (!form.TryGetValue("client_assertion_type", out var assertionType) ||
assertionType != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
{
return BadRequest(new { error = "invalid_request", error_description = "Invalid client_assertion_type." });
}

if (!form.TryGetValue("client_assertion", out var clientAssertion))
{
return BadRequest(new { error = "invalid_request", error_description = "Missing client_assertion." });
}

var handler = new JwtSecurityTokenHandler();
if (!handler.CanReadToken(clientAssertion))
{
return BadRequest(new { error = "invalid_client", error_description = "Invalid JWT structure." });
}

var unverifiedToken = handler.ReadJwtToken(clientAssertion);

var clientId = unverifiedToken.Subject;

var tool = toolService.GetByClientId(clientId);
if (tool == null)
{
return Unauthorized(new { error = "invalid_client", error_description = $"Unknown clientId: {clientId}" });
}

var signingKeys = await ltiKeyService.GetKeysAsync(tool.JwksEndpoint);

try
{
var tokenEndpointUrl = options.Value.AccessTokenUrl;

handler.ValidateToken(clientAssertion, new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = unverifiedToken.Issuer,

ValidateAudience = true,
ValidAudience = tokenEndpointUrl,

ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(5),

ValidateIssuerSigningKey = true,
IssuerSigningKeys = signingKeys
}, out _);
}
catch (Exception ex)
{
return Unauthorized(new { error = "invalid_client", error_description = $"Token validation failed: {ex.Message}" });
}

const string scope = "https://purl.imsglobal.org/spec/lti-ags/scope/score";

var accessToken = tokenService.GenerateAccessTokenForLti(tool.ClientId, scope);

return Ok(new
{
access_token = accessToken,
token_type = "Bearer",
expires_in = 3600,
scope
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using HwProj.APIGateway.API.Lti.Services;
using HwProj.CoursesService.Client;
using HwProj.Models.SolutionsService;
using HwProj.SolutionsService.Client;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LtiAdvantage.AssignmentGradeServices;

namespace HwProj.APIGateway.API.Lti.Controllers;

[Route("api/lti")]
[ApiController]
[Authorize(AuthenticationSchemes = "LtiScheme")]
public class LtiAssignmentsGradesControllers(
ICoursesServiceClient coursesServiceClient,
ISolutionsServiceClient solutionsClient,
ILtiToolService toolService)
: ControllerBase
{
[HttpPost("lineItem/{taskId}/scores")]
[Consumes("application/json", "application/vnd.ims.lis.v1.score+json")]
public async Task<IActionResult> UpdateTaskScore(long taskId, [FromBody] Score score)
{
var scopeClaim = User.FindFirst("scope")?.Value;
if (string.IsNullOrEmpty(scopeClaim) || !scopeClaim.Contains("https://purl.imsglobal.org/spec/lti-ags/scope/score"))
{
return Forbid();
}

var toolClientId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value;

if (string.IsNullOrEmpty(toolClientId))
{
return Unauthorized("Unknown tool client id.");
}

var tool = toolService.GetByClientId(toolClientId);
if (tool == null)
{
return BadRequest("Tool not found.");
}

var course = await coursesServiceClient.GetCourseByTaskForLti(taskId, score.UserId);
if (course == null)
{
return BadRequest("The task does not belong to any course.");
}

if (course.LtiToolName != tool.Name)
{
return BadRequest("This tool does not apply to this course.");
}

if (score.ScoreGiven < 0 || score.ScoreGiven > score.ScoreMaximum)
{
return BadRequest("ScoreGiven must be between 0 and ScoreMaximum.");
}

try
{
await this.SetTaskGrade(taskId, score);
return Ok(new { message = "Score updated successfully" });
}
catch (KeyNotFoundException ex)
{
return NotFound(ex.Message);
}
catch (Exception)
{
return StatusCode(500, "Internal Server Error");
}
}

private async Task SetTaskGrade(long taskId, Score score)
{
var postSolutionModel = new PostSolutionModel
{
StudentId = score.UserId,
LecturerComment = score.Comment,
Rating = (int)Math.Round(score.ScoreGiven)
Comment on lines +84 to +85
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Давай добавим оценку в комментарий

};


var solutionId = await solutionsClient.PostSolutionForLti(taskId, postSolutionModel);

var rate = new RateSolutionModel
{
Rating = (int)Math.Round(score.ScoreGiven),
LecturerComment = score.Comment
};

await solutionsClient.RateSolutionForLti(solutionId, rate);
Comment on lines +89 to +97
Copy link
Copy Markdown
Contributor

@DedSec256 DedSec256 Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можно объединить в один запрос/метод

}
}
Loading