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); + } + } +}