Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
701abe3
chore: ignore .worktrees/ directory
srijken May 3, 2026
56ef651
feat(sr): add Submission Review message type and for_user claim const…
srijken May 3, 2026
198ac81
feat(sr): add LtiSubmissionReviewRequest and ForUserClaimValueType
srijken May 3, 2026
7170c2b
test(sr): add reference-JSON round-trip for LtiSubmissionReviewRequest
srijken May 3, 2026
57fa05f
feat(sr): expose submissionReview extension on AGS LineItem
srijken May 3, 2026
f2d0bdb
feat(jwks): add IJwksKeyStore abstraction and Jwks endpoint constants
srijken May 3, 2026
bb4cb5f
feat(jwks): add JwksControllerBase publishing public keys at /.well-k…
srijken May 3, 2026
04c93bd
test(jwks): assert no private-key fields in JWKS response; log entry/…
srijken May 3, 2026
8c5716e
docs(jwks): document key-rotation contract on IJwksKeyStore
srijken May 3, 2026
5fa2951
feat(dynreg): add Dynamic Registration claim and scope constants
srijken May 3, 2026
59f307e
feat(dynreg): add PlatformOpenIdConfiguration and supporting models
srijken May 3, 2026
b205bf9
feat(dynreg): add ToolConfiguration registration request model
srijken May 3, 2026
ccdd585
feat(dynreg): tool-side HttpClient extensions for OIDC discovery and …
srijken May 3, 2026
07f5b31
feat(dynreg): flow cancellation token + surface platform error body
srijken May 3, 2026
cd2d767
feat(dynreg): platform-side DynamicRegistrationControllerBase
srijken May 3, 2026
cfe8952
docs: announce Submission Review, JWKS, Dynamic Registration
srijken May 3, 2026
12b9e41
docs: regenerate XML doc files for Tier 1 changes
srijken May 3, 2026
bb05069
docs: link new features to LTI spec URLs in README
srijken May 3, 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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -258,4 +258,7 @@ paket-files/

# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
*.pyc

# Git worktrees
.worktrees/
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
- [LtiAdvantage.IntegrationTests](https://github.com/LtiLibrary/LtiAdvantage/tree/master/test/LtiAdvantage.IntegrationTests) integration tests.
- [LtiAdvantage.UnitTests](https://github.com/LtiLibrary/LtiAdvantage/tree/master/test/LtiAdvantage.UnitTests) unit tests.

### What's new

- [LTI Submission Review 1.0](https://www.imsglobal.org/spec/lti-sr/v1p0) (`LtiSubmissionReviewRequest`, `for_user` claim, AGS `submissionReview` extension)
- [JWKS publishing](https://www.imsglobal.org/spec/security/v1p0#tool-jwk-set-url) (`JwksControllerBase` + `IJwksKeyStore`) for `/.well-known/jwks.json`
- [LTI Dynamic Registration 1.0](https://www.imsglobal.org/spec/lti-dr/v1p0) — tool-side `HttpClient` extensions and platform-side `DynamicRegistrationControllerBase`

## NuGet
| Library | Release | Prerelease |
| --- | --- | --- |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using LtiAdvantage.DynamicRegistration;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace LtiAdvantage.AspNetCore.DynamicRegistration
{
/// <summary>
/// Platform-side endpoint for LTI Dynamic Registration 1.0.
/// See https://www.imsglobal.org/spec/lti-dr/v1p0/#registration-endpoint
/// </summary>
[ApiController]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public abstract class DynamicRegistrationControllerBase : ControllerBase, IDynamicRegistrationController
{
private readonly IWebHostEnvironment _env;
private readonly ILogger<DynamicRegistrationControllerBase> _logger;

/// <summary>Initializes the base.</summary>
protected DynamicRegistrationControllerBase(IWebHostEnvironment env, ILogger<DynamicRegistrationControllerBase> logger)
{
_env = env;
_logger = logger;
}

/// <summary>
/// Persist the registration. Implementations MUST set <see cref="ToolConfiguration.ClientId"/>
/// on <c>request.Tool</c> (or return a new instance) before returning.
/// </summary>
protected abstract Task<ActionResult<ToolConfiguration>> OnRegisterAsync(RegisterToolRequest request);

/// <inheritdoc />
[HttpPost]
[Consumes("application/json")]
[Produces("application/json")]
[ProducesResponseType(typeof(ToolConfiguration), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme,
Policy = Constants.LtiScopes.DynamicRegistration.Scope)]
[Route("lti/register", Name = "lti-dynamic-registration")]
public async Task<ActionResult<ToolConfiguration>> RegisterAsync([Required] [FromBody] ToolConfiguration tool)
{
try
{
_logger.LogDebug($"Entering {nameof(RegisterAsync)}.");
var result = await OnRegisterAsync(new RegisterToolRequest(tool)).ConfigureAwait(false);
if (result.Result is ObjectResult o && o.StatusCode == null) o.StatusCode = StatusCodes.Status201Created;
else if (result.Value != null && result.Result == null) return Created(string.Empty, result.Value);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"An unexpected error occurred in {nameof(RegisterAsync)}.");
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
{
Title = "An unexpected error occurred",
Status = StatusCodes.Status500InternalServerError,
Detail = _env.IsDevelopment() ? ex.Message + ex.StackTrace : ex.Message
});
}
finally
{
_logger.LogDebug($"Exiting {nameof(RegisterAsync)}.");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Threading.Tasks;
using LtiAdvantage.DynamicRegistration;
using Microsoft.AspNetCore.Mvc;

namespace LtiAdvantage.AspNetCore.DynamicRegistration
{
/// <summary>The platform-side LTI Dynamic Registration endpoint.</summary>
public interface IDynamicRegistrationController
{
/// <summary>Registers a new tool. Returns 201 with the assigned <c>client_id</c> echoed back.</summary>
Task<ActionResult<ToolConfiguration>> RegisterAsync([FromBody] ToolConfiguration tool);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using LtiAdvantage.DynamicRegistration;

namespace LtiAdvantage.AspNetCore.DynamicRegistration
{
/// <summary>Request passed to the controller's <c>OnRegisterAsync</c> override.</summary>
public class RegisterToolRequest
{
/// <summary>Wraps the submitted tool configuration.</summary>
/// <param name="tool">The submitted tool configuration.</param>
public RegisterToolRequest(ToolConfiguration tool) => Tool = tool;

/// <summary>The submitted tool configuration. The override should populate <see cref="ToolConfiguration.ClientId"/>.</summary>
public ToolConfiguration Tool { get; }
}
}
13 changes: 13 additions & 0 deletions src/LtiAdvantage.AspNetCore/Jwks/IJwksController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;

namespace LtiAdvantage.AspNetCore.Jwks
{
/// <summary>JWKS publication endpoint.</summary>
public interface IJwksController
{
/// <summary>Returns the JSON Web Key Set.</summary>
Task<ActionResult<JsonWebKeySet>> GetJwksAsync();
}
}
55 changes: 55 additions & 0 deletions src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Threading.Tasks;
using LtiAdvantage.Jwks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;

namespace LtiAdvantage.AspNetCore.Jwks
{
/// <summary>
/// Publishes the platform's (or tool's) JWKS at a well-known URL so that
/// peers can verify signed JWTs. Anonymous; unauthenticated.
/// </summary>
[ApiController]
public abstract class JwksControllerBase : ControllerBase, IJwksController
{
private readonly IJwksKeyStore _keyStore;
private readonly ILogger<JwksControllerBase> _logger;

/// <summary>
/// Constructs a new <see cref="JwksControllerBase"/>.
/// </summary>
/// <param name="keyStore">The key store providing public keys to publish.</param>
/// <param name="logger">The logger.</param>
protected JwksControllerBase(IJwksKeyStore keyStore, ILogger<JwksControllerBase> logger)
{
_keyStore = keyStore;
_logger = logger;
}

/// <summary>
/// Returns the JSON Web Key Set containing the public keys used to verify
/// signed JWTs issued by this server.
/// </summary>
[HttpGet]
[Produces(Constants.MediaTypes.Jwks)]
[ProducesResponseType(typeof(JsonWebKeySet), StatusCodes.Status200OK)]
[Route(".well-known/jwks.json", Name = Constants.ServiceEndpoints.Jwks.JwksService)]
public async Task<ActionResult<JsonWebKeySet>> GetJwksAsync()
{
_logger.LogDebug($"Entering {nameof(GetJwksAsync)}.");
try
{
var keys = await _keyStore.GetPublicKeysAsync().ConfigureAwait(false);
var set = new JsonWebKeySet();
foreach (var k in keys) set.Keys.Add(k);
return set;
}
finally
{
_logger.LogDebug($"Exiting {nameof(GetJwksAsync)}.");
}
}
}
}
59 changes: 59 additions & 0 deletions src/LtiAdvantage.AspNetCore/LtiAdvantage.AspNetCore.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using LtiAdvantage.DynamicRegistration;

namespace LtiAdvantage.IdentityModel.Client
{
/// <summary>
/// HttpClient extensions implementing the tool side of LTI Dynamic Registration 1.0.
/// </summary>
public static class HttpClientDynamicRegistrationExtensions
{
/// <summary>
/// Fetches the platform's OpenID configuration document (<see cref="PlatformOpenIdConfiguration"/>).
/// </summary>
/// <param name="client">The HTTP client.</param>
/// <param name="openIdConfigurationUrl">The URL passed by the platform in the registration init launch.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
public static async Task<PlatformOpenIdConfiguration> GetPlatformOpenIdConfigurationAsync(
this HttpClient client, string openIdConfigurationUrl, CancellationToken cancellationToken = default)
{
if (client == null) throw new ArgumentNullException(nameof(client));
if (string.IsNullOrWhiteSpace(openIdConfigurationUrl))
throw new ArgumentException("URL is required", nameof(openIdConfigurationUrl));

using var response = await client.GetAsync(openIdConfigurationUrl, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new HttpRequestException(
$"Dynamic Registration failed: {(int)response.StatusCode} {response.ReasonPhrase}. Body: {errorBody}");
}
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<PlatformOpenIdConfiguration>(body);
}

/// <summary>
/// POSTs a tool registration request to the platform's <c>registration_endpoint</c>
/// using the bearer registration token supplied during the registration init launch.
/// Returns the platform-assigned <see cref="ToolConfiguration"/> (echoed config + <c>client_id</c>).
/// </summary>
/// <param name="client">The HTTP client.</param>
/// <param name="registrationEndpoint">The platform's registration endpoint URL.</param>
/// <param name="registrationAccessToken">Bearer token from the registration init launch.</param>
/// <param name="tool">The tool configuration to register.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
public static async Task<ToolConfiguration> RegisterToolAsync(
this HttpClient client,
string registrationEndpoint,
string registrationAccessToken,
ToolConfiguration tool,
CancellationToken cancellationToken = default)
{
if (client == null) throw new ArgumentNullException(nameof(client));
if (string.IsNullOrWhiteSpace(registrationEndpoint))
throw new ArgumentException("URL is required", nameof(registrationEndpoint));
if (string.IsNullOrWhiteSpace(registrationAccessToken))
throw new ArgumentException("Token is required", nameof(registrationAccessToken));
if (tool == null) throw new ArgumentNullException(nameof(tool));

var json = JsonSerializer.Serialize(tool);
using var request = new HttpRequestMessage(HttpMethod.Post, registrationEndpoint)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", registrationAccessToken);

using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new HttpRequestException(
$"Dynamic Registration failed: {(int)response.StatusCode} {response.ReasonPhrase}. Body: {errorBody}");
}
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<ToolConfiguration>(body);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\LtiAdvantage\LtiAdvantage.csproj" />
</ItemGroup>

</Project>
9 changes: 9 additions & 0 deletions src/LtiAdvantage/AssignmentGradeServices/LineItem.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Text.Json.Serialization;
using LtiAdvantage.SubmissionReview;

namespace LtiAdvantage.AssignmentGradeServices
{
Expand Down Expand Up @@ -56,5 +57,13 @@ public class LineItem
/// </summary>
[JsonPropertyName("tag")]
public string Tag { get; set; }

/// <summary>
/// Submission Review extension. When present, the platform uses
/// <c>SubmissionReviewExtension.Url</c> for LtiSubmissionReviewRequest
/// launches against this line item.
/// </summary>
[JsonPropertyName("submissionReview")]
public SubmissionReviewProperty SubmissionReviewExtension { get; set; }
}
}
Loading
Loading