diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..31b9a0b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "shared/LtiAdvantage"] + path = shared/LtiAdvantage + url = https://github.com/learningcom/LtiAdvantage diff --git a/AdvantageTool.sln b/AdvantageTool.sln index 046b74b..4253cb2 100644 --- a/AdvantageTool.sln +++ b/AdvantageTool.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.28010.2019 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30804.86 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{777D9453-8256-4AD3-ADDF-BE34047DFFEB}" ProjectSection(SolutionItems) = preProject @@ -10,9 +10,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdvantageTool", "src\AdvantageTool.csproj", "{4EBF015D-9B8C-4A2E-8A72-658F931718C0}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LtiAdvantage", "..\..\LTI\LtiAdvantage\src\LtiAdvantage\LtiAdvantage.csproj", "{097C6757-60C8-44C2-B378-F9A8E3A08377}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LtiAdvantage", "shared\LtiAdvantage\src\LtiAdvantage\LtiAdvantage.csproj", "{52E66378-7670-4E6E-978C-D3B16883506C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LtiAdvantage.IdentityModel", "..\..\LTI\LtiAdvantage\src\LtiAdvantage.IdentityModel\LtiAdvantage.IdentityModel.csproj", "{C55CD2BF-0A0E-43F2-A285-E82904CCD727}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LtiAdvantage.IdentityModel", "shared\LtiAdvantage\src\LtiAdvantage.IdentityModel\LtiAdvantage.IdentityModel.csproj", "{B04A07BC-C551-478E-8B6C-377D219EC8FF}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -24,14 +24,14 @@ Global {4EBF015D-9B8C-4A2E-8A72-658F931718C0}.Debug|Any CPU.Build.0 = Debug|Any CPU {4EBF015D-9B8C-4A2E-8A72-658F931718C0}.Release|Any CPU.ActiveCfg = Release|Any CPU {4EBF015D-9B8C-4A2E-8A72-658F931718C0}.Release|Any CPU.Build.0 = Release|Any CPU - {097C6757-60C8-44C2-B378-F9A8E3A08377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {097C6757-60C8-44C2-B378-F9A8E3A08377}.Debug|Any CPU.Build.0 = Debug|Any CPU - {097C6757-60C8-44C2-B378-F9A8E3A08377}.Release|Any CPU.ActiveCfg = Release|Any CPU - {097C6757-60C8-44C2-B378-F9A8E3A08377}.Release|Any CPU.Build.0 = Release|Any CPU - {C55CD2BF-0A0E-43F2-A285-E82904CCD727}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C55CD2BF-0A0E-43F2-A285-E82904CCD727}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C55CD2BF-0A0E-43F2-A285-E82904CCD727}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C55CD2BF-0A0E-43F2-A285-E82904CCD727}.Release|Any CPU.Build.0 = Release|Any CPU + {52E66378-7670-4E6E-978C-D3B16883506C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {52E66378-7670-4E6E-978C-D3B16883506C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {52E66378-7670-4E6E-978C-D3B16883506C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {52E66378-7670-4E6E-978C-D3B16883506C}.Release|Any CPU.Build.0 = Release|Any CPU + {B04A07BC-C551-478E-8B6C-377D219EC8FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B04A07BC-C551-478E-8B6C-377D219EC8FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B04A07BC-C551-478E-8B6C-377D219EC8FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B04A07BC-C551-478E-8B6C-377D219EC8FF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 9bd6a2e..631c42e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ # LTI Advantage Tool -Sample LTI Advantage Tool for .NET Core. See https://advantagetool.azurewebsites.net/ +Most of the interesting stuff is in [src/Pages/Tool.cshtml.cs](src/Pages/Tool.cshtml.cs) -Most of the interesting stuff is in https://github.com/andyfmiller/LtiAdvantageTool/blob/master/src/Pages/Tool.cshtml.cs +## Useful Links + +Debugging Tools + +* [OpenID Connect debugger](https://oidcdebugger.com/) + +LTI Specifications +* [Learning Tools Interoperability Core Specification 1.3 | IMS Global Learning Consortium](https://www.imsglobal.org/spec/lti/v1p3/) +* [Learning Tools Interoperability Names and Role Provisioning Services Version 2.0 | IMS Global Learning Consortium](https://www.imsglobal.org/spec/lti-nrps/v2p0) +* [Learning Tools Interoperability Assignment and Grade Services Version 2.0 | IMS Global Learning Consortium](https://www.imsglobal.org/spec/lti-ags/v2p0) \ No newline at end of file diff --git a/Start-AdvantageTool.ps1 b/Start-AdvantageTool.ps1 new file mode 100644 index 0000000..2e9d313 --- /dev/null +++ b/Start-AdvantageTool.ps1 @@ -0,0 +1 @@ +dotnet run --project .\src\AdvantageTool.csproj \ No newline at end of file diff --git a/shared/LtiAdvantage b/shared/LtiAdvantage new file mode 160000 index 0000000..b6272de --- /dev/null +++ b/shared/LtiAdvantage @@ -0,0 +1 @@ +Subproject commit b6272de80e1f2ce5f6c499f9c7e095d927b15213 diff --git a/src/AdvantageTool.csproj b/src/AdvantageTool.csproj index c218444..287d61b 100644 --- a/src/AdvantageTool.csproj +++ b/src/AdvantageTool.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/src/Pages/Catalog.cshtml.cs b/src/Pages/Catalog.cshtml.cs index 56a714f..209e8fd 100644 --- a/src/Pages/Catalog.cshtml.cs +++ b/src/Pages/Catalog.cshtml.cs @@ -107,15 +107,18 @@ public async Task OnPostAssignActivities() var contentItems = new List(); var customParameters = LtiRequest.Custom; + var platform = await _context.GetPlatformByIssuerAsync(LtiRequest.Iss); + foreach (var activity in Activities) { if (activity.Selected) { + var url = Url.Page("/Tool", null, new { platformId = platform.PlatformId }, Request.Scheme); var contentItem = new LtiLinkItem { Title = activity.Title, Text = activity.Description, - Url = Url.Page("./Tool", null, null, Request.Scheme), + Url = url, Custom = new Dictionary { { "activity_id", activity.Id.ToString() } @@ -143,11 +146,10 @@ public async Task OnPostAssignActivities() response.AddClaim(new Claim(JwtRegisteredClaimNames.Exp, EpochTime.GetIntDate(DateTime.UtcNow.AddMinutes(5)).ToString())); response.AddClaim(new Claim(JwtRegisteredClaimNames.Nonce, IdentityModel.CryptoRandom.CreateRandomKeyString(8))); - var platform = await _context.GetPlatformByIssuerAsync(LtiRequest.Iss); var credentials = PemHelper.SigningCredentialsFromPemString(platform.PrivateKey); var jwt = handler.WriteToken(new JwtSecurityToken(new JwtHeader(credentials), response)); - return Post("id_token", jwt, LtiRequest.DeepLinkingSettings.DeepLinkReturnUrl); + return Post("JWT", jwt, LtiRequest.DeepLinkingSettings.DeepLinkReturnUrl); } /// diff --git a/src/Pages/Components/LineItems/Default.cshtml b/src/Pages/Components/LineItems/Default.cshtml index 72591f0..befdc22 100644 --- a/src/Pages/Components/LineItems/Default.cshtml +++ b/src/Pages/Components/LineItems/Default.cshtml @@ -3,17 +3,22 @@ @if (Model != null) {
-
+
Gradebook -
+
+
+ + + +
@Model.Status @if (Model.LineItems == null) { @@ -29,7 +34,16 @@ @foreach (var lineItem in Model.LineItems.OrderBy(l => l.Header)) { - @lineItem.Header + + @lineItem.Header + @{var deleteRouteData = new Dictionary { { "lineItemUrl", lineItem.AgsLineItem.Id } };} + + } @@ -74,4 +88,41 @@
-} \ No newline at end of file +
+
+
+
+ Members +
+
+ @if (Model.Members == null) + { +

+ This context does not have any members. +

+ } + else + { + + + + + + + + @foreach (var member in Model.Members) + { + + + + } + +
UserId
+ @member.Key +
+ } +
+
+
+
+ } diff --git a/src/Pages/Components/LineItems/LineItemsViewComponent.cs b/src/Pages/Components/LineItems/LineItemsViewComponent.cs index 9458a42..7fd16df 100644 --- a/src/Pages/Components/LineItems/LineItemsViewComponent.cs +++ b/src/Pages/Components/LineItems/LineItemsViewComponent.cs @@ -62,16 +62,19 @@ public async Task InvokeAsync(string idToken) return View(model); } - // Get all the line items + // Get all the members of the course + model.Members = new Dictionary(); + try { var httpClient = _httpClientFactory.CreateClient(); httpClient.SetBearerToken(tokenResponse.AccessToken); + httpClient.DefaultRequestHeaders.Accept.Clear(); httpClient.DefaultRequestHeaders.Accept - .Add(new MediaTypeWithQualityHeaderValue(Constants.MediaTypes.LineItemContainer)); + .Add(new MediaTypeWithQualityHeaderValue(Constants.MediaTypes.MembershipContainer)); - using (var response = await httpClient.GetAsync(model.LtiRequest.AssignmentGradeServices?.LineItemsUrl)) + using (var response = await httpClient.GetAsync(model.LtiRequest.NamesRoleService.ContextMembershipUrl)) { if (!response.IsSuccessStatusCode) { @@ -80,34 +83,32 @@ public async Task InvokeAsync(string idToken) } var content = await response.Content.ReadAsStringAsync(); - model.LineItems = JsonConvert.DeserializeObject>(content) - .Select(i => new MyLineItem + var membership = JsonConvert.DeserializeObject(content); + foreach (var member in membership.Members.OrderBy(m => m.FamilyName).ThenBy(m => m.GivenName)) + { + if (!model.Members.ContainsKey(member.UserId)) { - AgsLineItem = i, - Header = i.Label ?? $"Tag: {i.Tag}" - }) - .ToList(); + model.Members.Add(member.UserId, $"{member.FamilyName}, {member.GivenName}"); + } + } } } catch (Exception e) { model.Status = e.Message; - return View(); + return View(model); } - // Get all the members of the course - model.Members = new Dictionary(); - + // Get all the line items try { var httpClient = _httpClientFactory.CreateClient(); httpClient.SetBearerToken(tokenResponse.AccessToken); - httpClient.DefaultRequestHeaders.Accept.Clear(); httpClient.DefaultRequestHeaders.Accept - .Add(new MediaTypeWithQualityHeaderValue(Constants.MediaTypes.MembershipContainer)); + .Add(new MediaTypeWithQualityHeaderValue(Constants.MediaTypes.LineItemContainer)); - using (var response = await httpClient.GetAsync(model.LtiRequest.NamesRoleService.ContextMembershipUrl)) + using (var response = await httpClient.GetAsync(model.LtiRequest.AssignmentGradeServices?.LineItemsUrl)) { if (!response.IsSuccessStatusCode) { @@ -116,14 +117,13 @@ public async Task InvokeAsync(string idToken) } var content = await response.Content.ReadAsStringAsync(); - var membership = JsonConvert.DeserializeObject(content); - foreach (var member in membership.Members.OrderBy(m => m.FamilyName).ThenBy(m => m.GivenName)) - { - if (!model.Members.ContainsKey(member.UserId)) + model.LineItems = JsonConvert.DeserializeObject>(content) + .Select(i => new MyLineItem { - model.Members.Add(member.UserId, $"{member.FamilyName}, {member.GivenName}"); - } - } + AgsLineItem = i, + Header = i.Label ?? $"Tag: {i.Tag}" + }) + .ToList(); } } catch (Exception e) diff --git a/src/Pages/Tool.cshtml b/src/Pages/Tool.cshtml index bf45bfe..c8d4900 100644 --- a/src/Pages/Tool.cshtml +++ b/src/Pages/Tool.cshtml @@ -74,14 +74,14 @@
Platform Details
- @(Model.LtiRequest?.Platform.Name ?? "(no name)") + @(Model.LtiRequest?.Platform?.Name ?? "(no name)")
- @(Model.LtiRequest?.Platform.Description ?? "(no description)") + @(Model.LtiRequest?.Platform?.Description ?? "(no description)")
- @(Model.LtiRequest?.Platform.ProductFamilyCode ?? "(no family code)") - @(Model.LtiRequest?.Platform.Version ?? "(no version)") + @(Model.LtiRequest?.Platform?.ProductFamilyCode ?? "(no family code)") + @(Model.LtiRequest?.Platform?.Version ?? "(no version)")
@@ -100,7 +100,7 @@ @(Model.LtiRequest?.Email ?? "(no email)")
- @(string.Join(", ", Model.LtiRequest?.Roles)) + @(Model.LtiRequest?.Roles != null ? string.Join(", ", Model.LtiRequest?.Roles) : "(no roles)")
@(Model.LtiRequest?.Lis?.PersonSourcedId ?? "(no sis id)") @@ -135,7 +135,7 @@

Context

-
+
Context Details
@@ -151,7 +151,7 @@
-
+
Member
@@ -171,7 +171,7 @@
-
+
Resource Link
@@ -184,8 +184,88 @@
+
+
+
Custom
+
+
+ @(Model.LtiRequest.Custom == null ? "" : string.Join(",", Model.LtiRequest.Custom.OrderBy(kv => kv.Key))) +
+
+
+
+ @if (!string.IsNullOrEmpty(Model.LtiRequest.AssignmentGradeServices.LineItemUrl)) + { +
+
+
+
Line Item
+
+
+
+ ID: @(Model.LineItem.Id) +
+
+ Label: @(Model.LineItem.Label ?? "(no label)") +
+
+ Resource Id: @(Model.LineItem.ResourceId ?? "(no resource id)") +
+
+ ResourceLink Id: @(Model.LineItem.ResourceLinkId ?? "(no resourcelink id)") +
+
+ ScoreMaximum: @(Model.LineItem.ScoreMaximum) +
+
+ Results +
+
+ + + + + + + + + + + + @foreach (var result in Model.Results) + { + + + + + + + + } + +
IdUserIdResultScoreResultMaximumScoreOf
@result.Id@result.UserId@result.ResultScore@result.ResultMaximum@result.ScoreOf
+
+
+ + + + +
+
+
+
+
+
+ } + + @await Component.InvokeAsync("LineItems", Model.IdToken)
diff --git a/src/Pages/Tool.cshtml.cs b/src/Pages/Tool.cshtml.cs index ec09b1a..3071c29 100644 --- a/src/Pages/Tool.cshtml.cs +++ b/src/Pages/Tool.cshtml.cs @@ -63,6 +63,10 @@ public ToolModel( ///
public LtiResourceLinkRequest LtiRequest { get; set; } + public ResultContainer Results { get; set; } + + public LineItem LineItem { get; set; } + /// /// Handle the LTI POST request from the Authorization Server. /// @@ -208,12 +212,32 @@ public async Task OnPostAsync( if (messageType == Constants.Lti.LtiDeepLinkingRequestMessageType) { - return Post("./Catalog", new { idToken }); + return Post("/Catalog", new { idToken }); } IdToken = idToken; LtiRequest = new LtiResourceLinkRequest(jwt.Payload); + var tokenResponse = await _accessTokenService.GetAccessTokenAsync( + LtiRequest.Iss, + Constants.LtiScopes.Ags.LineItem); + + var lineItemClient = _httpClientFactory.CreateClient(); + lineItemClient.SetBearerToken(tokenResponse.AccessToken); + lineItemClient.DefaultRequestHeaders.Accept + .Add(new MediaTypeWithQualityHeaderValue(Constants.MediaTypes.LineItem)); + + var resultsUrl = $"{LtiRequest.AssignmentGradeServices.LineItemUrl}/{Constants.ServiceEndpoints.Ags.ResultsService}"; + var resultsResponse = await lineItemClient.GetAsync(resultsUrl); + var resultsContent = await resultsResponse.Content.ReadAsStringAsync(); + var results = JsonConvert.DeserializeObject(resultsContent); + Results = results; + + var lineItemResponse = await lineItemClient.GetAsync(LtiRequest.AssignmentGradeServices.LineItemUrl); + var lineItemContent = await lineItemResponse.Content.ReadAsStringAsync(); + var lineItem = JsonConvert.DeserializeObject(lineItemContent); + LineItem = lineItem; + return Page(); } @@ -221,7 +245,7 @@ public async Task OnPostAsync( /// Handler for creating a line item. /// /// The result. - public async Task OnPostCreateLineItemAsync([FromForm(Name = "id_token")] string idToken) + public async Task OnPostCreateLineItemAsync([FromForm(Name = "id_token")] string idToken, [FromForm(Name = "resource_link_id")] string resourceLinkId) { if (idToken.IsMissing()) { @@ -251,11 +275,12 @@ public async Task OnPostCreateLineItemAsync([FromForm(Name = "id_ try { + var lineItem = new LineItem { EndDateTime = DateTime.UtcNow.AddMonths(3), Label = LtiRequest.ResourceLink.Title, - ResourceLinkId = LtiRequest.ResourceLink.Id, + ResourceLinkId = string.IsNullOrEmpty(resourceLinkId) ? LtiRequest.ResourceLink.Id : resourceLinkId, ScoreMaximum = 100, StartDateTime = DateTime.UtcNow }; @@ -277,6 +302,62 @@ public async Task OnPostCreateLineItemAsync([FromForm(Name = "id_ return Page(); } + return Relaunch( + LtiRequest.Iss, + LtiRequest.UserId, + LtiRequest.ResourceLink.Id, + LtiRequest.Context.Id); + } + + /// + /// Handler for creating a line item. + /// + /// The result. + public async Task OnPostDeleteLineItemAsync([FromForm(Name = "id_token")] string idToken, string lineItemUrl) + { + if (idToken.IsMissing()) + { + Error = $"{nameof(idToken)} is missing."; + return Page(); + } + + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(idToken); + LtiRequest = new LtiResourceLinkRequest(jwt.Payload); + + var tokenResponse = await _accessTokenService.GetAccessTokenAsync( + LtiRequest.Iss, + Constants.LtiScopes.Ags.LineItem); + + // The IMS reference implementation returns "Created" with success. + if (tokenResponse.IsError && tokenResponse.Error != "Created") + { + Error = tokenResponse.Error; + return Page(); + } + + var httpClient = _httpClientFactory.CreateClient(); + httpClient.SetBearerToken(tokenResponse.AccessToken); + httpClient.DefaultRequestHeaders.Accept + .Add(new MediaTypeWithQualityHeaderValue(Constants.MediaTypes.LineItem)); + + try + { + using (var response = await httpClient.DeleteAsync(lineItemUrl)) + { + if (!response.IsSuccessStatusCode) + { + Error = response.ReasonPhrase; + return Page(); + } + } + } + catch (Exception e) + { + Error = e.Message; + return Page(); + } + return Relaunch( LtiRequest.Iss, LtiRequest.UserId, diff --git a/src/Startup.cs b/src/Startup.cs index 22ef0da..64a3bf2 100644 --- a/src/Startup.cs +++ b/src/Startup.cs @@ -8,6 +8,8 @@ using AdvantageTool.Utility; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Net.Http; namespace AdvantageTool { @@ -60,7 +62,17 @@ public void ConfigureServices(IServiceCollection services) .AddRazorPagesOptions(options => options.Conventions.AuthorizeFolder("/Platforms")) .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); - services.AddHttpClient(); + services.AddHttpClient(Options.DefaultName, c => + { + }).ConfigurePrimaryHttpMessageHandler(() => + { + return new HttpClientHandler + { + ClientCertificateOptions = ClientCertificateOption.Manual, + ServerCertificateCustomValidationCallback = + (httpRequestMessage, cert, certChain, policyErrors) => true + }; + }); // Make AccessTokenService available for dependency injection. services.AddTransient(); diff --git a/src/appsettings.json b/src/appsettings.json index ae5c2d0..6cc3155 100644 --- a/src/appsettings.json +++ b/src/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-AdvantageTool-53bc9b9d-9d6a-45d4-8429-2a2761773502;Trusted_Connection=True;MultipleActiveResultSets=true" + "DefaultConnection": "Server=localhost;Database=aspnet-AdvantageTool-53bc9b9d-9d6a-45d4-8429-2a2761773502;User Id=sa;Password=Password_123;" }, "Logging": { "LogLevel": {