From 701abe3596a1c17a565bc500b0932c7e0560f934 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 17:42:53 +0200 Subject: [PATCH 01/18] chore: ignore .worktrees/ directory --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 56ef65147e0baaea02799a0745c33e2f5a0becd4 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 17:46:56 +0200 Subject: [PATCH 02/18] feat(sr): add Submission Review message type and for_user claim constants --- src/LtiAdvantage/Constants.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/LtiAdvantage/Constants.cs b/src/LtiAdvantage/Constants.cs index de5b58b..e4f4de4 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. /// From 198ac81040d8e4e307d210de06b2e5f75cc757a2 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 17:51:07 +0200 Subject: [PATCH 03/18] feat(sr): add LtiSubmissionReviewRequest and ForUserClaimValueType --- .../Lti/LtiSubmissionReviewRequest.cs | 77 +++++++++++++++++++ .../SubmissionReview/ForUserClaimValueType.cs | 40 ++++++++++ .../LtiSubmissionReviewRequestShould.cs | 40 ++++++++++ 3 files changed, 157 insertions(+) create mode 100644 src/LtiAdvantage/Lti/LtiSubmissionReviewRequest.cs create mode 100644 src/LtiAdvantage/SubmissionReview/ForUserClaimValueType.cs create mode 100644 test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs 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/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/test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs b/test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs new file mode 100644 index 0000000..f8627bf --- /dev/null +++ b/test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs @@ -0,0 +1,40 @@ +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); + } + } +} From 7170c2b7aaec7e8b89b65130390aa4fc9ce8e3ad Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 17:56:07 +0200 Subject: [PATCH 04/18] test(sr): add reference-JSON round-trip for LtiSubmissionReviewRequest --- .../LtiAdvantage.UnitTests.csproj | 4 ++++ .../LtiSubmissionReviewRequest.json | 24 +++++++++++++++++++ .../LtiSubmissionReviewRequestShould.cs | 10 ++++++++ 3 files changed, 38 insertions(+) create mode 100644 test/LtiAdvantage.UnitTests/ReferenceJson/LtiSubmissionReviewRequest.json diff --git a/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj b/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj index e13c564..71874c6 100644 --- a/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj +++ b/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj @@ -10,6 +10,7 @@ + @@ -25,6 +26,9 @@ Always + + Always + Always 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/SubmissionReview/LtiSubmissionReviewRequestShould.cs b/test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs index f8627bf..d790f14 100644 --- a/test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs +++ b/test/LtiAdvantage.UnitTests/SubmissionReview/LtiSubmissionReviewRequestShould.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using LtiAdvantage.Lti; using LtiAdvantage.SubmissionReview; using Xunit; @@ -36,5 +37,14 @@ public void RoundTripForUserClaim() 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); + } } } From 57fa05fb90c20880f01a28ec488df1300d760eb3 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:01:40 +0200 Subject: [PATCH 05/18] feat(sr): expose submissionReview extension on AGS LineItem --- .../AssignmentGradeServices/LineItem.cs | 9 +++++++ .../SubmissionReviewProperty.cs | 25 +++++++++++++++++++ .../AssignmentGradeServices/LineItemShould.cs | 24 ++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 src/LtiAdvantage/SubmissionReview/SubmissionReviewProperty.cs 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/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.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"]); + } } } From f2d0bdb9dc25c9d22f35dd0a796ff0fd9ee65aad Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:06:57 +0200 Subject: [PATCH 06/18] feat(jwks): add IJwksKeyStore abstraction and Jwks endpoint constants --- src/LtiAdvantage/Constants.cs | 14 ++++++++++++++ src/LtiAdvantage/Jwks/IJwksKeyStore.cs | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 src/LtiAdvantage/Jwks/IJwksKeyStore.cs diff --git a/src/LtiAdvantage/Constants.cs b/src/LtiAdvantage/Constants.cs index e4f4de4..ed8b401 100644 --- a/src/LtiAdvantage/Constants.cs +++ b/src/LtiAdvantage/Constants.cs @@ -250,6 +250,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 /// @@ -379,6 +384,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/Jwks/IJwksKeyStore.cs b/src/LtiAdvantage/Jwks/IJwksKeyStore.cs new file mode 100644 index 0000000..e013b8c --- /dev/null +++ b/src/LtiAdvantage/Jwks/IJwksKeyStore.cs @@ -0,0 +1,21 @@ +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 that should appear in the JWKS document. + /// Each key MUST have Kid set; Use SHOULD be "sig"; Alg SHOULD be "RS256". + /// + Task> GetPublicKeysAsync(); + } +} From bb4cb5f319edbb5790dc629dcbb4c941f086b29a Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:12:48 +0200 Subject: [PATCH 07/18] feat(jwks): add JwksControllerBase publishing public keys at /.well-known/jwks.json --- .../Jwks/IJwksController.cs | 13 +++++ .../Jwks/JwksControllerBase.cs | 47 ++++++++++++++++++ .../Controllers/JwksController.cs | 35 ++++++++++++++ .../Jwks/JwksControllerShould.cs | 48 +++++++++++++++++++ test/LtiAdvantage.IntegrationTests/Startup.cs | 3 ++ 5 files changed, 146 insertions(+) create mode 100644 src/LtiAdvantage.AspNetCore/Jwks/IJwksController.cs create mode 100644 src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs create mode 100644 test/LtiAdvantage.IntegrationTests/Controllers/JwksController.cs create mode 100644 test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs 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..8efaec8 --- /dev/null +++ b/src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs @@ -0,0 +1,47 @@ +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() + { + var keys = await _keyStore.GetPublicKeysAsync().ConfigureAwait(false); + var set = new JsonWebKeySet(); + foreach (var k in keys) set.Keys.Add(k); + return set; + } + } +} 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/Jwks/JwksControllerShould.cs b/test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs new file mode 100644 index 0000000..73a8653 --- /dev/null +++ b/test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs @@ -0,0 +1,48 @@ +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 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?}"); From 04c93bd499d36920c14e9ca5b36b13a482efae98 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:19:01 +0200 Subject: [PATCH 08/18] test(jwks): assert no private-key fields in JWKS response; log entry/exit --- .../Jwks/JwksControllerBase.cs | 16 ++++++++++++---- .../Jwks/JwksControllerShould.cs | 13 +++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs b/src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs index 8efaec8..2eb2974 100644 --- a/src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs +++ b/src/LtiAdvantage.AspNetCore/Jwks/JwksControllerBase.cs @@ -38,10 +38,18 @@ protected JwksControllerBase(IJwksKeyStore keyStore, ILogger [Route(".well-known/jwks.json", Name = Constants.ServiceEndpoints.Jwks.JwksService)] public async Task> GetJwksAsync() { - var keys = await _keyStore.GetPublicKeysAsync().ConfigureAwait(false); - var set = new JsonWebKeySet(); - foreach (var k in keys) set.Keys.Add(k); - return set; + _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/test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs b/test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs index 73a8653..edb6613 100644 --- a/test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs +++ b/test/LtiAdvantage.IntegrationTests/Jwks/JwksControllerShould.cs @@ -41,6 +41,19 @@ public async Task ReturnPublicJwks_Anonymously() 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(); } From 8c5716e067ed97ad56e93cf6b42be437284db689 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:20:51 +0200 Subject: [PATCH 09/18] docs(jwks): document key-rotation contract on IJwksKeyStore --- src/LtiAdvantage/Jwks/IJwksKeyStore.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/LtiAdvantage/Jwks/IJwksKeyStore.cs b/src/LtiAdvantage/Jwks/IJwksKeyStore.cs index e013b8c..9ab2423 100644 --- a/src/LtiAdvantage/Jwks/IJwksKeyStore.cs +++ b/src/LtiAdvantage/Jwks/IJwksKeyStore.cs @@ -13,8 +13,15 @@ namespace LtiAdvantage.Jwks public interface IJwksKeyStore { /// - /// Returns the public keys that should appear in the JWKS document. - /// Each key MUST have Kid set; Use SHOULD be "sig"; Alg SHOULD be "RS256". + /// 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(); } From 5fa295143fdb283876b89e7c96eb1bb9e5645088 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:22:26 +0200 Subject: [PATCH 10/18] feat(dynreg): add Dynamic Registration claim and scope constants --- src/LtiAdvantage/Constants.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/LtiAdvantage/Constants.cs b/src/LtiAdvantage/Constants.cs index ed8b401..8ee66de 100644 --- a/src/LtiAdvantage/Constants.cs +++ b/src/LtiAdvantage/Constants.cs @@ -151,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. /// @@ -233,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. /// From 59f307eeab7dbb31a94b78248ae2edcb436bc68b Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:26:10 +0200 Subject: [PATCH 11/18] feat(dynreg): add PlatformOpenIdConfiguration and supporting models --- .../LtiPlatformConfiguration.cs | 28 ++++++++ .../DynamicRegistration/MessageDescriptor.cs | 41 ++++++++++++ .../PlatformOpenIdConfiguration.cs | 65 +++++++++++++++++++ .../PlatformOpenIdConfigurationShould.cs | 32 +++++++++ .../LtiAdvantage.UnitTests.csproj | 4 ++ .../PlatformOpenIdConfiguration.json | 28 ++++++++ 6 files changed, 198 insertions(+) create mode 100644 src/LtiAdvantage/DynamicRegistration/LtiPlatformConfiguration.cs create mode 100644 src/LtiAdvantage/DynamicRegistration/MessageDescriptor.cs create mode 100644 src/LtiAdvantage/DynamicRegistration/PlatformOpenIdConfiguration.cs create mode 100644 test/LtiAdvantage.UnitTests/DynamicRegistration/PlatformOpenIdConfigurationShould.cs create mode 100644 test/LtiAdvantage.UnitTests/ReferenceJson/PlatformOpenIdConfiguration.json 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/MessageDescriptor.cs b/src/LtiAdvantage/DynamicRegistration/MessageDescriptor.cs new file mode 100644 index 0000000..9b2b17e --- /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 LtiToolConfiguration (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/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/LtiAdvantage.UnitTests.csproj b/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj index 71874c6..f46774a 100644 --- a/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj +++ b/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj @@ -11,6 +11,7 @@ + @@ -29,6 +30,9 @@ Always + + Always + Always 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"] + } +} From b205bf9fef8569d148e901ef7e484ad0a974028c Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:32:52 +0200 Subject: [PATCH 12/18] feat(dynreg): add ToolConfiguration registration request model --- .../LtiToolConfiguration.cs | 44 +++++++++++++ .../DynamicRegistration/MessageDescriptor.cs | 2 +- .../DynamicRegistration/ToolConfiguration.cs | 65 +++++++++++++++++++ .../ToolConfigurationShould.cs | 32 +++++++++ .../LtiAdvantage.UnitTests.csproj | 4 ++ .../ReferenceJson/ToolConfiguration.json | 28 ++++++++ 6 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/LtiAdvantage/DynamicRegistration/LtiToolConfiguration.cs create mode 100644 src/LtiAdvantage/DynamicRegistration/ToolConfiguration.cs create mode 100644 test/LtiAdvantage.UnitTests/DynamicRegistration/ToolConfigurationShould.cs create mode 100644 test/LtiAdvantage.UnitTests/ReferenceJson/ToolConfiguration.json 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 index 9b2b17e..b2300c6 100644 --- a/src/LtiAdvantage/DynamicRegistration/MessageDescriptor.cs +++ b/src/LtiAdvantage/DynamicRegistration/MessageDescriptor.cs @@ -6,7 +6,7 @@ namespace LtiAdvantage.DynamicRegistration /// /// Describes a supported LTI message type. Used in both /// (what the platform launches) - /// and LtiToolConfiguration (what the tool can receive). + /// and (what the tool can receive). /// public class MessageDescriptor { 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/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 f46774a..ca0d6ba 100644 --- a/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj +++ b/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj @@ -15,6 +15,7 @@ + @@ -42,6 +43,9 @@ Always + + Always + 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"] + } + ] + } +} From ccdd585618dba28144403c0f4f6df8c6e1a93a16 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:40:40 +0200 Subject: [PATCH 13/18] feat(dynreg): tool-side HttpClient extensions for OIDC discovery and registration Add GetPlatformOpenIdConfigurationAsync and RegisterToolAsync extension methods to HttpClient implementing the tool side of LTI Dynamic Registration 1.0 (Task C4). Adds project reference from LtiAdvantage.IdentityModel to LtiAdvantage core (required for the DynamicRegistration models), and from the unit-test project to LtiAdvantage.IdentityModel. --- ...HttpClientDynamicRegistrationExtensions.cs | 73 +++++++++++++++++ .../LtiAdvantage.IdentityModel.csproj | 4 + ...ientDynamicRegistrationExtensionsShould.cs | 80 +++++++++++++++++++ .../LtiAdvantage.UnitTests.csproj | 1 + 4 files changed, 158 insertions(+) create mode 100644 src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs create mode 100644 test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs diff --git a/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs b/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs new file mode 100644 index 0000000..675bc93 --- /dev/null +++ b/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs @@ -0,0 +1,73 @@ +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); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync().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); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync().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/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs b/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs new file mode 100644 index 0000000..1ecd812 --- /dev/null +++ b/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs @@ -0,0 +1,80 @@ +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); + } + + 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/LtiAdvantage.UnitTests.csproj b/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj index ca0d6ba..c5fda50 100644 --- a/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj +++ b/test/LtiAdvantage.UnitTests/LtiAdvantage.UnitTests.csproj @@ -63,6 +63,7 @@ + From 07f5b31ee3c9bc32a16512c15ad6097b8d5bcce1 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:45:36 +0200 Subject: [PATCH 14/18] feat(dynreg): flow cancellation token + surface platform error body --- ...HttpClientDynamicRegistrationExtensions.cs | 18 ++++++++++++---- ...ientDynamicRegistrationExtensionsShould.cs | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs b/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs index 675bc93..b2064a6 100644 --- a/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs +++ b/src/LtiAdvantage.IdentityModel/Client/HttpClientDynamicRegistrationExtensions.cs @@ -28,8 +28,13 @@ public static async Task GetPlatformOpenIdConfigura throw new ArgumentException("URL is required", nameof(openIdConfigurationUrl)); using var response = await client.GetAsync(openIdConfigurationUrl, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStringAsync().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); } @@ -65,8 +70,13 @@ public static async Task RegisterToolAsync( request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", registrationAccessToken); using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStringAsync().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/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs b/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs index 1ecd812..cb63243 100644 --- a/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs +++ b/test/LtiAdvantage.UnitTests/DynamicRegistration/HttpClientDynamicRegistrationExtensionsShould.cs @@ -69,6 +69,27 @@ public async Task PostRegistration_WithBearerToken() 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; From cd2d767b66587e86dea8655aa378c295f5a1a63a Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:50:26 +0200 Subject: [PATCH 15/18] feat(dynreg): platform-side DynamicRegistrationControllerBase Adds the platform-side LTI Dynamic Registration 1.0 endpoint as an abstract MVC controller base, matching the pattern used by MembershipControllerBase. The endpoint enforces the https://purl.imsglobal.org/spec/lti-reg/scope/registration scope via [Authorize] policy and delegates persistence to OnRegisterAsync, which implementations override to assign the client_id. Includes a TestServer integration test covering the success path (201 + assigned ClientId) and the insufficient-scope path (403). --- .../DynamicRegistrationControllerBase.cs | 76 ++++++++++++++++++ .../IDynamicRegistrationController.cs | 13 +++ .../RegisterToolRequest.cs | 15 ++++ .../DynamicRegistrationController.cs | 23 ++++++ .../DynamicRegistrationControllerShould.cs | 80 +++++++++++++++++++ 5 files changed, 207 insertions(+) create mode 100644 src/LtiAdvantage.AspNetCore/DynamicRegistration/DynamicRegistrationControllerBase.cs create mode 100644 src/LtiAdvantage.AspNetCore/DynamicRegistration/IDynamicRegistrationController.cs create mode 100644 src/LtiAdvantage.AspNetCore/DynamicRegistration/RegisterToolRequest.cs create mode 100644 test/LtiAdvantage.IntegrationTests/Controllers/DynamicRegistrationController.cs create mode 100644 test/LtiAdvantage.IntegrationTests/DynamicRegistration/DynamicRegistrationControllerShould.cs 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/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/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(); } + } +} From cfe8952e4b4ffe1acab88414cf3c3eada4612552 Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:55:07 +0200 Subject: [PATCH 16/18] docs: announce Submission Review, JWKS, Dynamic Registration --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index e1c17eb..f97cf42 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 (`LtiSubmissionReviewRequest`, `for_user` claim, AGS `submissionReview` extension) +- JWKS publishing (`JwksControllerBase` + `IJwksKeyStore`) for `/.well-known/jwks.json` +- LTI Dynamic Registration 1.0 — tool-side `HttpClient` extensions and platform-side `DynamicRegistrationControllerBase` + ## NuGet | Library | Release | Prerelease | | --- | --- | --- | From 12b9e414063ddef97197e8927cb645009dcc661b Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 18:55:58 +0200 Subject: [PATCH 17/18] docs: regenerate XML doc files for Tier 1 changes --- .../LtiAdvantage.AspNetCore.xml | 59 ++++ src/LtiAdvantage/LtiAdvantage.xml | 315 ++++++++++++++++++ 2 files changed, 374 insertions(+) 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/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. From bb050690ac6bbae4d2856df2a6bcaa99aee81adf Mon Sep 17 00:00:00 2001 From: Sander Rijken Date: Sun, 3 May 2026 19:45:23 +0200 Subject: [PATCH 18/18] docs: link new features to LTI spec URLs in README --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f97cf42..e650a12 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ ### What's new -- LTI Submission Review 1.0 (`LtiSubmissionReviewRequest`, `for_user` claim, AGS `submissionReview` extension) -- JWKS publishing (`JwksControllerBase` + `IJwksKeyStore`) for `/.well-known/jwks.json` -- LTI Dynamic Registration 1.0 — tool-side `HttpClient` extensions and platform-side `DynamicRegistrationControllerBase` +- [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 |