Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
e67db88
fix: made it possible to run on macOS
KirillBorisovich Nov 30, 2025
6d1c5c3
Merge branch 'InteIIigeNET:master' into master
KirillBorisovich Feb 24, 2026
3a85b53
Merge branch 'InteIIigeNET:master' into master
KirillBorisovich Mar 22, 2026
1b094a4
Merge branch 'InteIIigeNET:master' into master
KirillBorisovich Sep 4, 2026
89c404d
feat: made a controller for OIDC
KirillBorisovich Nov 30, 2025
1e165af
feat: added a repository for storing the connection between HomeworkT…
KirillBorisovich Dec 4, 2025
43f762b
feat: добавил возможность выбора LtiTool при создании курса
KirillBorisovich Dec 5, 2025
fb37842
feat: почти доделал deeplinking
KirillBorisovich Jan 3, 2026
6045ae5
feat: made front-end support for deeplinking
KirillBorisovich Jan 4, 2026
24703c9
feat: migrated the database
KirillBorisovich Jan 4, 2026
802e030
feat: debugged the deeplinking implementation and added it to the cor…
KirillBorisovich Jan 6, 2026
68ad8dd
fix: fixed a bug with the presence of ltiLaunchUrl in the server resp…
KirillBorisovich Jan 6, 2026
643084b
refactor: deleted unnecessary folders
KirillBorisovich Jan 6, 2026
47bc3ed
feat: did deeplinking
KirillBorisovich Jan 6, 2026
44b9847
feat: made it possible to take into account the maximum score when im…
KirillBorisovich Jan 7, 2026
63c4380
feat: support test launches through LTI. Final grading is not included.
KirillBorisovich Feb 18, 2026
3e182ca
feat: added functionality for LTI tools to report scores
KirillBorisovich Feb 23, 2026
8abad5f
refactor: deleted MockToolController
KirillBorisovich Feb 23, 2026
75fb055
fix: enabled notifications
KirillBorisovich Feb 23, 2026
0385b14
fix and refactor: fixed a bug in the signature verification in LtiDee…
KirillBorisovich Feb 23, 2026
1642f78
feat: added a pop-up window to warn you before you start testing
KirillBorisovich Feb 24, 2026
35909d1
fix: added a parameter required for the protocol
KirillBorisovich Mar 1, 2026
ce6fa6c
refactor: added appsettings.json
KirillBorisovich Mar 1, 2026
13d7e74
refactor: updated appsettings.json and deleted MockToolController
KirillBorisovich Mar 2, 2026
79c6fd6
feat: added LtiMockTool
KirillBorisovich Mar 20, 2026
bf74ece
refactor: made almost all the necessary edits
KirillBorisovich Mar 21, 2026
f2ff27d
refactor: implement ForeignKey relationship for HomeworkTaskLtiLaunch…
KirillBorisovich Mar 22, 2026
90d6571
refactor: update appsettings.json
KirillBorisovich Mar 22, 2026
62933e8
refactor: returned the appsettings.json
KirillBorisovich Mar 22, 2026
147757a
refactor: improved the structure in LtiKeyServices and HomeworkContro…
KirillBorisovich Apr 25, 2026
db330fb
refactor: changed the EventBus credits, combined the sending and eval…
KirillBorisovich Apr 25, 2026
38cc5fb
fix: Improved the LTI tool response parser and mitigated XSS vulnerab…
KirillBorisovich Apr 26, 2026
5194237
refactor: moved the logic of filling tasks with LTI information to Ta…
KirillBorisovich Apr 28, 2026
bd8800b
fix: fixed an inaccuracy in the protocol implementation
KirillBorisovich Jun 8, 2026
8f54198
feat: added lti bot initialization
KirillBorisovich Aug 2, 2026
4cfacf4
Revert "feat: added lti bot initialization"
KirillBorisovich Aug 15, 2026
0c41765
feat: implement obtaining or creating an LTI bot by ClientID
KirillBorisovich Aug 15, 2026
708f851
feat: added automatic creation of an LTI bot and its appointment as a…
KirillBorisovich Aug 15, 2026
b4e35c2
refactor: duplication of LTI logic has been removed and grading on be…
KirillBorisovich Aug 15, 2026
51377af
feat: added verification of assignment binding to the LTI tool before…
KirillBorisovich Aug 16, 2026
0507a35
refactor: remove redundant internal wrappers from course and solution…
KirillBorisovich Aug 16, 2026
67141c3
refactor: update code based on review comments
KirillBorisovich Sep 4, 2026
a3cd712
Fix LTI target link handling and add fallback for missing content ite…
KirillBorisovich Sep 4, 2026
3969262
fix: add missing collections import after rebase
KirillBorisovich Sep 9, 2026
33e37ce
fix: restore LTI score submission and imported task validation
KirillBorisovich Sep 9, 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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Net;
using System.Threading.Tasks;
using AutoMapper;
using HwProj.APIGateway.API.Lti.Services;
using HwProj.APIGateway.API.Models;
using HwProj.AuthService.Client;
using HwProj.CoursesService.Client;
Expand All @@ -22,16 +23,19 @@ namespace HwProj.APIGateway.API.Controllers;
public class CoursesController : AggregationController
{
private readonly ICoursesServiceClient _coursesClient;
private readonly ILtiToolService _ltiToolService;
private readonly IMapper _mapper;
private readonly IStudentsInformationProvider _studentsInfo;

public CoursesController(
ICoursesServiceClient coursesClient,
ILtiToolService ltiToolService,
IAuthServiceClient authServiceClient,
IMapper mapper,
IStudentsInformationProvider studentsInfo) : base(authServiceClient)
{
_coursesClient = coursesClient;
_ltiToolService = ltiToolService;
_mapper = mapper;
_studentsInfo = studentsInfo;
}
Expand Down Expand Up @@ -103,6 +107,40 @@ public async Task<IActionResult> GetProgramNames()
[ProducesResponseType(typeof(long), (int)HttpStatusCode.OK)]
public async Task<IActionResult> CreateCourse(CreateCourseViewModel model)
{
AccountDataDto? ltiBot = null;

if (!string.IsNullOrWhiteSpace(model.LtiToolName))
{
var ltiTool = _ltiToolService.GetByName(model.LtiToolName);
if (ltiTool == null)
{
return BadRequest($"LTI-инструмент '{model.LtiToolName}' не найден");
}

var botResult = await AuthServiceClient.GetOrCreateLtiBot(ltiTool.ClientId);
if (!botResult.Succeeded)
{
return StatusCode(
(int)HttpStatusCode.ServiceUnavailable,
botResult.Errors);
}

ltiBot = botResult.Value;
if (ltiBot == null)
{
return StatusCode(
(int)HttpStatusCode.InternalServerError,
"LTI-бот был создан, но его данные не найдены");
}

if (ltiBot.Role != Roles.ExpertRole)
{
return StatusCode(
(int)HttpStatusCode.InternalServerError,
"LTI-бот не имеет роли Expert");
}
}

if (model.GroupNames.Any() && model.FetchStudents)
{
var studentCandidates = new List<StudentModel>();
Expand Down Expand Up @@ -138,9 +176,33 @@ public async Task<IActionResult> CreateCourse(CreateCourseViewModel model)
}

var result = await _coursesClient.CreateCourse(model);
return result.Succeeded
? Ok(result.Value)
: BadRequest(result.Errors);
if (!result.Succeeded)
{
return BadRequest(result.Errors);
}

if (ltiBot != null)
{
var addExpertResult = await _coursesClient.AcceptLecturer(
result.Value,
ltiBot.Email,
ltiBot.UserId,
sendNotification: false);

if (!addExpertResult.Succeeded)
{
return StatusCode(
(int)HttpStatusCode.InternalServerError,
new
{
CourseId = result.Value,
Errors = addExpertResult.Errors,
Message = "Курс создан, но LTI-бот не был добавлен как эксперт"
});
}
}

return Ok(result.Value);
}

[HttpPost("update/{courseId}")]
Expand Down Expand Up @@ -310,7 +372,8 @@ private async Task<CourseViewModel> ToCourseViewModel(CourseDTO course)
Homeworks = course.Homeworks,
Groups = course.Groups,
IsCompleted = course.IsCompleted,
IsOpen = course.IsOpen
IsOpen = course.IsOpen,
LtiToolName = course.LtiToolName,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,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="3.0.0" />
<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
{
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
{
public string KeyId { get; set; }
public string PrivateKeyPem { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace HwProj.APIGateway.API.Lti.Configuration
{
public class LtiToolConfig
{
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,113 @@
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 HwProj.AuthService.Client;
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,
IAuthServiceClient authServiceClient
) : 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.Issuer;

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 = tool.ClientId,

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 botResult = await authServiceClient.GetOrCreateLtiBot(tool.ClientId);
if (!botResult.Succeeded || botResult.Value == null)
{
return StatusCode(StatusCodes.Status500InternalServerError, new
{
error = "server_error",
error_description = "Failed to resolve the LTI bot account."
});
}

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

return Ok(new
{
access_token = accessToken,
token_type = "Bearer",
expires_in = 3600,
scope
});
}
}
Loading
Loading