diff --git a/.gitignore b/.gitignore
index 3c4efe2..376a0b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -258,4 +258,7 @@ paket-files/
# Python Tools for Visual Studio (PTVS)
__pycache__/
-*.pyc
\ No newline at end of file
+*.pyc
+
+# Git worktrees
+.worktrees/
\ No newline at end of file
diff --git a/README.md b/README.md
index e1c17eb..e650a12 100644
--- a/README.md
+++ b/README.md
@@ -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 |
| --- | --- | --- |
diff --git a/src/LtiAdvantage.AspNetCore/DynamicRegistration/DynamicRegistrationControllerBase.cs b/src/LtiAdvantage.AspNetCore/DynamicRegistration/DynamicRegistrationControllerBase.cs
new file mode 100644
index 0000000..6180c96
--- /dev/null
+++ b/src/LtiAdvantage.AspNetCore/DynamicRegistration/DynamicRegistrationControllerBase.cs
@@ -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
+{
+ ///
+ /// Platform-side endpoint for LTI Dynamic Registration 1.0.
+ /// See https://www.imsglobal.org/spec/lti-dr/v1p0/#registration-endpoint
+ ///
+ [ApiController]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public abstract class DynamicRegistrationControllerBase : ControllerBase, IDynamicRegistrationController
+ {
+ private readonly IWebHostEnvironment _env;
+ private readonly ILogger _logger;
+
+ /// Initializes the base.
+ protected DynamicRegistrationControllerBase(IWebHostEnvironment env, ILogger logger)
+ {
+ _env = env;
+ _logger = logger;
+ }
+
+ ///
+ /// Persist the registration. Implementations MUST set
+ /// on request.Tool (or return a new instance) before returning.
+ ///
+ protected abstract Task> OnRegisterAsync(RegisterToolRequest request);
+
+ ///
+ [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> 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)}.");
+ }
+ }
+ }
+}
diff --git a/src/LtiAdvantage.AspNetCore/DynamicRegistration/IDynamicRegistrationController.cs b/src/LtiAdvantage.AspNetCore/DynamicRegistration/IDynamicRegistrationController.cs
new file mode 100644
index 0000000..0f6a5f0
--- /dev/null
+++ b/src/LtiAdvantage.AspNetCore/DynamicRegistration/IDynamicRegistrationController.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+using LtiAdvantage.DynamicRegistration;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LtiAdvantage.AspNetCore.DynamicRegistration
+{
+ /// The platform-side LTI Dynamic Registration endpoint.
+ public interface IDynamicRegistrationController
+ {
+ /// Registers a new tool. Returns 201 with the assigned client_id echoed back.
+ Task> RegisterAsync([FromBody] ToolConfiguration tool);
+ }
+}
diff --git a/src/LtiAdvantage.AspNetCore/DynamicRegistration/RegisterToolRequest.cs b/src/LtiAdvantage.AspNetCore/DynamicRegistration/RegisterToolRequest.cs
new file mode 100644
index 0000000..887cab6
--- /dev/null
+++ b/src/LtiAdvantage.AspNetCore/DynamicRegistration/RegisterToolRequest.cs
@@ -0,0 +1,15 @@
+using LtiAdvantage.DynamicRegistration;
+
+namespace LtiAdvantage.AspNetCore.DynamicRegistration
+{
+ /// Request passed to the controller's OnRegisterAsync override.
+ public class RegisterToolRequest
+ {
+ /// Wraps the submitted tool configuration.
+ /// The submitted tool configuration.
+ public RegisterToolRequest(ToolConfiguration tool) => Tool = tool;
+
+ /// The submitted tool configuration. The override should populate .
+ public ToolConfiguration Tool { get; }
+ }
+}
diff --git a/src/LtiAdvantage.AspNetCore/Jwks/IJwksController.cs b/src/LtiAdvantage.AspNetCore/Jwks/IJwksController.cs
new file mode 100644
index 0000000..bba16c5
--- /dev/null
+++ b/src/LtiAdvantage.AspNetCore/Jwks/IJwksController.cs
@@ -0,0 +1,13 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.IdentityModel.Tokens;
+
+namespace LtiAdvantage.AspNetCore.Jwks
+{
+ /// JWKS publication endpoint.
+ public interface IJwksController
+ {
+ /// Returns the JSON Web Key Set.
+ Task> GetJwksAsync();
+ }
+}
diff --git a/src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs b/src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs
new file mode 100644
index 0000000..2eb2974
--- /dev/null
+++ b/src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs
@@ -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
+{
+ ///
+ /// Publishes the platform's (or tool's) JWKS at a well-known URL so that
+ /// peers can verify signed JWTs. Anonymous; unauthenticated.
+ ///
+ [ApiController]
+ public abstract class JwksControllerBase : ControllerBase, IJwksController
+ {
+ private readonly IJwksKeyStore _keyStore;
+ private readonly ILogger _logger;
+
+ ///
+ /// Constructs a new .
+ ///
+ /// The key store providing public keys to publish.
+ /// The logger.
+ protected JwksControllerBase(IJwksKeyStore keyStore, ILogger logger)
+ {
+ _keyStore = keyStore;
+ _logger = logger;
+ }
+
+ ///
+ /// Returns the JSON Web Key Set containing the public keys used to verify
+ /// signed JWTs issued by this server.
+ ///
+ [HttpGet]
+ [Produces(Constants.MediaTypes.Jwks)]
+ [ProducesResponseType(typeof(JsonWebKeySet), StatusCodes.Status200OK)]
+ [Route(".well-known/jwks.json", Name = Constants.ServiceEndpoints.Jwks.JwksService)]
+ public async Task> 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)}.");
+ }
+ }
+ }
+}
diff --git a/src/LtiAdvantage.AspNetCore/LtiAdvantage.AspNetCore.xml b/src/LtiAdvantage.AspNetCore/LtiAdvantage.AspNetCore.xml
index 1ee0be3..b1dbd4a 100644
--- a/src/LtiAdvantage.AspNetCore/LtiAdvantage.AspNetCore.xml
+++ b/src/LtiAdvantage.AspNetCore/LtiAdvantage.AspNetCore.xml
@@ -383,6 +383,65 @@
Get or set the line item id.
+
+
+ Platform-side endpoint for LTI Dynamic Registration 1.0.
+ See https://www.imsglobal.org/spec/lti-dr/v1p0/#registration-endpoint
+
+
+
+ Initializes the base.
+
+
+
+ Persist the registration. Implementations MUST set
+ on request.Tool (or return a new instance) before returning.
+
+
+
+
+
+
+ The platform-side LTI Dynamic Registration endpoint.
+
+
+ Registers a new tool. Returns 201 with the assigned client_id echoed back.
+
+
+ Request passed to the controller's OnRegisterAsync override.
+
+
+ Wraps the submitted tool configuration.
+ The submitted tool configuration.
+
+
+ The submitted tool configuration. The override should populate .
+
+
+ JWKS publication endpoint.
+
+
+ Returns the JSON Web Key Set.
+
+
+
+ Publishes the platform's (or tool's) JWKS at a well-known URL so that
+ peers can verify signed JWTs. Anonymous; unauthenticated.
+
+
+
+
+ Constructs a new .
+
+ The key store providing public keys to publish.
+ The logger.
+
+
+
+ Returns the JSON Web Key Set containing the public keys used to verify
+ signed JWTs issued by this server.
+
+
Represents an LTI service response.
diff --git a/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs b/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs
new file mode 100644
index 0000000..b2064a6
--- /dev/null
+++ b/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs
@@ -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
+{
+ ///
+ /// HttpClient extensions implementing the tool side of LTI Dynamic Registration 1.0.
+ ///
+ public static class HttpClientDynamicRegistrationExtensions
+ {
+ ///
+ /// Fetches the platform's OpenID configuration document ().
+ ///
+ /// The HTTP client.
+ /// The URL passed by the platform in the registration init launch.
+ /// Optional cancellation token.
+ public static async Task 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(body);
+ }
+
+ ///
+ /// POSTs a tool registration request to the platform's registration_endpoint
+ /// using the bearer registration token supplied during the registration init launch.
+ /// Returns the platform-assigned (echoed config + client_id).
+ ///
+ /// The HTTP client.
+ /// The platform's registration endpoint URL.
+ /// Bearer token from the registration init launch.
+ /// The tool configuration to register.
+ /// Optional cancellation token.
+ public static async Task 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(body);
+ }
+ }
+}
diff --git a/src/LtiAdvantage.IdentityModel/LtiAdvantage.IdentityModel.csproj b/src/LtiAdvantage.IdentityModel/LtiAdvantage.IdentityModel.csproj
index bdd38d4..0a8977e 100644
--- a/src/LtiAdvantage.IdentityModel/LtiAdvantage.IdentityModel.csproj
+++ b/src/LtiAdvantage.IdentityModel/LtiAdvantage.IdentityModel.csproj
@@ -24,4 +24,8 @@
+
+
+
+
diff --git a/src/LtiAdvantage/AssignmentGradeServices/LineItem.cs b/src/LtiAdvantage/AssignmentGradeServices/LineItem.cs
index 1f07d09..0efdcbb 100644
--- a/src/LtiAdvantage/AssignmentGradeServices/LineItem.cs
+++ b/src/LtiAdvantage/AssignmentGradeServices/LineItem.cs
@@ -1,5 +1,6 @@
using System;
using System.Text.Json.Serialization;
+using LtiAdvantage.SubmissionReview;
namespace LtiAdvantage.AssignmentGradeServices
{
@@ -56,5 +57,13 @@ public class LineItem
///
[JsonPropertyName("tag")]
public string Tag { get; set; }
+
+ ///
+ /// Submission Review extension. When present, the platform uses
+ /// SubmissionReviewExtension.Url for LtiSubmissionReviewRequest
+ /// launches against this line item.
+ ///
+ [JsonPropertyName("submissionReview")]
+ public SubmissionReviewProperty SubmissionReviewExtension { get; set; }
}
}
diff --git a/src/LtiAdvantage/Constants.cs b/src/LtiAdvantage/Constants.cs
index de5b58b..8ee66de 100644
--- a/src/LtiAdvantage/Constants.cs
+++ b/src/LtiAdvantage/Constants.cs
@@ -59,6 +59,11 @@ public static class Lti
///
public const string LtiResourceLinkRequestMessageType = "LtiResourceLinkRequest";
+ ///
+ /// The message type of an LtiSubmissionReviewRequest.
+ ///
+ public const string LtiSubmissionReviewRequestMessageType = "LtiSubmissionReviewRequest";
+
///
/// LTI version.
///
@@ -115,6 +120,11 @@ public static class LtiClaims
///
public const string ErrorMessage = "https://purl.imsglobal.org/spec/lti-dl/claim/errormsg";
+ ///
+ /// The user whose submission is being reviewed (Submission Review 1.0 §3.2).
+ ///
+ public const string ForUser = "https://purl.imsglobal.org/spec/lti-sr/claim/for_user";
+
///
/// Information to help the Tool present itself appropriately.
///
@@ -141,6 +151,12 @@ public static class LtiClaims
///
public const string LtiMigration = "https://purl.imsglobal.org/spec/lti/claim/lti1p1";
+ /// The LTI Platform Configuration claim on an OpenID configuration document (Dynamic Registration §3.5).
+ public const string LtiPlatformConfiguration = "https://purl.imsglobal.org/spec/lti-platform-configuration";
+
+ /// The LTI Tool Configuration claim on a registration request body (Dynamic Registration §3.6).
+ public const string LtiToolConfiguration = "https://purl.imsglobal.org/spec/lti-tool-configuration";
+
///
/// Optional plain text message.
///
@@ -223,6 +239,13 @@ public static class Ags
public const string ScoreReadonly = "https://purl.imsglobal.org/spec/lti-ags/scope/score.readonly";
}
+ /// LTI Dynamic Registration scopes (Dynamic Registration §4.4).
+ public static class DynamicRegistration
+ {
+ /// Scope required on the registration access token.
+ public const string Scope = "https://purl.imsglobal.org/spec/lti-reg/scope/registration";
+ }
+
///
/// Names and Role Provisioning Service scopes.
///
@@ -240,6 +263,11 @@ public static class Nrps
///
public static class MediaTypes
{
+ ///
+ /// JSON Web Key Set media type (RFC 7517 §6).
+ ///
+ public const string Jwks = "application/jwk-set+json";
+
///
/// https://www.imsglobal.org/spec/lti-ags/v2p0/#media-types-and-schemas
///
@@ -369,6 +397,15 @@ public static class Ags
public const string ScoresService = "scores";
}
+ ///
+ /// JWKS publication endpoint.
+ ///
+ public static class Jwks
+ {
+ /// The well-known JWKS endpoint route name.
+ public const string JwksService = "jwks";
+ }
+
///
/// Names and Role Provisioning Service endpoints.
///
diff --git a/src/LtiAdvantage/DynamicRegistration/LtiPlatformConfiguration.cs b/src/LtiAdvantage/DynamicRegistration/LtiPlatformConfiguration.cs
new file mode 100644
index 0000000..56ac3d1
--- /dev/null
+++ b/src/LtiAdvantage/DynamicRegistration/LtiPlatformConfiguration.cs
@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace LtiAdvantage.DynamicRegistration
+{
+ ///
+ /// Value of the LTI extension claim on a platform's OpenID configuration document.
+ /// See https://www.imsglobal.org/spec/lti-dr/v1p0/#lti-platform-configuration
+ ///
+ public class LtiPlatformConfiguration
+ {
+ /// The product family code identifying the platform vendor.
+ [JsonPropertyName("product_family_code")]
+ public string ProductFamilyCode { get; set; }
+
+ /// The platform's version string.
+ [JsonPropertyName("version")]
+ public string Version { get; set; }
+
+ /// The set of LTI message types this platform supports launching, plus optional placements.
+ [JsonPropertyName("messages_supported")]
+ public IList MessagesSupported { get; set; }
+
+ /// Custom variable expansions the platform supports (e.g. "Person.email.primary").
+ [JsonPropertyName("variables")]
+ public IList Variables { get; set; }
+ }
+}
diff --git a/src/LtiAdvantage/DynamicRegistration/LtiToolConfiguration.cs b/src/LtiAdvantage/DynamicRegistration/LtiToolConfiguration.cs
new file mode 100644
index 0000000..617af28
--- /dev/null
+++ b/src/LtiAdvantage/DynamicRegistration/LtiToolConfiguration.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace LtiAdvantage.DynamicRegistration
+{
+ ///
+ /// LTI extension claim on a Dynamic Registration request body.
+ /// See https://www.imsglobal.org/spec/lti-dr/v1p0/#lti-tool-configuration
+ ///
+ public class LtiToolConfiguration
+ {
+ /// The tool's primary domain (without scheme, no path).
+ [JsonPropertyName("domain")]
+ public string Domain { get; set; }
+
+ /// Additional domains the tool also serves from.
+ [JsonPropertyName("secondary_domains")]
+ public IList SecondaryDomains { get; set; }
+
+ /// Optional deployment id requested by the tool.
+ [JsonPropertyName("deployment_id")]
+ public string DeploymentId { get; set; }
+
+ /// Default target_link_uri for launches.
+ [JsonPropertyName("target_link_uri")]
+ public string TargetLinkUri { get; set; }
+
+ /// Custom parameters to merge into every launch.
+ [JsonPropertyName("custom_parameters")]
+ public IDictionary CustomParameters { get; set; }
+
+ /// Human-readable description of the tool.
+ [JsonPropertyName("description")]
+ public string Description { get; set; }
+
+ /// The OpenID claims this tool requests (e.g. "name", "email").
+ [JsonPropertyName("claims")]
+ public IList Claims { get; set; }
+
+ /// The LTI message types this tool supports receiving.
+ [JsonPropertyName("messages")]
+ public IList Messages { get; set; }
+ }
+}
diff --git a/src/LtiAdvantage/DynamicRegistration/MessageDescriptor.cs b/src/LtiAdvantage/DynamicRegistration/MessageDescriptor.cs
new file mode 100644
index 0000000..b2300c6
--- /dev/null
+++ b/src/LtiAdvantage/DynamicRegistration/MessageDescriptor.cs
@@ -0,0 +1,41 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace LtiAdvantage.DynamicRegistration
+{
+ ///
+ /// Describes a supported LTI message type. Used in both
+ /// (what the platform launches)
+ /// and (what the tool can receive).
+ ///
+ public class MessageDescriptor
+ {
+ /// The message type, e.g. "LtiResourceLinkRequest".
+ [JsonPropertyName("type")]
+ public string Type { get; set; }
+
+ /// Per-message target_link_uri (tool side).
+ [JsonPropertyName("target_link_uri")]
+ public string TargetLinkUri { get; set; }
+
+ /// Per-message label shown by the platform (tool side).
+ [JsonPropertyName("label")]
+ public string Label { get; set; }
+
+ /// Per-message icon URI (tool side).
+ [JsonPropertyName("icon_uri")]
+ public string IconUri { get; set; }
+
+ /// Custom parameters to merge into this launch (tool side).
+ [JsonPropertyName("custom_parameters")]
+ public IDictionary CustomParameters { get; set; }
+
+ /// Placements this message is offered for (e.g. "ContentArea", "RichTextEditor").
+ [JsonPropertyName("placements")]
+ public IList Placements { get; set; }
+
+ /// Roles required for this launch (tool side).
+ [JsonPropertyName("roles")]
+ public IList Roles { get; set; }
+ }
+}
diff --git a/src/LtiAdvantage/DynamicRegistration/PlatformOpenIdConfiguration.cs b/src/LtiAdvantage/DynamicRegistration/PlatformOpenIdConfiguration.cs
new file mode 100644
index 0000000..1026eaa
--- /dev/null
+++ b/src/LtiAdvantage/DynamicRegistration/PlatformOpenIdConfiguration.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace LtiAdvantage.DynamicRegistration
+{
+ ///
+ /// A platform's OpenID Provider Metadata document (RFC 8414) including
+ /// the LTI Dynamic Registration extension claim
+ /// (https://purl.imsglobal.org/spec/lti-platform-configuration).
+ ///
+ public class PlatformOpenIdConfiguration
+ {
+ /// The platform's issuer identifier URL.
+ [JsonPropertyName("issuer")]
+ public string Issuer { get; set; }
+
+ /// OAuth 2 authorization endpoint URL.
+ [JsonPropertyName("authorization_endpoint")]
+ public string AuthorizationEndpoint { get; set; }
+
+ /// OAuth 2 token endpoint URL.
+ [JsonPropertyName("token_endpoint")]
+ public string TokenEndpoint { get; set; }
+
+ /// Auth methods supported at the token endpoint (typically private_key_jwt).
+ [JsonPropertyName("token_endpoint_auth_methods_supported")]
+ public IList TokenEndpointAuthMethodsSupported { get; set; }
+
+ /// JWS algorithms supported for client assertions.
+ [JsonPropertyName("token_endpoint_auth_signing_alg_values_supported")]
+ public IList TokenEndpointAuthSigningAlgValuesSupported { get; set; }
+
+ /// URL of the platform's public JWKS document.
+ [JsonPropertyName("jwks_uri")]
+ public string JwksUri { get; set; }
+
+ /// URL the tool POSTs its registration request to.
+ [JsonPropertyName("registration_endpoint")]
+ public string RegistrationEndpoint { get; set; }
+
+ /// OAuth 2 scopes the platform supports.
+ [JsonPropertyName("scopes_supported")]
+ public IList ScopesSupported { get; set; }
+
+ /// OAuth 2 response types the platform supports (typically id_token).
+ [JsonPropertyName("response_types_supported")]
+ public IList ResponseTypesSupported { get; set; }
+
+ /// Subject types supported (typically public).
+ [JsonPropertyName("subject_types_supported")]
+ public IList SubjectTypesSupported { get; set; }
+
+ /// JWS signing algorithms supported for ID tokens (typically RS256).
+ [JsonPropertyName("id_token_signing_alg_values_supported")]
+ public IList IdTokenSigningAlgValuesSupported { get; set; }
+
+ /// Standard claims the platform supports.
+ [JsonPropertyName("claims_supported")]
+ public IList ClaimsSupported { get; set; }
+
+ /// The LTI extension claim. Constant: .
+ [JsonPropertyName("https://purl.imsglobal.org/spec/lti-platform-configuration")]
+ public LtiPlatformConfiguration LtiPlatformConfiguration { get; set; }
+ }
+}
diff --git a/src/LtiAdvantage/DynamicRegistration/ToolConfiguration.cs b/src/LtiAdvantage/DynamicRegistration/ToolConfiguration.cs
new file mode 100644
index 0000000..0c379f5
--- /dev/null
+++ b/src/LtiAdvantage/DynamicRegistration/ToolConfiguration.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace LtiAdvantage.DynamicRegistration
+{
+ ///
+ /// Tool-side registration request body sent to a platform's
+ /// registration_endpoint. Mirrors OIDC client metadata
+ /// (RFC 7591) plus the LTI lti-tool-configuration claim.
+ ///
+ public class ToolConfiguration
+ {
+ /// OIDC application_type — typically "web".
+ [JsonPropertyName("application_type")]
+ public string ApplicationType { get; set; } = "web";
+
+ /// OAuth 2 response types — typically ["id_token"].
+ [JsonPropertyName("response_types")]
+ public IList ResponseTypes { get; set; }
+
+ /// OAuth 2 grant types — typically ["implicit", "client_credentials"].
+ [JsonPropertyName("grant_types")]
+ public IList GrantTypes { get; set; }
+
+ /// The OIDC initiate_login_uri.
+ [JsonPropertyName("initiate_login_uri")]
+ public string InitiateLoginUri { get; set; }
+
+ /// Allowed launch redirect URIs.
+ [JsonPropertyName("redirect_uris")]
+ public IList RedirectUris { get; set; }
+
+ /// Human-readable client name.
+ [JsonPropertyName("client_name")]
+ public string ClientName { get; set; }
+
+ /// The tool's homepage URL.
+ [JsonPropertyName("client_uri")]
+ public string ClientUri { get; set; }
+
+ /// The tool's logo image URL.
+ [JsonPropertyName("logo_uri")]
+ public string LogoUri { get; set; }
+
+ /// Space-separated list of scopes the tool will request.
+ [JsonPropertyName("scope")]
+ public string Scope { get; set; }
+
+ /// Auth method at the token endpoint — typically "private_key_jwt".
+ [JsonPropertyName("token_endpoint_auth_method")]
+ public string TokenEndpointAuthMethod { get; set; } = "private_key_jwt";
+
+ /// URL of the tool's public JWKS document.
+ [JsonPropertyName("jwks_uri")]
+ public string JwksUri { get; set; }
+
+ /// Echoed back by the platform on successful registration (Dynamic Registration §4.5).
+ [JsonPropertyName("client_id")]
+ public string ClientId { get; set; }
+
+ /// The LTI extension claim. Constant: .
+ [JsonPropertyName("https://purl.imsglobal.org/spec/lti-tool-configuration")]
+ public LtiToolConfiguration LtiToolConfiguration { get; set; }
+ }
+}
diff --git a/src/LtiAdvantage/Jwks/IJwksKeyStore.cs b/src/LtiAdvantage/Jwks/IJwksKeyStore.cs
new file mode 100644
index 0000000..9ab2423
--- /dev/null
+++ b/src/LtiAdvantage/Jwks/IJwksKeyStore.cs
@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.IdentityModel.Tokens;
+
+namespace LtiAdvantage.Jwks
+{
+ ///
+ /// Provides the set of public keys to publish at /.well-known/jwks.json.
+ /// Implementations decide how keys are stored, rotated, and retired —
+ /// typically all currently-active and recently-retired (still in token TTL)
+ /// signing keys are returned, each with a stable kid.
+ ///
+ public interface IJwksKeyStore
+ {
+ ///
+ /// Returns the public keys to publish in the JWKS document.
+ ///
+ /// Rotation guidance:
+ /// - Always include the current signing key (the one used to sign tokens issued now).
+ /// - Continue to include each retired signing key for at least the longest TTL of any token signed with it,
+ /// so verifiers can still validate in-flight tokens after rotation.
+ /// - Each key MUST have a stable, unique Kid.
+ /// - Set Use = "sig" and Alg = "RS256" for LTI 1.3 signing keys.
+ /// - Do NOT include private key material — only the public components (N, E).
+ ///
+ Task> GetPublicKeysAsync();
+ }
+}
diff --git a/src/LtiAdvantage/Lti/LtiSubmissionReviewRequest.cs b/src/LtiAdvantage/Lti/LtiSubmissionReviewRequest.cs
new file mode 100644
index 0000000..86fedbf
--- /dev/null
+++ b/src/LtiAdvantage/Lti/LtiSubmissionReviewRequest.cs
@@ -0,0 +1,77 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using LtiAdvantage.AssignmentGradeServices;
+using LtiAdvantage.NamesRoleProvisioningService;
+using LtiAdvantage.SubmissionReview;
+using LtiAdvantage.Utilities;
+
+namespace LtiAdvantage.Lti
+{
+ ///
+ ///
+ /// LTI Submission Review request (https://www.imsglobal.org/spec/lti-sr/v1p0).
+ ///
+ [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
+ public class LtiSubmissionReviewRequest : LtiRequest
+ {
+ ///
+ ///
+ /// Create an instance of with default
+ /// values for the MessageType and Version claims.
+ ///
+ public LtiSubmissionReviewRequest()
+ {
+ MessageType = Constants.Lti.LtiSubmissionReviewRequestMessageType;
+ Version = Constants.Lti.Version;
+ }
+
+ ///
+ ///
+ /// Create an instance of with the claims.
+ ///
+ /// A list of claims.
+ public LtiSubmissionReviewRequest(IEnumerable claims) : base(claims)
+ {
+ }
+
+ ///
+ ///
+ /// Create an instance of with the
+ /// claims in payload.
+ ///
+ ///
+ public LtiSubmissionReviewRequest(JwtPayload payload) : base(payload.Claims)
+ {
+ }
+
+ /// The Assignment and Grade Services claim (the lineitem under review).
+ public AssignmentGradeServicesClaimValueType AssignmentGradeServices
+ {
+ get => this.GetClaimValue(Constants.LtiClaims.AssignmentGradeServices);
+ set => this.SetClaimValue(Constants.LtiClaims.AssignmentGradeServices, value);
+ }
+
+ /// The Names and Roles Provisioning Service claim.
+ public NamesRoleServiceClaimValueType NamesRoleService
+ {
+ get => this.GetClaimValue(Constants.LtiClaims.NamesRoleService);
+ set => this.SetClaimValue(Constants.LtiClaims.NamesRoleService, value);
+ }
+
+ /// The resource_link claim (same shape as in LtiResourceLinkRequest).
+ public ResourceLinkClaimValueType ResourceLink
+ {
+ get => this.GetClaimValue(Constants.LtiClaims.ResourceLink);
+ set => this.SetClaimValue(Constants.LtiClaims.ResourceLink, value);
+ }
+
+ /// The for_user claim (the user whose submission is being reviewed).
+ public ForUserClaimValueType ForUser
+ {
+ get => this.GetClaimValue(Constants.LtiClaims.ForUser);
+ set => this.SetClaimValue(Constants.LtiClaims.ForUser, value);
+ }
+ }
+}
diff --git a/src/LtiAdvantage/LtiAdvantage.xml b/src/LtiAdvantage/LtiAdvantage.xml
index 67e37f6..ea821d4 100644
--- a/src/LtiAdvantage/LtiAdvantage.xml
+++ b/src/LtiAdvantage/LtiAdvantage.xml
@@ -141,6 +141,13 @@
Optional tag.
+
+
+ Submission Review extension. When present, the platform uses
+ SubmissionReviewExtension.Url for LtiSubmissionReviewRequest
+ launches against this line item.
+
+
@@ -317,6 +324,11 @@
The message type of an LtiResourceLinkRequest.
+
+
+ The message type of an LtiSubmissionReviewRequest.
+
+
LTI version.
@@ -372,6 +384,11 @@
Optional plain text message.
+
+
+ The user whose submission is being reviewed (Submission Review 1.0 §3.2).
+
+
Information to help the Tool present itself appropriately.
@@ -398,6 +415,12 @@
Optional LTI 1.1 migration mapping.
+
+ The LTI Platform Configuration claim on an OpenID configuration document (Dynamic Registration §3.5).
+
+
+ The LTI Tool Configuration claim on a registration request body (Dynamic Registration §3.6).
+
Optional plain text message.
@@ -478,6 +501,12 @@
Custom Assignment and Grade Service score readonly scope.
+
+ LTI Dynamic Registration scopes (Dynamic Registration §4.4).
+
+
+ Scope required on the registration access token.
+
Names and Role Provisioning Service scopes.
@@ -493,6 +522,11 @@
Service media types.
+
+
+ JSON Web Key Set media type (RFC 7517 §6).
+
+
https://www.imsglobal.org/spec/lti-ags/v2p0/#media-types-and-schemas
@@ -619,6 +653,14 @@
Scores endpoint.
+
+
+ JWKS publication endpoint.
+
+
+
+ The well-known JWKS endpoint route name.
+
Names and Role Provisioning Service endpoints.
@@ -1225,6 +1267,195 @@
Comma-separate list of features for window.open().
+
+
+ Value of the LTI extension claim on a platform's OpenID configuration document.
+ See https://www.imsglobal.org/spec/lti-dr/v1p0/#lti-platform-configuration
+
+
+
+ The product family code identifying the platform vendor.
+
+
+ The platform's version string.
+
+
+ The set of LTI message types this platform supports launching, plus optional placements.
+
+
+ Custom variable expansions the platform supports (e.g. "Person.email.primary").
+
+
+
+ LTI extension claim on a Dynamic Registration request body.
+ See https://www.imsglobal.org/spec/lti-dr/v1p0/#lti-tool-configuration
+
+
+
+ The tool's primary domain (without scheme, no path).
+
+
+ Additional domains the tool also serves from.
+
+
+ Optional deployment id requested by the tool.
+
+
+ Default target_link_uri for launches.
+
+
+ Custom parameters to merge into every launch.
+
+
+ Human-readable description of the tool.
+
+
+ The OpenID claims this tool requests (e.g. "name", "email").
+
+
+ The LTI message types this tool supports receiving.
+
+
+
+ Describes a supported LTI message type. Used in both
+ (what the platform launches)
+ and (what the tool can receive).
+
+
+
+ The message type, e.g. "LtiResourceLinkRequest".
+
+
+ Per-message target_link_uri (tool side).
+
+
+ Per-message label shown by the platform (tool side).
+
+
+ Per-message icon URI (tool side).
+
+
+ Custom parameters to merge into this launch (tool side).
+
+
+ Placements this message is offered for (e.g. "ContentArea", "RichTextEditor").
+
+
+ Roles required for this launch (tool side).
+
+
+
+ A platform's OpenID Provider Metadata document (RFC 8414) including
+ the LTI Dynamic Registration extension claim
+ (https://purl.imsglobal.org/spec/lti-platform-configuration).
+
+
+
+ The platform's issuer identifier URL.
+
+
+ OAuth 2 authorization endpoint URL.
+
+
+ OAuth 2 token endpoint URL.
+
+
+ Auth methods supported at the token endpoint (typically private_key_jwt).
+
+
+ JWS algorithms supported for client assertions.
+
+
+ URL of the platform's public JWKS document.
+
+
+ URL the tool POSTs its registration request to.
+
+
+ OAuth 2 scopes the platform supports.
+
+
+ OAuth 2 response types the platform supports (typically id_token).
+
+
+ Subject types supported (typically public).
+
+
+ JWS signing algorithms supported for ID tokens (typically RS256).
+
+
+ Standard claims the platform supports.
+
+
+ The LTI extension claim. Constant: .
+
+
+
+ Tool-side registration request body sent to a platform's
+ registration_endpoint. Mirrors OIDC client metadata
+ (RFC 7591) plus the LTI lti-tool-configuration claim.
+
+
+
+ OIDC application_type — typically "web".
+
+
+ OAuth 2 response types — typically ["id_token"].
+
+
+ OAuth 2 grant types — typically ["implicit", "client_credentials"].
+
+
+ The OIDC initiate_login_uri.
+
+
+ Allowed launch redirect URIs.
+
+
+ Human-readable client name.
+
+
+ The tool's homepage URL.
+
+
+ The tool's logo image URL.
+
+
+ Space-separated list of scopes the tool will request.
+
+
+ Auth method at the token endpoint — typically "private_key_jwt".
+
+
+ URL of the tool's public JWKS document.
+
+
+ Echoed back by the platform on successful registration (Dynamic Registration §4.5).
+
+
+ The LTI extension claim. Constant: .
+
+
+
+ Provides the set of public keys to publish at /.well-known/jwks.json.
+ Implementations decide how keys are stored, rotated, and retired —
+ typically all currently-active and recently-retired (still in token TTL)
+ signing keys are returned, each with a stable kid.
+
+
+
+
+ Returns the public keys to publish in the JWKS document.
+
+ Rotation guidance:
+ - Always include the current signing key (the one used to sign tokens issued now).
+ - Continue to include each retired signing key for at least the longest TTL of any token signed with it,
+ so verifiers can still validate in-flight tokens after rotation.
+ - Each key MUST have a stable, unique Kid.
+ - Set Use = "sig" and Alg = "RS256" for LTI 1.3 signing keys.
+ - Do NOT include private key material — only the public components (N, E).
+
+
Properties of the context from which the launch originated (for example, course id and title).
@@ -2627,6 +2858,46 @@
+
+
+
+ LTI Submission Review request (https://www.imsglobal.org/spec/lti-sr/v1p0).
+
+
+
+
+
+ Create an instance of with default
+ values for the MessageType and Version claims.
+
+
+
+
+
+ Create an instance of with the claims.
+
+ A list of claims.
+
+
+
+
+ Create an instance of with the
+ claims in payload.
+
+
+
+
+ The Assignment and Grade Services claim (the lineitem under review).
+
+
+ The Names and Roles Provisioning Service claim.
+
+
+ The resource_link claim (same shape as in LtiResourceLinkRequest).
+
+
+ The for_user claim (the user whose submission is being reviewed).
+
Properties associated with the platform initiating the launch.
@@ -2998,6 +3269,50 @@
Service version. Default is .
+
+
+ The user whose submission is being reviewed.
+ See https://www.imsglobal.org/spec/lti-sr/v1p0/#for-user-claim
+
+
+
+ The user_id of the reviewed user.
+
+
+ The reviewed user's full name.
+
+
+ The reviewed user's given (first) name.
+
+
+ The reviewed user's family (last) name.
+
+
+ The reviewed user's email.
+
+
+ The roles of the reviewed user in the context.
+
+
+ The person sourcedId of the reviewed user.
+
+
+
+ Submission Review extension on an AGS LineItem.
+ See https://www.imsglobal.org/spec/lti-sr/v1p0/#submissionreview-extension
+
+
+
+
+ The launch URL the platform should use when launching back into
+ the tool to review a submission for this line item.
+
+
+
+
+ Optional custom parameters to include with each Submission Review launch.
+
+
Enum extensions.
diff --git a/src/LtiAdvantage/SubmissionReview/ForUserClaimValueType.cs b/src/LtiAdvantage/SubmissionReview/ForUserClaimValueType.cs
new file mode 100644
index 0000000..7990e6a
--- /dev/null
+++ b/src/LtiAdvantage/SubmissionReview/ForUserClaimValueType.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace LtiAdvantage.SubmissionReview
+{
+ ///
+ /// The user whose submission is being reviewed.
+ /// See https://www.imsglobal.org/spec/lti-sr/v1p0/#for-user-claim
+ ///
+ public class ForUserClaimValueType
+ {
+ /// The user_id of the reviewed user.
+ [JsonPropertyName("user_id")]
+ public string UserId { get; set; }
+
+ /// The reviewed user's full name.
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ /// The reviewed user's given (first) name.
+ [JsonPropertyName("given_name")]
+ public string GivenName { get; set; }
+
+ /// The reviewed user's family (last) name.
+ [JsonPropertyName("family_name")]
+ public string FamilyName { get; set; }
+
+ /// The reviewed user's email.
+ [JsonPropertyName("email")]
+ public string Email { get; set; }
+
+ /// The roles of the reviewed user in the context.
+ [JsonPropertyName("roles")]
+ public IList Roles { get; set; }
+
+ /// The person sourcedId of the reviewed user.
+ [JsonPropertyName("person_sourcedid")]
+ public string PersonSourcedId { get; set; }
+ }
+}
diff --git a/src/LtiAdvantage/SubmissionReview/SubmissionReviewProperty.cs b/src/LtiAdvantage/SubmissionReview/SubmissionReviewProperty.cs
new file mode 100644
index 0000000..2aac21e
--- /dev/null
+++ b/src/LtiAdvantage/SubmissionReview/SubmissionReviewProperty.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace LtiAdvantage.SubmissionReview
+{
+ ///
+ /// Submission Review extension on an AGS LineItem.
+ /// See https://www.imsglobal.org/spec/lti-sr/v1p0/#submissionreview-extension
+ ///
+ public class SubmissionReviewProperty
+ {
+ ///
+ /// The launch URL the platform should use when launching back into
+ /// the tool to review a submission for this line item.
+ ///
+ [JsonPropertyName("url")]
+ public string Url { get; set; }
+
+ ///
+ /// Optional custom parameters to include with each Submission Review launch.
+ ///
+ [JsonPropertyName("custom")]
+ public IDictionary Custom { get; set; }
+ }
+}
diff --git a/test/LtiAdvantage.IntegrationTests/Controllers/DynamicRegistrationController.cs b/test/LtiAdvantage.IntegrationTests/Controllers/DynamicRegistrationController.cs
new file mode 100644
index 0000000..4fac230
--- /dev/null
+++ b/test/LtiAdvantage.IntegrationTests/Controllers/DynamicRegistrationController.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Threading.Tasks;
+using LtiAdvantage.AspNetCore.DynamicRegistration;
+using LtiAdvantage.DynamicRegistration;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace LtiAdvantage.IntegrationTests.Controllers
+{
+ public class DynamicRegistrationController : DynamicRegistrationControllerBase
+ {
+ public DynamicRegistrationController(IWebHostEnvironment env, ILogger logger)
+ : base(env, logger) { }
+
+ protected override Task> OnRegisterAsync(RegisterToolRequest request)
+ {
+ request.Tool.ClientId = Guid.NewGuid().ToString();
+ return Task.FromResult>(
+ new ObjectResult(request.Tool) { StatusCode = 201 });
+ }
+ }
+}
diff --git a/test/LtiAdvantage.IntegrationTests/Controllers/JwksController.cs b/test/LtiAdvantage.IntegrationTests/Controllers/JwksController.cs
new file mode 100644
index 0000000..31e11e4
--- /dev/null
+++ b/test/LtiAdvantage.IntegrationTests/Controllers/JwksController.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Threading.Tasks;
+using LtiAdvantage.AspNetCore.Jwks;
+using LtiAdvantage.Jwks;
+using Microsoft.Extensions.Logging;
+using Microsoft.IdentityModel.Tokens;
+
+namespace LtiAdvantage.IntegrationTests.Controllers
+{
+ public class JwksController : JwksControllerBase
+ {
+ public JwksController(IJwksKeyStore keyStore, ILogger logger)
+ : base(keyStore, logger) { }
+ }
+
+ public class TestJwksKeyStore : IJwksKeyStore
+ {
+ public Task> GetPublicKeysAsync()
+ {
+ using var rsa = RSA.Create(2048);
+ var rsaParams = rsa.ExportParameters(includePrivateParameters: false);
+ var jwk = new JsonWebKey
+ {
+ Kty = "RSA",
+ Use = "sig",
+ Alg = "RS256",
+ Kid = "test-kid-1",
+ N = Base64UrlEncoder.Encode(rsaParams.Modulus),
+ E = Base64UrlEncoder.Encode(rsaParams.Exponent),
+ };
+ return Task.FromResult>(new[] { jwk });
+ }
+ }
+}
diff --git a/test/LtiAdvantage.IntegrationTests/DynamicRegistration/DynamicRegistrationControllerShould.cs b/test/LtiAdvantage.IntegrationTests/DynamicRegistration/DynamicRegistrationControllerShould.cs
new file mode 100644
index 0000000..c080d56
--- /dev/null
+++ b/test/LtiAdvantage.IntegrationTests/DynamicRegistration/DynamicRegistrationControllerShould.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using LtiAdvantage.DynamicRegistration;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace LtiAdvantage.IntegrationTests.DynamicRegistration
+{
+ public class DynamicRegistrationControllerShould : IDisposable
+ {
+ private const string Url = "lti/register";
+ private readonly HttpClient _client;
+ private readonly TestServer _server;
+
+ public DynamicRegistrationControllerShould()
+ {
+ _server = new TestServer(new WebHostBuilder()
+ .UseContentRoot(AppContext.BaseDirectory)
+ .ConfigureLogging(l => { l.AddConsole(); l.AddDebug(); })
+ .UseStartup());
+ _client = _server.CreateClient();
+ }
+
+ [Fact]
+ public async Task Reject_WhenScopeMissing()
+ {
+ // Authenticated but with a non-registration scope → policy denies.
+ _client.DefaultRequestHeaders.Add("x-test-scope",
+ Constants.LtiScopes.Nrps.MembershipReadonly);
+ var resp = await PostAsync(MinimalTool());
+ Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
+ }
+
+ [Fact]
+ public async Task RegisterTool_AndReturnClientId()
+ {
+ _client.DefaultRequestHeaders.Add("x-test-scope",
+ Constants.LtiScopes.DynamicRegistration.Scope);
+
+ var resp = await PostAsync(MinimalTool());
+
+ Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
+ var body = await resp.Content.ReadAsStringAsync();
+ var registered = JsonSerializer.Deserialize(body);
+ Assert.False(string.IsNullOrWhiteSpace(registered.ClientId));
+ Assert.Equal("Example Tool", registered.ClientName);
+ }
+
+ private Task PostAsync(ToolConfiguration tool)
+ {
+ var json = JsonSerializer.Serialize(tool);
+ return _client.PostAsync(Url, new StringContent(json, Encoding.UTF8, "application/json"));
+ }
+
+ private static ToolConfiguration MinimalTool() => new()
+ {
+ ApplicationType = "web",
+ ClientName = "Example Tool",
+ InitiateLoginUri = "https://tool.example.com/login",
+ RedirectUris = new[] { "https://tool.example.com/launch" },
+ JwksUri = "https://tool.example.com/.well-known/jwks.json",
+ ResponseTypes = new[] { "id_token" },
+ GrantTypes = new[] { "implicit", "client_credentials" },
+ TokenEndpointAuthMethod = "private_key_jwt",
+ LtiToolConfiguration = new LtiToolConfiguration
+ {
+ Domain = "tool.example.com",
+ TargetLinkUri = "https://tool.example.com/launch"
+ }
+ };
+
+ public void Dispose() { _client?.Dispose(); _server?.Dispose(); }
+ }
+}
diff --git a/test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs b/test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs
new file mode 100644
index 0000000..edb6613
--- /dev/null
+++ b/test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace LtiAdvantage.IntegrationTests.Jwks
+{
+ public class JwksControllerShould : IDisposable
+ {
+ private readonly HttpClient _client;
+ private readonly TestServer _server;
+
+ public JwksControllerShould()
+ {
+ _server = new TestServer(new WebHostBuilder()
+ .UseContentRoot(AppContext.BaseDirectory)
+ .ConfigureLogging(l => { l.AddConsole(); l.AddDebug(); })
+ .UseStartup());
+ _client = _server.CreateClient();
+ }
+
+ [Fact]
+ public async Task ReturnPublicJwks_Anonymously()
+ {
+ var response = await _client.GetAsync(".well-known/jwks.json");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.StartsWith("application/jwk-set+json",
+ response.Content.Headers.ContentType.ToString());
+
+ var body = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(body);
+ Assert.True(doc.RootElement.TryGetProperty("keys", out var keys));
+ Assert.True(keys.GetArrayLength() >= 1);
+
+ var first = keys[0];
+ Assert.Equal("test-kid-1", first.GetProperty("kid").GetString());
+ Assert.Equal("sig", first.GetProperty("use").GetString());
+ Assert.Equal("RSA", first.GetProperty("kty").GetString());
+
+ // Public key parameters MUST be present.
+ Assert.False(string.IsNullOrEmpty(first.GetProperty("n").GetString()), "modulus n missing");
+ Assert.False(string.IsNullOrEmpty(first.GetProperty("e").GetString()), "exponent e missing");
+ Assert.Equal("RS256", first.GetProperty("alg").GetString());
+
+ // Private RSA parameters MUST NOT be present.
+ Assert.False(first.TryGetProperty("d", out _), "private exponent d must not be published");
+ Assert.False(first.TryGetProperty("p", out _), "private prime p must not be published");
+ Assert.False(first.TryGetProperty("q", out _), "private prime q must not be published");
+ Assert.False(first.TryGetProperty("dp", out _), "dp must not be published");
+ Assert.False(first.TryGetProperty("dq", out _), "dq must not be published");
+ Assert.False(first.TryGetProperty("qi", out _), "qi must not be published");
+ }
+
+ public void Dispose() { _client?.Dispose(); _server?.Dispose(); }
+ }
+}
diff --git a/test/LtiAdvantage.IntegrationTests/Startup.cs b/test/LtiAdvantage.IntegrationTests/Startup.cs
index 0e8e895..6ce7fce 100644
--- a/test/LtiAdvantage.IntegrationTests/Startup.cs
+++ b/test/LtiAdvantage.IntegrationTests/Startup.cs
@@ -17,6 +17,8 @@ public void ConfigureServices(IServiceCollection services)
.AddScheme(JwtBearerDefaults.AuthenticationScheme, options => { });
services.AddLtiAdvantagePolicies();
+
+ services.AddSingleton();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
@@ -26,6 +28,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
+ endpoints.MapControllers();
endpoints.MapControllerRoute(
"default",
"{controller}/{action=Index}/{id?}");
diff --git a/test/LtiAdvantage.UnitTests/AssignmentGradeServices/LineItemShould.cs b/test/LtiAdvantage.UnitTests/AssignmentGradeServices/LineItemShould.cs
index 1d90da2..78bc31f 100644
--- a/test/LtiAdvantage.UnitTests/AssignmentGradeServices/LineItemShould.cs
+++ b/test/LtiAdvantage.UnitTests/AssignmentGradeServices/LineItemShould.cs
@@ -2,6 +2,7 @@
using System.Globalization;
using System.Text.Json;
using LtiAdvantage.AssignmentGradeServices;
+using LtiAdvantage.SubmissionReview;
using Xunit;
namespace LtiAdvantage.UnitTests.AssignmentGradeServices
@@ -41,5 +42,28 @@ public void SerializeToValidJson()
JsonAssert.Equal(referenceJson, lineItemJson);
}
+
+ [Fact]
+ public void RoundTripSubmissionReviewProperty()
+ {
+ var lineItem = new LineItem
+ {
+ Id = "https://platform.example.com/lineitems/1",
+ Label = "Essay",
+ ScoreMaximum = 10,
+ SubmissionReviewExtension = new SubmissionReviewProperty
+ {
+ Url = "https://tool.example.com/review",
+ Custom = new System.Collections.Generic.Dictionary { ["a"] = "1" }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(lineItem);
+ var roundTripped = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(roundTripped.SubmissionReviewExtension);
+ Assert.Equal("https://tool.example.com/review", roundTripped.SubmissionReviewExtension.Url);
+ Assert.Equal("1", roundTripped.SubmissionReviewExtension.Custom["a"]);
+ }
}
}
diff --git a/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs b/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs
new file mode 100644
index 0000000..cb63243
--- /dev/null
+++ b/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using LtiAdvantage.DynamicRegistration;
+using LtiAdvantage.IdentityModel.Client;
+using Xunit;
+
+namespace LtiAdvantage.UnitTests.DynamicRegistration
+{
+ public class HttpClientDynamicRegistrationExtensionsShould
+ {
+ [Fact]
+ public async Task FetchPlatformConfiguration_FromOpenidConfigurationUrl()
+ {
+ var configJson = TestUtils.LoadReferenceJsonFile("PlatformOpenIdConfiguration");
+ var handler = new StubHandler((req, ct) =>
+ {
+ Assert.Equal("https://platform.example.com/.well-known/openid-configuration",
+ req.RequestUri.ToString());
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(configJson, Encoding.UTF8, "application/json")
+ };
+ });
+ using var client = new HttpClient(handler);
+
+ var config = await client.GetPlatformOpenIdConfigurationAsync(
+ "https://platform.example.com/.well-known/openid-configuration");
+
+ Assert.Equal("https://platform.example.com", config.Issuer);
+ Assert.Equal("https://platform.example.com/lti/register", config.RegistrationEndpoint);
+ }
+
+ [Fact]
+ public async Task PostRegistration_WithBearerToken()
+ {
+ var responseJson = TestUtils.LoadReferenceJsonFile("ToolConfiguration");
+ // Pretend the platform echoed back with a client_id assigned.
+ var responseTool = JsonSerializer.Deserialize(responseJson);
+ responseTool.ClientId = "client-9";
+ var responseBody = JsonSerializer.Serialize(responseTool);
+
+ HttpRequestMessage capturedRequest = null;
+ var handler = new StubHandler((req, ct) =>
+ {
+ capturedRequest = req;
+ return new HttpResponseMessage(HttpStatusCode.Created)
+ {
+ Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
+ };
+ });
+ using var client = new HttpClient(handler);
+
+ var request = JsonSerializer.Deserialize(
+ TestUtils.LoadReferenceJsonFile("ToolConfiguration"));
+
+ var result = await client.RegisterToolAsync(
+ "https://platform.example.com/lti/register",
+ "registration-token-xyz",
+ request);
+
+ Assert.Equal("client-9", result.ClientId);
+ Assert.Equal("Bearer", capturedRequest.Headers.Authorization.Scheme);
+ Assert.Equal("registration-token-xyz", capturedRequest.Headers.Authorization.Parameter);
+ Assert.Equal("application/json", capturedRequest.Content.Headers.ContentType.MediaType);
+ }
+
+ [Fact]
+ public async Task SurfaceErrorBody_OnRegistrationFailure()
+ {
+ const string errorBody = """{"error":"invalid_redirect_uri","error_description":"bad uri"}""";
+ var handler = new StubHandler((req, ct) =>
+ new HttpResponseMessage(HttpStatusCode.BadRequest)
+ {
+ Content = new StringContent(errorBody, Encoding.UTF8, "application/json")
+ });
+ using var client = new HttpClient(handler);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ client.RegisterToolAsync(
+ "https://platform.example.com/lti/register",
+ "tok",
+ new ToolConfiguration()));
+
+ Assert.Contains("400", ex.Message);
+ Assert.Contains("invalid_redirect_uri", ex.Message);
+ }
+
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ private readonly Func _fn;
+ public StubHandler(Func fn) => _fn = fn;
+ protected override Task SendAsync(HttpRequestMessage r, CancellationToken c)
+ => Task.FromResult(_fn(r, c));
+ }
+ }
+}
diff --git a/test/LtiAdvantage.UnitTests/DynamicRegistration/PlatformOpenIdConfigurationShould.cs b/test/LtiAdvantage.UnitTests/DynamicRegistration/PlatformOpenIdConfigurationShould.cs
new file mode 100644
index 0000000..75022ee
--- /dev/null
+++ b/test/LtiAdvantage.UnitTests/DynamicRegistration/PlatformOpenIdConfigurationShould.cs
@@ -0,0 +1,32 @@
+using System.Text.Json;
+using LtiAdvantage.DynamicRegistration;
+using Xunit;
+
+namespace LtiAdvantage.UnitTests.DynamicRegistration
+{
+ public class PlatformOpenIdConfigurationShould
+ {
+ [Fact]
+ public void ParseSpecExample()
+ {
+ var json = TestUtils.LoadReferenceJsonFile("PlatformOpenIdConfiguration");
+ var config = JsonSerializer.Deserialize(json);
+
+ Assert.Equal("https://platform.example.com", config.Issuer);
+ Assert.Equal("https://platform.example.com/lti/register", config.RegistrationEndpoint);
+ Assert.Contains("private_key_jwt", config.TokenEndpointAuthMethodsSupported);
+ Assert.NotNull(config.LtiPlatformConfiguration);
+ Assert.Equal("ExamplePlatform", config.LtiPlatformConfiguration.ProductFamilyCode);
+ Assert.Equal(2, config.LtiPlatformConfiguration.MessagesSupported.Count);
+ }
+
+ [Fact]
+ public void RoundTrip()
+ {
+ var json = TestUtils.LoadReferenceJsonFile("PlatformOpenIdConfiguration");
+ var config = JsonSerializer.Deserialize(json);
+ var roundTripped = JsonSerializer.Serialize(config);
+ JsonAssert.Equal(json, roundTripped);
+ }
+ }
+}
diff --git a/test/LtiAdvantage.UnitTests/DynamicRegistration/ToolConfigurationShould.cs b/test/LtiAdvantage.UnitTests/DynamicRegistration/ToolConfigurationShould.cs
new file mode 100644
index 0000000..deb8153
--- /dev/null
+++ b/test/LtiAdvantage.UnitTests/DynamicRegistration/ToolConfigurationShould.cs
@@ -0,0 +1,32 @@
+using System.Text.Json;
+using LtiAdvantage.DynamicRegistration;
+using Xunit;
+
+namespace LtiAdvantage.UnitTests.DynamicRegistration
+{
+ public class ToolConfigurationShould
+ {
+ [Fact]
+ public void ParseSpecExample()
+ {
+ var json = TestUtils.LoadReferenceJsonFile("ToolConfiguration");
+ var tool = JsonSerializer.Deserialize(json);
+
+ Assert.Equal("web", tool.ApplicationType);
+ Assert.Equal("private_key_jwt", tool.TokenEndpointAuthMethod);
+ Assert.NotNull(tool.LtiToolConfiguration);
+ Assert.Equal("tool.example.com", tool.LtiToolConfiguration.Domain);
+ Assert.Single(tool.LtiToolConfiguration.Messages);
+ Assert.Equal("LtiDeepLinkingRequest", tool.LtiToolConfiguration.Messages[0].Type);
+ }
+
+ [Fact]
+ public void RoundTrip()
+ {
+ var json = TestUtils.LoadReferenceJsonFile("ToolConfiguration");
+ var tool = JsonSerializer.Deserialize(json);
+ var roundTripped = JsonSerializer.Serialize(tool);
+ JsonAssert.Equal(json, roundTripped);
+ }
+ }
+}
diff --git a/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj b/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj
index e13c564..c5fda50 100644
--- a/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj
+++ b/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj
@@ -10,9 +10,12 @@
+
+
+
@@ -25,6 +28,12 @@
Always
+
+ Always
+
+
+ Always
+
Always
@@ -34,6 +43,9 @@
Always
+
+ Always
+
@@ -51,6 +63,7 @@
+
diff --git a/test/LtiAdvantage.UnitTests/ReferenceJson/LtiSubmissionReviewRequest.json b/test/LtiAdvantage.UnitTests/ReferenceJson/LtiSubmissionReviewRequest.json
new file mode 100644
index 0000000..bf126a9
--- /dev/null
+++ b/test/LtiAdvantage.UnitTests/ReferenceJson/LtiSubmissionReviewRequest.json
@@ -0,0 +1,24 @@
+{
+ "iss": "https://platform.example.com",
+ "aud": ["abcd-1234"],
+ "sub": "instructor-9",
+ "exp": 1933333333,
+ "iat": 1933332433,
+ "nonce": "nonce-1",
+ "https://purl.imsglobal.org/spec/lti/claim/message_type": "LtiSubmissionReviewRequest",
+ "https://purl.imsglobal.org/spec/lti/claim/version": "1.3.0",
+ "https://purl.imsglobal.org/spec/lti/claim/deployment_id": "dep-1",
+ "https://purl.imsglobal.org/spec/lti/claim/target_link_uri": "https://tool.example.com/review",
+ "https://purl.imsglobal.org/spec/lti/claim/resource_link": { "id": "rl-1" },
+ "https://purl.imsglobal.org/spec/lti/claim/roles": [
+ "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor"
+ ],
+ "https://purl.imsglobal.org/spec/lti-sr/claim/for_user": {
+ "user_id": "student-7",
+ "name": "Jane Doe",
+ "given_name": "Jane",
+ "family_name": "Doe",
+ "email": "jane@example.edu",
+ "roles": ["http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"]
+ }
+}
diff --git a/test/LtiAdvantage.UnitTests/ReferenceJson/PlatformOpenIdConfiguration.json b/test/LtiAdvantage.UnitTests/ReferenceJson/PlatformOpenIdConfiguration.json
new file mode 100644
index 0000000..f44bf66
--- /dev/null
+++ b/test/LtiAdvantage.UnitTests/ReferenceJson/PlatformOpenIdConfiguration.json
@@ -0,0 +1,28 @@
+{
+ "issuer": "https://platform.example.com",
+ "authorization_endpoint": "https://platform.example.com/auth",
+ "token_endpoint": "https://platform.example.com/token",
+ "token_endpoint_auth_methods_supported": ["private_key_jwt"],
+ "token_endpoint_auth_signing_alg_values_supported": ["RS256"],
+ "jwks_uri": "https://platform.example.com/.well-known/jwks.json",
+ "registration_endpoint": "https://platform.example.com/lti/register",
+ "scopes_supported": [
+ "openid",
+ "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
+ "https://purl.imsglobal.org/spec/lti-ags/scope/score",
+ "https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly"
+ ],
+ "response_types_supported": ["id_token"],
+ "subject_types_supported": ["public"],
+ "id_token_signing_alg_values_supported": ["RS256"],
+ "claims_supported": ["sub", "iss", "name", "email"],
+ "https://purl.imsglobal.org/spec/lti-platform-configuration": {
+ "product_family_code": "ExamplePlatform",
+ "version": "1.0",
+ "messages_supported": [
+ { "type": "LtiResourceLinkRequest" },
+ { "type": "LtiDeepLinkingRequest", "placements": ["ContentArea", "RichTextEditor"] }
+ ],
+ "variables": ["CourseSection.sourcedId", "Person.email.primary"]
+ }
+}
diff --git a/test/LtiAdvantage.UnitTests/ReferenceJson/ToolConfiguration.json b/test/LtiAdvantage.UnitTests/ReferenceJson/ToolConfiguration.json
new file mode 100644
index 0000000..ae6ae44
--- /dev/null
+++ b/test/LtiAdvantage.UnitTests/ReferenceJson/ToolConfiguration.json
@@ -0,0 +1,28 @@
+{
+ "application_type": "web",
+ "response_types": ["id_token"],
+ "grant_types": ["implicit", "client_credentials"],
+ "initiate_login_uri": "https://tool.example.com/login",
+ "redirect_uris": ["https://tool.example.com/launch"],
+ "client_name": "Example Tool",
+ "client_uri": "https://tool.example.com",
+ "logo_uri": "https://tool.example.com/logo.png",
+ "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly",
+ "token_endpoint_auth_method": "private_key_jwt",
+ "jwks_uri": "https://tool.example.com/.well-known/jwks.json",
+ "https://purl.imsglobal.org/spec/lti-tool-configuration": {
+ "domain": "tool.example.com",
+ "description": "An example LTI 1.3 tool",
+ "target_link_uri": "https://tool.example.com/launch",
+ "custom_parameters": { "context_history": "$Context.id.history" },
+ "claims": ["iss", "sub", "name", "email"],
+ "messages": [
+ {
+ "type": "LtiDeepLinkingRequest",
+ "target_link_uri": "https://tool.example.com/dl",
+ "label": "Add example content",
+ "placements": ["ContentArea"]
+ }
+ ]
+ }
+}
diff --git a/test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs b/test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs
new file mode 100644
index 0000000..d790f14
--- /dev/null
+++ b/test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs
@@ -0,0 +1,50 @@
+using System.Text.Json;
+using LtiAdvantage.Lti;
+using LtiAdvantage.SubmissionReview;
+using Xunit;
+
+namespace LtiAdvantage.UnitTests.SubmissionReview
+{
+ public class LtiSubmissionReviewRequestShould
+ {
+ [Fact]
+ public void HaveCorrectMessageTypeAndVersion()
+ {
+ var request = new LtiSubmissionReviewRequest();
+
+ Assert.True(request.TryGetValue(
+ "https://purl.imsglobal.org/spec/lti/claim/message_type", out var messageType));
+ Assert.Equal("LtiSubmissionReviewRequest", messageType);
+
+ Assert.True(request.TryGetValue(
+ "https://purl.imsglobal.org/spec/lti/claim/version", out var version));
+ Assert.Equal("1.3.0", version);
+ }
+
+ [Fact]
+ public void RoundTripForUserClaim()
+ {
+ var request = new LtiSubmissionReviewRequest
+ {
+ ForUser = new ForUserClaimValueType
+ {
+ UserId = "abc-123",
+ Name = "Jane Doe",
+ Email = "jane@example.edu"
+ }
+ };
+
+ Assert.Equal("abc-123", request.ForUser.UserId);
+ Assert.Equal("Jane Doe", request.ForUser.Name);
+ }
+
+ [Fact]
+ public void ParseValidLtiSubmissionReviewRequest()
+ {
+ var referenceJson = TestUtils.LoadReferenceJsonFile("LtiSubmissionReviewRequest");
+ var request = JsonSerializer.Deserialize(referenceJson);
+ var requestJson = JsonSerializer.Serialize(request);
+ JsonAssert.Equal(referenceJson, requestJson);
+ }
+ }
+}