Using Swagger to Explore the LTI Advantage API

LTI Advantage platforms support Assignment and Grade Services and Names and Role Provisioning Services. These services include several RESTful endpoints:

  • Assignment and Grade Services
    • LineItems (GET, POST, PUT, DELETE)
    • Results (GET)
    • Scores (POST)
  • Names and Role Provisioning Services
    • Membership (GET)

Together, this API can be used to interact with the class gradebook:

Student 1Student 2Student 3
LineItem 1Score 1
Score 2
Result
LineItem 2
LineItem 3Score 1
Result

You can explore this API in my sample platform using Swagger: https://advantageplatform.azurewebsites.net/swagger. The rest of this post describes how I integrated Swagger into my sample platform.

All of the source code can be found in GitHub:

  • LtiAdvantagePlatform – Sample LTI Advantage Platform using ASP.NET Core. The Swagger tools are installed and configured here.
  • LtiAdvantage – ASP.NET Core library for both platforms and tools. The endpoints are defined and documented here.

Install and Configure Swashbuckle

To make it easier to develop and test the endpoints in my sample platform, I added Swagger tools by adding Swashbuckle.AspNetCore:

Install-Package Swashbuckle.AspNetCore 

To make sure the authorizations are working correctly, I added the OAuth2Scheme to AddSwaggerGen in the ConfigureServices method of Startup.cs:

                // All the controllers in this sample platform require authorization. This 
                // SecurityDefinition will use validate the user is registered, then request
                // an access token from the TokenUrl.
                options.AddSecurityDefinition("oauth2", new OAuth2Scheme
                {
                    TokenUrl = "/connect/token",
                    Type = "oauth2",
                    Flow = "password",
                    Scopes = Config.LtiScopes.ToDictionary(s => s, s => "")
                });

All of the API endpoints look for an Access Token in the request header to authenticate the request. The Swagger UI knows how to request a token from TokenUrl which is handled by Identity Server 4, but Identity Server needs to know about the Swagger UI. This is done in three steps.

First define a “swagger” client in Config.cs:

        /// <summary&gt;
        /// Built-in clients.
        /// </summary&gt;
        /// <returns&gt;</returns&gt;
        public static IEnumerable<Client&gt; GetClients()
        {
            return new List<Client&gt;
            {
                // Client for Swagger UI
                new Client
                {
                    ClientId = "swagger",
                    ClientSecrets = new List<Secret&gt;
                    {
                        new Secret("secret".Sha256())
                    },
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    AllowedScopes = LtiScopes,
                    RedirectUris = new [] { "/swagger/oauth2-redirect.html" }
                }
            };
        }

Then load the client in the InitializeDatabase method of Startup.cs:

                    foreach (var client in Config.GetClients())
                    {
                        context.Clients.Add(client.ToEntity());
                    }

And finally configure the Swagger UI to use the client when requesting an Access Token in the Configure method of Startup.cs:

            // Fire up Swagger and Swagger UI
            app.UseSwagger();
            app.UseSwaggerUI(options =>
            {
                options.SwaggerEndpoint("/swagger/v1/swagger.json", "LTI Advantage 1.3");
                options.DocumentTitle = "Advantage Platform - Swagger UI";
                options.OAuthClientId("swagger");
                options.OAuthClientSecret("secret");
            });

Add API Documentation

ASP.NET Core includes OpenAPI documentation to any API by simply decorating the Controller with the [ApiController] attribute. And ASP.NET Core includes the [ProducesResponseType] attribute to add document specific types of responses. For example, since all of the LTI APIs require authorization and specific scopes, they all may return 401 Unauthorized (not signed in) or 403 Forbidden (wrong scope). By decorating the Controller with the [ProducesResponseType] attribute, all of the endpoints inside the controller inherit those two possible responses.

    [ApiController]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesResponseType(StatusCodes.Status403Forbidden)]
    public abstract class MembershipControllerBase : ControllerBase, IMembershipController

Each endpoint can have its own, specific documentation too. For example GET Membership will produce a response with a MembershipContainer with a 200 OK status code.

[ProducesResponseType(typeof(MembershipContainer), StatusCodes.Status200OK)]

And Swashbuckle will merge xml-doc comments into the Swagger UI.

        /// <summary&gt;
        /// Returns the membership of a context.
        /// </summary&gt;
        /// <param name="contextId"&gt;The context id.</param&gt;
        /// <param name="limit"&gt;Optional limit to the number of members to return.</param&gt;
        /// <param name="rlid"&gt;Optional resource link filter for members with access to resource link.</param&gt;
        /// <param name="role"&gt;Optional role filter for members that have the specified role.</param&gt;
        /// <returns&gt;The members.</returns&gt;

The complete definition of the Membership endpoint looks like this:

    /// <inheritdoc cref="ControllerBase" /&gt;
    /// <summary&gt;
    /// Implements the Names and Role Provisioning Service membership endpoint.
    /// </summary&gt;
    [ApiController]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesResponseType(StatusCodes.Status403Forbidden)]
    public abstract class MembershipControllerBase : ControllerBase, IMembershipController
    {
        private readonly IHostingEnvironment _env;
        private readonly ILogger<MembershipControllerBase&gt; _logger;

        /// <summary&gt;
        /// </summary&gt;
        protected MembershipControllerBase(IHostingEnvironment env, ILogger<MembershipControllerBase&gt; logger)
        {
            _env = env;
            _logger = logger;
        }

        /// <summary&gt;
        /// Returns the membership.
        /// </summary&gt;
        protected abstract Task<ActionResult<MembershipContainer&gt;&gt; OnGetMembershipAsync(GetMembershipRequest request);

        /// <summary&gt;
        /// Returns the membership of a context.
        /// </summary&gt;
        /// <param name="contextId"&gt;The context id.</param&gt;
        /// <param name="limit"&gt;Optional limit to the number of members to return.</param&gt;
        /// <param name="rlid"&gt;Optional resource link filter for members with access to resource link.</param&gt;
        /// <param name="role"&gt;Optional role filter for members that have the specified role.</param&gt;
        /// <returns&gt;The members.</returns&gt;
        [HttpGet]
        [Produces(Constants.MediaTypes.MembershipContainer)]
        [ProducesResponseType(typeof(MembershipContainer), StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
        [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme, 
            Policy = Constants.LtiScopes.NrpsMembershipReadonly)]
        [Route("context/{contextId}/membership", Name = Constants.ServiceEndpoints.NrpsMembershipService)]
        [Route("context/{contextId}/membership.{format}")]
        public virtual async Task<ActionResult<MembershipContainer&gt;&gt; GetMembershipAsync([Required] string contextId, 
            int? limit = null, string rlid = null, Role? role = null)
        {
            try
            {
                _logger.LogDebug($"Entering {nameof(GetMembershipAsync)}.");

                try
                {
                    var request = new GetMembershipRequest(contextId, limit, rlid, role);
                    return await OnGetMembershipAsync(request).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, $"An unexpected error occurred in {nameof(GetMembershipAsync)}.");
                    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(GetMembershipAsync)}.");
            }
        }
    }

And the Swagger UI looks like this:

Membership Endpoint

You can explore the explore the entire LTI Advantage API in my sample platform using Swagger here: https://advantageplatform.azurewebsites.net/swagger.

Posted in LTI | Tagged , , , , , , , , | Leave a comment

Using Identity Model for LTI Advantage

LTI Advantage uses OpenID Connect and OAuth 2.0 for authentication and authorization. In particular:

  • When a platform launches a tool, it initiates an OpenID Connect third party login. The tool then sends an Authentication Request to the platform, and the platform responds with an id_token (a signed JWT) with LTI parameters (e.g. context) as claims (e.g. https://purl.imsglobal.org/spec/lti/claim/context). And finally, the tool validates the id_token and renders itself appropriately.
    • For its part in this flow, the tool must make a request to the Authorization Endpoint on the platform, and retrieve the platform’s public key using the JSON Web Key Set Endpoint (JWKS) to validate the id_token. Identity Model provides a client library to help make the Authentication Request.
  • When the tool is ready to use one of the service APIs (e.g. to get the membership of a context) it sends an Access Token Request to the platform. The platform responds with an Access Token containing the Client Credentials. The tool then includes the token as an Authorization header in the API request.
    • For its part in this flow, the tool must must make a request to the Token Endpoint on the platform. The Identity Model client library also helps with that request.

In my sample tool, I use the Identity Model client library to help make requests to the Authorization, JWKS, and Token endpoints. Full source code is available in GitHub:

Add Identity Model to the ASP.NET Core Web App

I started my sample tool using the ASP.NET Core Web Application Razor Pages template with Individual User Accounts.

Selecting Individual User Accounts adds ASP.NET Core Identity and Entity Framework Core to the project. I added Identity Model from NuGet:

PM > Install-Package IdentityModel

Launching

The platform will launch a tool from a resource link or for deep linking. In either case, the platform will first initiate an OpenID Connect third party login. That means the tool will receive a GET or POST request from the platform with values in the querystring if it is a GET request or in the body if it is a POST request:

  • iss – The issuer (i.e. the platform)
  • login_hint – The user id of the user that initiating the launch
  • target_link_url – The endpoint to be executed at the end of the OpenID Connect Authentication flow (i.e. the tool URL).
  • lti_message_hint – An opaque value that identifies the source of the launch. The platform will use this when it authenticates the launch.

When the tool receives the request, it sends an Authentication request back to the platform asking for an id_token to be POSTed to the target_link_url. I used IdentityModel’s CreateAuthorizeUrl to help build the request:

            var ru = new RequestUrl(platform.AuthorizeUrl);
            var url = ru.CreateAuthorizeUrl
            (
                clientId: platform.ClientId,
                responseType: OidcConstants.ResponseTypes.IdToken,

                // POST the id_token directly to the tool's launch URL
                redirectUri: TargetLinkUri,
                responseMode: OidcConstants.ResponseModes.FormPost,

                // Per IMS guidance
                scope: OidcConstants.StandardScopes.OpenId,

                // Consider checking state after redirect to make sure the state was not tampared with
                state: CryptoRandom.CreateUniqueId(),

                // The userId
                loginHint: LoginHint,

                // Consider checking nonce at launch to make sure the id_token came from this flow and not direct
                nonce: CryptoRandom.CreateUniqueId(),

                // No user interaction
                prompt: "none",

                // The messagedId (i.e. resource link id or deep link id)
                extra: new { lti_message_hint = LtiMessageHint }
            );

            _logger.LogInformation("Requesting authentication.");

            return Redirect(url);

If the platform determines the request is legit, it will POST an id_token to the target_link_url with all the claims necessary to launch the tool. The tool must validate the id_token and then render itself appropriately.

Calling a Service API

When the tool is ready to use one of the LTI Advantage services, it requests an Access Token from the platform. Identity Model includes the RequestClientCredentialsTokenAsync method for this, but this method assumes the platform will validate the request by directly comparing the client secret. IMS’ reference implementation will validate the request by verifying the signature of a signed JWT. So I created a custom method that uses the Identity Model RequestTokenAsync model, but includes a signed JWT instead of a client secret.

First a version of IdentityModel.Client.ClientCredentialsTokenRequest that includes a signed JWT:

    /// <inheritdoc /&gt;
    /// <summary&gt;
    /// Request for token using signed jwt as client_credentials.
    /// </summary&gt;
    /// <seealso cref="T:IdentityModel.Client.ClientCredentialsTokenRequest" /&gt;
    public class JwtClientCredentialsTokenRequest : ClientCredentialsTokenRequest
    {
        /// <summary&gt;
        /// Gets or sets the JWT.
        /// </summary&gt;
        /// <value&gt;
        /// The JWT.
        /// </value&gt;
        public string Jwt { get; set; }
    }

Then an extension method based on RequestClientCredentialsTokenAsync that makes the request:

        /// <summary&gt;
        /// Request a token based on client credentials with a signed JWT.
        /// </summary&gt;
        /// <remarks&gt;
        /// Based on https://www.imsglobal.org/spec/security/v1p0/#using-json-web-tokens-with-oauth-2-0-client-credentials-grant.
        /// </remarks&gt;
        /// <param name="client"&gt;The client.</param&gt;
        /// <param name="request"&gt;The request.</param&gt;
        /// <param name="cancellationToken"&gt;The cancellation token.</param&gt;
        /// <returns&gt;</returns&gt;
        public static async Task<TokenResponse&gt; RequestClientCredentialsTokenWithJwtAsync(this HttpMessageInvoker client, 
            JwtClientCredentialsTokenRequest request, CancellationToken cancellationToken = default(CancellationToken))
        {
            request.GrantType = OidcConstants.GrantTypes.ClientCredentials;
            request.ClientAssertion = new ClientAssertion
            {
                Type = OidcConstants.ClientAssertionTypes.JwtBearer,
                Value = request.Jwt
            };
            if (!string.IsNullOrWhiteSpace(request.Scope))
            {
                request.Parameters.Add(OidcConstants.TokenRequest.Scope, request.Scope);
            }

            return await client.RequestTokenAsync(request, cancellationToken);
        }

And finally, wrap it up in a method the Tool can call to get an Access Token:

        /// <summary&gt;
        /// Get an access token from the issuer.
        /// </summary&gt;
        /// <param name="issuer"&gt;The issuer.</param&gt;
        /// <param name="scope"&gt;The scope to request.</param&gt;
        /// <returns&gt;The token response.</returns&gt;
        public async Task<TokenResponse&gt; GetAccessTokenAsync(string issuer, string scope)
        {
            if (issuer.IsMissing())
            {
                return new TokenResponse(new ArgumentNullException(nameof(issuer)));
            }

            if (scope.IsMissing())
            {
                return new TokenResponse(new ArgumentNullException(nameof(scope)));
            }

            var platform = await _context.GetPlatformByIssuerAsync(issuer);
            if (platform == null)
            {
                return new TokenResponse(new Exception("Cannot find platform registration."));
            }

            // Use a signed JWT as client credentials.
            var payload = new JwtPayload();
            payload.AddClaim(new Claim(JwtRegisteredClaimNames.Iss, platform.ClientId));
            payload.AddClaim(new Claim(JwtRegisteredClaimNames.Sub, platform.ClientId));
            payload.AddClaim(new Claim(JwtRegisteredClaimNames.Aud, platform.AccessTokenUrl));
            payload.AddClaim(new Claim(JwtRegisteredClaimNames.Iat, EpochTime.GetIntDate(DateTime.UtcNow).ToString()));
            payload.AddClaim(new Claim(JwtRegisteredClaimNames.Nbf, EpochTime.GetIntDate(DateTime.UtcNow.AddSeconds(-5)).ToString()));
            payload.AddClaim(new Claim(JwtRegisteredClaimNames.Exp, EpochTime.GetIntDate(DateTime.UtcNow.AddMinutes(5)).ToString()));
            payload.AddClaim(new Claim(JwtRegisteredClaimNames.Jti, LtiRequest.GenerateCryptographicNonce()));

            var handler = new JwtSecurityTokenHandler();
            var credentials = PemHelper.SigningCredentialsFromPemString(platform.PrivateKey);
            var jwt = handler.WriteToken(new JwtSecurityToken(new JwtHeader(credentials), payload));

            var httpClient = _httpClientFactory.CreateClient();
            return await httpClient.RequestClientCredentialsTokenWithJwtAsync(
                    new JwtClientCredentialsTokenRequest
                    {
                        Address = platform.AccessTokenUrl,
                        ClientId = platform.ClientId,
                        Jwt = jwt,
                        Scope = scope
                    });
        }

I also used IdentityModel Protocol and Claim Type Constants.

Next time I’ll write about adding Swagger to the sample platform. That made development and ad-hoc test much easier.

Posted in LTI | Tagged , , , | Leave a comment

Using Identity Server 4 for LTI Advantage

LTI Advantage uses OpenID Connect and OAuth 2.0 for authentication and authorization. In particular:

In my sample platform, I use Identity Server 4 to provide the Authorization, JWKS, and Token endpoints. I was able to implement the entire LTI Advantage flow using the extension points provided by the Identity Server 4 framework. Full source code is available in GitHub:

Add Identity Server 4 to the ASP.NET Core Web App

I started my sample platform using the ASP.NET Core Web Application Razor Pages template with Individual User Accounts.

Selecting Individual User Accounts adds ASP.NET Core Identity and Entity Framework Core to the project. To add Identity Server 4 to the project, I followed the instructions they provide:

These extra steps won’t be necessary soon…

Last but not least, the big news is, that the ASP.NET team decided to ship IdentityServer in their new templates that will be released shortly after v2.2.

What happened in 2018?” – Dominick Baier

The end result is a Startup.cs that looks something like this:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext&gt;(options =&gt;
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

    services.AddIdentity<ApplicationUser, IdentityRole&gt;()
        .AddEntityFrameworkStores<ApplicationDbContext&gt;()
        .AddDefaultTokenProviders();

    // Add application services.
    services.AddTransient<IEmailSender, EmailSender&gt;();

    services.AddMvc();

    // configure identity server with in-memory stores, keys, clients and scopes
    services.AddIdentityServer()
        .AddDeveloperSigningCredential()
        // this adds the config data from DB (clients, resources)
        .AddConfigurationStore(options =&gt;
        {
            options.ConfigureDbContext = builder =&gt;
                builder.UseSqlServer(connectionString,
                    sql =&gt; sql.MigrationsAssembly(migrationsAssembly));
        })
        // this adds the operational data from DB (codes, tokens, consents)
        .AddOperationalStore(options =&gt;
        {
            options.ConfigureDbContext = builder =&gt;
                builder.UseSqlServer(connectionString,
                    sql =&gt; sql.MigrationsAssembly(migrationsAssembly));

        });
        .AddAspNetIdentity<ApplicationUser&gt;();
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseBrowserLink();
        app.UseDatabaseErrorPage();
    }
    else
    {
        app.UseExceptionHandler("/Home/Error");
    }

    app.UseStaticFiles();

    // app.UseAuthentication(); // not needed, since UseIdentityServer adds the authentication middleware
    app.UseIdentityServer();

    app.UseMvc(routes =&gt;
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
    });
}

At this point, Identity Server is participating in the application ASP.NET Identity tasks such as creating new user accounts, user login, and user logout. One way to observe this is to examine the ClaimsPrincipal of the HttpContext.User. Without Identity Server, the user id is in the “nameidentifier” claim. With Identity Server, the user id is in the “sub” claim.

But Identity Server is not yet participating in tool launches. To do that, I needed to do two things:

  1. Impersonate members of the tenant’s course.
  2. Add LTI claims to the id_token created by Identity Server in response to an Authentication Request.

Impersonating Members of the Tenant’s Course

In my sample platform, each registered user is a tenant. And each tenant has their own platform, course, and people in that course. By default one of those people is a student and the other is a teacher. When you (as a registered user) launch a resource link, you impersonate one of those people.

When a platform launches a tool, it initiates an OpenID Connect third party login. The tool then sends an Authentication Request to the platform, and the platform responds with an id_token.

To enable impersonation so that Identity Server is authenticating the student or teacher and not you, the application user, I send the student or teacher id as the login_hint within the OpenID Connect third party login request, and use a CustomAuthorizeRequestValidator to replace the Authentication Request Subject claim with a new ClaimsPrincipal for the impersonated user:

    /// <inheritdoc /&gt;
    /// <summary&gt;
    /// Replace the subject in the authorize request, with a <see cref="T:System.Security.Claims.ClaimsPrincipal" /&gt;
    /// for the person being impersonated. For example a student in a course.
    /// </summary&gt;
    public class ImpersonationAuthorizeRequestValidator : ICustomAuthorizeRequestValidator
    {
        public const string AuthenticationType = @"Impersonation";

        public Task ValidateAsync(CustomAuthorizeRequestValidationContext context)
        {
            var subject = context.Result.ValidatedRequest.Subject.Claims.SingleOrDefault(c =&gt; c.Type == "sub")?.Value;
            var loginHint = context.Result.ValidatedRequest.LoginHint;

            if (loginHint.IsPresent() &amp;&amp; subject != loginHint)
            {
                // Replace the subject with the person being impersonated in login_hint
                context.Result.ValidatedRequest.Subject = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim&gt;
                {
                    new Claim("sub", loginHint),
                    new Claim("auth_time", DateTime.UtcNow.ToEpochTime().ToString()),
                    new Claim("idp", "local")
                }, AuthenticationType));
            }

            return Task.CompletedTask;
        }
    }

Add LTI claims to the id_token

After the platform authenticates the impersonated user, it generates an id_token to send back to the tool. An a minimum, the id_token is a signed JWT with a few basic claims. In LTI Advantage, the id_token includes LTI parameters (e.g. context) as claims (e.g. “https://purl.imsglobal.org/spec/lti/claim/context&#8221;).

To add the LTI claims to the id_token that Identity Server issues, I created a custom ProfileService that adds the LTI claims appropriate to the type of message:

    /// <inheritdoc /&gt;
    /// <summary&gt;
    /// Custom ProfileService to add LTI Advantage claims to id_token.
    /// </summary&gt;
    /// <remarks&gt;
    /// See https://damienbod.com/2016/11/18/extending-identity-in-identityserver4-to-manage-users-in-asp-net-core/.
    /// </remarks&gt;
    public class LtiAdvantageProfileService : IProfileService
    {
        private readonly ApplicationDbContext _context;
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly LinkGenerator _linkGenerator;
        private readonly ILogger<LtiAdvantageProfileService&gt; _logger;

        public LtiAdvantageProfileService(
            ApplicationDbContext context,
            IHttpContextAccessor httpContextAccessor,
            LinkGenerator linkGenerator,
            ILogger<LtiAdvantageProfileService&gt; logger)
        {
            _context = context;
            _httpContextAccessor = httpContextAccessor;
            _linkGenerator = linkGenerator;
            _logger = logger;
        }

        /// <inheritdoc /&gt;
        /// <summary&gt;
        /// Add LTI Advantage claims to id_token.
        /// </summary&gt;
        /// <param name="context"&gt;The context.</param&gt;
        /// <returns&gt;</returns&gt;
        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            try
            {
                _logger.LogInformation($"Starting {nameof(GetProfileDataAsync)}.");

                if (context.ValidatedRequest is ValidatedAuthorizeRequest request)
                {
                    var ltiMessageHint = request.Raw["lti_message_hint"];
                    if (ltiMessageHint.IsMissing())
                    {
                        _logger.LogInformation("Not an LTI request.");
                        return;
                    }

                    if (!int.TryParse(request.LoginHint, out var personId))
                    {
                        _logger.LogError("Cannot convert login hint to person id.");
                    }

                    var message = JToken.Parse(ltiMessageHint);
                    var id = message.Value<int&gt;("id");
                    var user = await _context.GetUserAsync(_httpContextAccessor.HttpContext.User);
                    var course = message.Value<string&gt;("courseId") == null ? null : user.Course;
                    var person = await _context.GetPersonAsync(personId);
                    var messageType = message.Value<string&gt;("messageType");

                    switch (messageType)
                    {
                        case Constants.Lti.LtiResourceLinkRequestMessageType:
                        {
                            var resourceLink = await _context.GetResourceLinkAsync(id);

                            // Null unless there is exactly one gradebook column for the resource link.
                            var gradebookColumn = await _context.GetGradebookColumnByResourceLinkIdAsync(id);

                            context.IssuedClaims = GetResourceLinkRequestClaims(
                                resourceLink, gradebookColumn, person, course, user.Platform);

                            break;
                        }
                        case Constants.Lti.LtiDeepLinkingRequestMessageType:
                        {
                            var tool = await _context.GetToolAsync(id);

                            context.IssuedClaims = GetDeepLinkingRequestClaims(
                                tool, person, course, user.Platform);

                            break;
                        }
                        default:
                            _logger.LogError($"{nameof(messageType)}=\"{messageType}\" not supported.");

                            break;
                    }
                }
            }
            finally
            {
                _logger.LogInformation($"Exiting {nameof(GetProfileDataAsync)}.");
            }
    }

Protecting LTI Advantage APIs

At this point, the platform is configured to handle resource link requests and deep linking requests. Once the resource link is launched, the tool may make calls back to the platform to get course membership and gradebook information. To protect those APIs, I needed to tell Identity Server what clients are allowed to access the APIs, what APIs they are allowed to access, and what permissions they need.

Define the Clients

Every tool has a corresponding OAuth Client. Each time a tool is registered with the platform, I create a new Identity Server Client object to represent the OAuth Client:

            var client = new Client
            {
                ClientId = Tool.ClientId,
                ClientName = Tool.Name,
                AllowedGrantTypes = GrantTypes.ImplicitAndClientCredentials, 
                AllowedScopes = Config.LtiScopes,
                ClientSecrets = new List<Secret&gt;
                {
                    new Secret
                    {
                        Type = LtiAdvantage.IdentityServer4.Validation.Constants.SecretTypes.PrivatePemKey,
                        Value = Tool.PrivateKey
                    }
                },
                RedirectUris = { Tool.LaunchUrl },
                RequireConsent = false
            };

Config.LtiScopes is a list of all the possible scopes an LTI Tool might use. Tool.PrivateKey is the private key assigned to the tool (in the same format that IMS’ reference implementation uses to exchange keys between the platform and the tool). Storing the key as a ClientSecret will make it accessible to Identity Server when authorizing APIs endpoints.

Define the APIs

Next I needed to tell Identity Server what APIs to protect and what scopes are required. This is done once during startup:

                // Define the API's that will be protected.
                if (!EnumerableExtensions.Any(context.ApiResources))
                {
                    foreach (var resource in Config.GetApiResources())
                    {
                        context.ApiResources.Add(resource.ToEntity());
                    }

                    context.SaveChanges();
                }

Config.GetApiResources() returns a list of ApiResources to be protected:

        /// <summary&gt;
        /// List of API's that are protected.
        /// </summary&gt;
        /// <returns&gt;</returns&gt;
        public static IEnumerable<ApiResource&gt; GetApiResources()
        {
            return new List<ApiResource&gt;
            {
                new ApiResource
                {
                    Name = Constants.ServiceEndpoints.AgsLineItemsService,
                    DisplayName = "LTI Assignment and Grade Line Item Service",
                    Description = "Provides tools access to gradebook columns",
                    Scopes =
                    {
                        new Scope
                        {
                            Name = Constants.LtiScopes.AgsLineItem,
                            DisplayName = $"Full access to {Constants.ServiceEndpoints.AgsLineItemsService}",
                            Description = "Allow the tool to add, remove, change, and read gradebook columns"
                        },
                        new Scope
                        {
                            Name = Constants.LtiScopes.AgsLineItemReadonly,
                            DisplayName = $"Read only access to {Constants.ServiceEndpoints.AgsLineItemsService}",
                            Description = "Allow the tool to read gradebook columns"
                        }
                    }
                },
                new ApiResource
                {
                    Name = Constants.ServiceEndpoints.AgsResultsService,
                    DisplayName = "LTI Assignment and Grade Result Service",
                    Description = "Provides tools access to gradebook results",
                    Scopes =
                    {
                        new Scope
                        {
                            Name = Constants.LtiScopes.AgsResultReadonly,
                            DisplayName = $"Read only access to {Constants.ServiceEndpoints.AgsResultsService}",
                            Description = "Allow the tool to read gradebook results"
                        }
                    }
                },
                new ApiResource
                {
                    Name = Constants.ServiceEndpoints.AgsScoresService,
                    DisplayName = "LTI Assignment and Grade Score Service",
                    Description = "Provides tools access to gradebook scores",
                    Scopes =
                    {
                        new Scope
                        {
                            Name = Constants.LtiScopes.AgsScore,
                            DisplayName = $"Full access to {Constants.ServiceEndpoints.AgsScoresService}",
                            Description = "Allow the tool to add and read gradebook scores"
                        },
                        new Scope
                        {
                            Name = Constants.LtiScopes.AgsScoreReadonly,
                            DisplayName = $"Read only access to {Constants.ServiceEndpoints.AgsScoresService}",
                            Description = "Allow the tool to read gradebook scores"
                        }
                    }
                },
                new ApiResource
                {
                    Name = Constants.ServiceEndpoints.NrpsMembershipService,
                    DisplayName = "LTI Names and Role Provisioning Membership Service",
                    Description = "Provides tools access to course membership",
                    Scopes =
                    {
                        new Scope
                        {
                            Name = Constants.LtiScopes.NrpsMembershipReadonly,
                            DisplayName = $"Read only access to {Constants.ServiceEndpoints.NrpsMembershipService}",
                            Description = "Allow the tool to see who is enrolled in a course"
                        }
                    }
                }
            };
        }

Require API Authorization

To require authorization, decorate each endpoint with an Authorize attribute to force authorization with the required scope:

        [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme, 
            Policy = Constants.LtiScopes.NrpsMembershipReadonly)]
        public virtual async Task<ActionResult<MembershipContainer&gt;&gt; GetMembershipAsync([Required] string contextId, 
            int? limit = null, string rlid = null, Role? role = null)

See that “Policy” parameter? It has a value of the required scope. It is converted into a RequireClaim policy by a custom AuthorizationPolicyProvider. I could have defined all the policies in Startup.cs, but I ended up creating the API controllers in a different assembly with no control over Startup.cs. It was more reliable to dynamically create the RequireClaim policy the first time a client calls the endpoint.

Authorize Access to the APIs

The last bit of Identity Server configuration is to enable API protection using Bearer tokens. The article Protecting an API using Client Credentials assumes that’s all you are doing with Identity Server, but I’m also using Identity Server for ASP.NET Identity, so I added Bearer authentication to Cookie authentication already in place for ASP.NET Identity. Note that “Authority” as an environment variable so that this code works on both localhost and Azure.

            // Add authentication and set the default scheme to IdentityConstants.ApplicationScheme
            // so that IdentityServer can find the right ASP.NET Core Identity pages
            // https://github.com/IdentityServer/IdentityServer4/issues/2510#issuecomment-411871543
            services.AddAuthentication(IdentityConstants.ApplicationScheme)

                // Add Bearer authentication to authenticate API calls
                .AddIdentityServerAuthentication(options =>
                {
                    // The JwtBearer authentication handler will use the discovery endpoint
                    // of the authorization server to find the JWKS endpoint, to find the
                    // key to validate the JwtBearer token. Don't forget to define the
                    // base address of your Identity Server here. If you don't, you'll get
                    // "Unauthorized" errors when you call an API that is protected by a
                    // JwtBearer token.
                    options.Authority = Configuration["Authority"];
                });

Validate the Access Token

At this point the LTI Advantage APIs are being protected, but all requests will be rejected because Identity Server cannot verify the signature. That’s because the default Bearer token validator assumes that client credentials in the Bearer token are signed by an X509Certificate2. But IMS’ reference implementation actually uses RSA signing keys which it exchanges in PEM format. For example,

-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAraBeBq2z0P8yy5GPeoRn/P5q8t505i0cYL3M5/JyO1ENpZM3
feJrLxBiKmYLqP/fRrG3FBBhxES+5b29yrfQVb1Y7S8y3UmyoMofDW4BiIvdE+v/
/9lsOs+AIAZRFkcCd6HXxxXpP45ZVrMxh/al29X/IZQmTt3clV1/MUklITMPHVJ+
r0ZcMeb3OLmRMZO45c2fwiCypD4uBPb3AUBd/D9/+dX8qgTSQJSLOuH0VOzyER8L
FDSwyFptGrshryfX6mvt9KfyxqWoSmA78RpAKZ4+RhL2fUrLnQFQUC0eVdky1xIS
cTBxKPYwIZlB3v/gP0NzUnNu2qFOVxfFl0rKGwIDAQABAoIBAEeXe43HbAC+aaR0
xbOgCvzPryurvIn4id3+BRKS7rU0q6rdNCFtDgMe/0s6Po6VyuvsdXAJfTafnhM/
FJYVCwt/gr5yGsgSDlysWvd/p5Q1D5iaVDmb3ju1ub/6us6zwvmvOzj0+PNi78WH
J+JHOoaWC5g97TnR05WnMr4QygWQyRptTmrCB4lnbUzulquqSDRbGgNkFr1GaNj0
b0xrfovS8pCPkvVmHRWoQAJt+v0FTUZ2o5qy+DOkRfDbEDLOG2NGhQfybGapL63H
i8Z6+ZO7egf6ZyfYklQqfVOBbFuZjjtXMQFw1yaIe2gBEvn0jj68JRb3Frl7nko1
8eDyfUkCgYEA3NYgRWIRl/np4hqLVMtlVDg/m56sn7ADIBsM/OmzEJ7t3nUlGE6X
otShwtd3lEviYvxvjqmM6XgbnZolCbGoDxxc5+lUESKWH3ML5O7kywwnHEbPWKTT
M0IMgZl9Gn8WE33MwY7w2MxR3cp4ShVie4V+rXQ9traSkqY8rSUdMOUCgYEAyUXW
bY5qtvrxOX4NdjGrqVNiSI0BVLIwq5rMgLN6D+EKv0dqZ6isCWg0BRP7J0Bt9eq6
gy1sXL3DUbG0thVx7rRUbER1Hszni0av8UhrK9J0UknL/C3wWd8EcYOlrkjI9a0Y
01YNiDTH/8P22WzMe+A8iZZwBYiAOg7ssj0HXv8CgYBK67JDF8RURQseFFdUyzRz
YCnkR+7Utkg5KjQ70aVYbDLTF/cfyfoT2gOPML5250/EuVO3mLofswnbbCJIqacU
iVDTtQs6TPuVa9iLMKkaYeMa6sMJldG5QB0yErqotJjuv+0pda8sPhVAI6Kvr5Wb
xmx1uEv/ou0TJ6bKLx86KQKBgQDDW5jsj64+2sVm01XHoiCHYprj5pEjHy2kcsUK
KqpQXVMsI+pAoPQS0WSkhSdiiuPwLJxKFL24Kqw5UC4iCiCi27+RssSnV6Vqhvrh
TDRRvZ0P/fcTV5eR86iBcZFP3+/GnfOZtU2/JdP2CcRAd5zmo9i+hxlGFZ64O6I8
woW0CwKBgBxa7CnQDSZ2MyX4pM5ovK0uuLNWgkxcSCJqIFRFsDQ0f+WnEeiPLUVE
brQJj9Igs0FCjz2PJL87w3Uy7JKnzOsyGqILAqVtTjXtHrYZiwApTMY6QIHaVRvn
nbdVsR9hICucdOh9c3+Dl/T5YldH5muSDA188qVLQ3foXJSHRsuy
-----END RSA PRIVATE KEY-----

To validate the signature using the PEM formatted keys used by IMS’ reference implementation, I added PrivatePemKeyJwtSecretValidator into Identity Server’s processing in Startup.cs:

            services.AddIdentityServer(options =&gt;
                .AddSecretValidator<PrivatePemKeyJwtSecretValidator&gt;()

At last the sample platform has everything it needs to securely launch resources and protect the LTI Advantage APIs.

Next time I’ll write about how I use IdentityModel in my sample LTI Advantage Tool.

Posted in LTI | Tagged , , , , | Leave a comment

Using ASP.NET Core for LTI v1.3 and LTI Advantage

Last time I wrote about LTI v1.3 and LTI Advantage. This time I’ll describe how I wrote my samples using ASP.NET Core.

I created two sample web applications: one for the platform side and one for the tool side of the integration. They share many attributes:

  • Both target the netcoreapp2.2 framework
  • Both were started in Visual Studio 2017 from the ASP.NET Core Web Application Razor Pages template with Individual User Accounts (to provide something like multi-tenancy)
  • Both reference a common library where I am collecting shared code
  • Both support the full suite of LTI Advantage features:
    • LTI v1.3
    • Assignment and Grade Services v2.0
    • Deep Linking v2.0
    • Names and Role Provisioning Services v2.0
  • Both work seamlessly with IMS’ reference implementation
    • And both use BouncyCastle to read and write PEM format IMS’ reference implementation uses to exchange signing keys

The Advantage Platform

I used the ASP.NET Core Web Application template with Individual User Accounts so I could provide something like multi-tenancy to people using the live version my LTI Advantage Platform for testing their tools. Each user that registers gets their own platform with one course and two people. Only they can use the tools and resource links that they register in their platform.

The source is on GitHub: https://github.com/andyfmiller/LtiAdvantagePlatform


Try It

  1. Go to https://advantageplatform.azurewebsites.net/
  2. Click on Register in the top nav bar
  3. Enter an email address (can be fake…this is only used to login) and password.
  4. Click on the Register button

You’ll be redirected to the home page which is now a dashboard showing you the platform, course, and people that were created for you.


LTI Advantage uses OpenID Connect and OAuth 2.0 to authenticate and authorize access for people, platforms, and tools. That means there needs to be an authorization server in the mix. The first two big decisions I made were to make the platform perform double duty as both the LTI Platform the authorization server; and to use Identity Server 4 for the latter.

Adding Identity Server 4 killed two birds with one stone:

  • A certified implementation of OpenID Connect (including all the flows used by an LTI Advantage integration)
  • Access control for the service APIs (e.g. LineItems in the Assignment and Grade Services)

An LTI Advantage Platform has several API endpoints to support with access control. To make development and testing easier for me and perhaps for people using my Advantage Platform to test their tools, I added Swagger and configured it work with Microsoft Identity (i.e. “Individual User Accounts”) and Identity Server 4 (e.g. “access control for the service APIs”) so that users only have access to their own platform, course, people, results, and scores.


Try It

First look up your course id:

  1. Go to https://advantageplatform.azurewebsites.net/ and login (or register if you haven’t registered yet)
  2. Click on your username in the top nav bar
  3. Click on Course in the left nav bar
  4. Remember your course id

Now try Swagger:

  1. Go to https://advantageplatform.azurewebsites.net/swagger/
  2. Click on the Authorize button
  3. Enter the username and password you used to register yourself
  4. Leave the client_id and client_secret alone
  5. Select the https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly scope
  6. Click on the Authorize button
  7. Click on the Membership > Get endpoint
  8. Click on the Try it out button
  9. Enter your course id in the contextid field
  10. Click on Execute

You’ll see the list of people that were created for you when you registered.


The Advantage Tool

Just as I did for the platform, I used the ASP.NET Core Web Application template with Individual User Accounts so I could provide something like multi-tenancy to people using the live version my LTI Advantage Tool for testing their platforms. Only they can use the platforms they register.

The source is on GitHub: https://github.com/andyfmiller/LtiAdvantageTool


Try It

  1. Go to https://advantagetool.azurewebsites.net/
  2. Click on Register in the top nav bar
  3. Enter an email address (can be fake, this is only used as a username) and password
  4. Click on the Register button

You’ll be redirected to the home page with a dashboard showing you the platforms you register to test with.


An LTI Advantage Tool has to understand and use OpenID Connect and OAuth 2.0 to communicate with an LTI Advantage Platform. To make that easier, I used IdentityModel. This made it very easy to request authorization to launch the tool and access tokens to use the service endpoints.

Next time I’ll write more about using Identity Server 4 for LTI Advantage.

Posted in LTI | Tagged , , , , | Leave a comment

LTI v1.3 and LTI Advantage

The next version of LTI is almost here and it is shaping up to be great. In a nutshell, LTI v1.3 takes v1.1, swaps out OAuth 1.0a for OpenID Connect and OAuth 2.0, and wraps the name/value parameters in a nicely designed JSON Web Token (JWT). For example, in v1.1 the context was sent to the tool like this:

context_id=456434513
context_label=SI182
context_title=Design of Personal Environments
context_type=CourseSection

In v1.3 it is sent like this:

"https://purl.imsglobal.org/spec/lti/claim/context": {
  "id": "456434513",
  "label": "SI182"
  "title": "Design of Personal Environments",
  "type": [
    "CourseSection"
  ]
}

LTI Advantage is a label (and soon a certification) that says your platform or tool supports LTI v1.3, Assignment and Grade Services v2.0 (replacing Outcomes v1.0 in LTI v1.1), Names and Role Provisioning Services v2.0, and Deep Linking v2.0. By combining all 4 under one moniker and by offering a certification, I think IMS is hoping to build a critical mass of converts to the suite of services quickly. I hope this works.

This new combination of services solves two major problems with LTI v1.1: weak security and a poor teacher experience.

  • Weak Security – OAuth 1.0a has been deprecated for years because it does not provide much protection. In practice, it is very hard to control who has access to the secret, and nearly impossible to figure out who is using it. By relying on current state-of-the-art technology, LTI Advantage should be much more secure. I’m not a security expert or even good at security. But this decision seems like the right way to go.
  • Poor teacher experience 1 – Using just LTI v1.1 (i.e. w/o syncing class rosters using OneRoster), there is no way for a tool to know the current class roster. This means the tool cannot show the teacher which students should have, but have not finished an activity or taken a test. And, making it worse, there is no way for a tool to know who has left the class, so the teacher ends up having to wade through extra information to find the needle. That’s a big enough problem that some teachers will not use brilliant LTI activities, even if they are better than the tools built into their LMS. Names and Role Provisioning Services v2.0 (NRPS) solves both problems.
  • Poor teacher experience 2 – Second, using Outcomes v1.0, a tool can only tell the teacher’s LMS when the assignment has a grade, but not when the assignment has been started, turned in, waiting for manual grading, or has a document that needs reviewing. The combination of Assignment and Grade Services v2.0 (AGS) and Deep Linking v2.0 (DL) solves that. AGS has several statuses the LMS can surface in their teacher dashboard. And DL can be used to send attachments to the LMS to the teacher can easily review them.

IMS has built a reference implementation of LTI Advantage in written in Ruby. And I have built samples using ASP.NET Core. IMS’ reference implementation has both the platform and the tool in one app. I separated them into two: one platform and one tool. My source code is available on GitHub: platform and tool. Contact IMS if you want access to their source code.

Both implement the entire LTI Advantage suite of services (LTI v1.3, AGS, DL, and NRPS). And both implementations work seamlessly with each other (i.e. an IMS platform works with my tools, and my platform works with IMS’ tools).

Next time, I’ll write a bit about how I built my samples.

Posted in LTI | Tagged , , | 1 Comment

Sending LTI Outcomes to Google Classroom

In “Using LTI Tools in Google Classroom” I showed how I used Google’s Classroom share button to assign a link that launches an LTI Tool. For example,

https://hostname/gc2lti/{nonce}?url={lti tool}

Where gc2lti is routed to the Gc2LtiController, {nonce} is a unique random number to differentiate between multiple assignments of the same LTI Tool, and {lti tool} is the URL of the actual LTI Tool. Gc2LtiController receives the launch request from Google Classroom (a very plain GET request), uses Google APIs to create a well-formed LTI Basic Launch Request, and then POSTs it to the LTI Tool. However, that LTI request did not support outcomes. So, there was no way for the LTI Tool to send outcomes back to Google Classroom.

In this blog post I show how I added support for LTI 1.x outcomes.

I used Visual Studio Community 2017 and .NET Core 2.0 for everything in this POC, and I’ve put the entire solution (both versions) up in github. There is also a running version of this version (with outcomes) on Azure.

Google Classroom Requirements

Google has two requirements for sending grades back to Google Classroom via the Google Classroom API:

  1. Tools (or apps…I use the terms interchangeably) can only assign grades to assignments created using the same Web Client ID. Tools cannot assign grades to assignments the teacher manually created, or to assignments created by other apps, including the Google share button. That meant creating a replacement for the Classroom share button that uses my Web Client ID.
  2. Only the teacher can assign a grade to an assignment. If the Tool was launched by a student, then the Tool must impersonate the teacher that used the Tool to create the assignment, when it comes time to send the grade back to Google Classroom. That means capturing the teacher’s OAuth token when they create the assignment.

Once those two requirements are dealt with, assigning the grade is simple. Let’s get started.

Custom Classroom Share Button

My version of a custom Classroom share button is in the HomeController with associated razor views. The default page (/Index) does not require authentication. The Index action displays a sample “catalog” page with the custom share button. When you click on the button, it opens a new window and starts the sharing process with the
Course
action,

<a href="Share" title="Share to Classroom"
   onclick="share('@Model.Url', '@Model.Title', '@Model.Description');return false;">
    <img src="images/32x32_yellow_stroke_icon@1x.png"/>
    Share to Classroom
</a>
<script type="text/javascript">
    function share(shareUrl, title, description) {
        window.open(`Home/Course?url=${encodeURI(shareUrl)}&title=${title}&description=${description}`,
            "_blank",
            "toolbar=no,width=640,height=400,left=100,top=100");
        return false;
    }
</script>

The Course action does require Google authorization and this is when the teacher’s authorization token is captured for later use when saving the grade in Google Classroom.

var result = await new AuthorizationCodeMvcApp(thisnew AppFlowTeacherMetadata(ClientId, ClientSecret, Db))
    .AuthorizeAsync(cancellationToken)
    .ConfigureAwait(false);

if (!await SaveOfflineToken(cancellationToken, classroomService, result))
{
    return RedirectToAction("Course", model);
}

Normally the AuthResult returned by AuthorizationCodeMvcApp has an AccessToken that expires in 1 hour, but no RefreshToken. This is sometimes called the “automatic” AccessToken. But I need an AuthResult that includes a RefreshToken so that the grade can be recorded hours or days later.

AuthorizationCodeMvcApp will only return an AuthResult with a RefreshToken when the user explicitly grants the Tool permission. This is sometimes called the “offline” AccessToken. SaveOfflineToken checks to see if the AuthResult has a RefreshToken. If not, the user’s credential is revoked and the user is redirected back to the Course action. This time through, Google will require the user to login and explicitly grant permission to the Tool. Once I have an AuthResult with a RefreshToken, I save it and a reference to the corresponding UserId so I can retrieve it later.

Once the AuthResult with an “offline” token is recorded, the user (teacher) picks the Google Course which will get the assignment, and then is sent to the Confirm action. Confirm asks the teacher to confirm the title, instructions, LTI Tool URL, and the maximum number of points possible for this assignment.

If an assignment does not have MaxPoints, Google will not display the grade to the either the teacher or the student.

When the user clicks on Assign, the Assign action is invoked which creates the Google CourseWork in the Course.

When the CourseWork is created, a Google server will GET the link URL to grab a screenshot and link title. Even if the link title is specified in the API call, Google will overwrite it with the title of the web page at https://hostname/gc2lti. For that reason, Gc2LtiController tries to identify these probing GETs and return a nice looking thumbnail with a page title that matches the assignment title.

Converting a Launch from Google Classroom into an LTI Basic Launch Request

In the previous post I showed how to use Google’s Classroom share button to insert a specially formatted link for the LTI Tool so that a simple GET request (which is all you can count on from Google) can be turned into an LTI Basic Launch Request. This version of the POC works the same way…the custom Classroom share button inserts a similar special link so that the Gc2LtiController can intercept the GET request, attach all the required LTI parameters, and POST it to the LTI Tool.

There is one big enhancement in this version of Gc2LtiController versus the previous version. This version of Gc2LtiController fills in LisResultSourcedId and LisOutcomeServiceUrl so the Tool knows to send outcomes.

LisOutcomeServiceUrl points to a new OutcomesController. And LisResultSourcedId includes the Google CourseId, CourseWorkId, StudentId, and TeacherId,

var lisResultSourcedId = new LisResultSourcedId
{
    CourseId = ltiRequest.ContextId,
    CourseWorkId = ltiRequest.ResourceLinkId,
    StudentId = ltiRequest.UserId,
    TeacherId = courseWork.CreatorUserId
};
ltiRequest.LisResultSourcedId =
    JsonConvert.SerializeObject(lisResultSourcedId, Formatting.None);

Converting an LTI Outcomes Request into a Google Classroom Grade

So now the LTI Tool has been launched and it is time to send a grade back to the assignment in Google Classroom.

OutcomesController receives the LTI Outcomes request from the Tool, parses LisResultSourcedId into CourseId, CourseWorkId, StudentId, and TeacherId; and then calls the Google Classroom API to set the grade for the assignment.

Google Classroom grades are part of student CourseWork Submissions. There are two types of grades: assignedGrade and draftGrade. Only teachers see draftGrade. It is primarily for situations where the teacher must evaluate the submission before assigning a final grade (assignedGrade). Students see assignedGrade. OutcomesController reads the assignedGrade and writes both the assignedGrade and draftGrade.

The Google Classroom API supports reading (get) and writing (patch) grades, but not support deleting deleting them. OutcomesController handles all 3 LTI Outcomes requests (ReadResult, ReplaceResult, and DeleteResult), but returns Not Implemented when the request is to DeleteResult.

ReplaceResult

When the request is to ReplaceResult, OutcomesController performs these steps:

  1. Authenticate the LTI Request.
  2. Lookup the “offline” TokenResponse for the TeacherId.
  3. Using TokenResponse, create a UserCredential for the teacher.
  4. Using the teacher’s UserCredential, create an instance of the ClassroomService.
  5. Using the ClassroomService, get the CourseWork for the assignment.
  6. Then get the student’s StudentSubmission.
  7. Set the AssignedGrade and DraftGrade = {LTI Result} * CourseWork.MaxPoints.

1. Authenticate the LTI Request

I took several shortcuts in the POC. For example, I do not use .ConfigureAwait(false) on any of the async calls. And I have hardcoded the “secret”.

var response = new ReplaceResultResponse();
 
var ltiRequest = await Request.ParseLtiRequestAsync();
var signature = ltiRequest.GenerateSignature("secret");
if (!ltiRequest.Signature.Equals(signature))
{
    response.StatusCode = StatusCodes.Status401Unauthorized;
    return response;
}

2. Lookup the “offline” TokenResponse for the TeacherId

The RefreshToken in the “offline” TokenResponse will work until the user revokes permissions for your Tool (or you change the permissions (Scopes) you ask for). If you can’t save a grade due to insufficient permissions, invalidate the teacher’s AccessToken and then have the student ask their teacher to launch the assignment. The teacher will be asked to grant your Tool the necessary permissions.

var lisResultSourcedId = JsonConvert.DeserializeObject<LisResultSourcedId>(arg.Result.SourcedId);
var googleUser = await Db.GoogleUsers.FindAsync(lisResultSourcedId.TeacherId);
var appFlow = new AppFlowTeacherMetadata(ClientId, ClientSecret, Db);
var token = await appFlow.Flow.LoadTokenAsync(googleUser.UserId, CancellationToken.None);

3. Using the TokenResponse, create a UserCredential for the teacher

var credential = new UserCredential(appFlow.Flow, googleUser.UserId, token);

4. Using the teacher’s UserCredential, create an instance of the ClassroomService

using (var classroomService = new ClassroomService(new BaseClientService.Initializer
{
    HttpClientInitializer = credential,
    ApplicationName = "gc2lti"
}))

5. Using the ClassroomService, get the CourseWork for the assignment

var courseWorkRequest = classroomService.Courses.CourseWork.Get
(
    lisResultSourcedId.CourseId,
    lisResultSourcedId.CourseWorkId
);
var courseWork = await courseWorkRequest.ExecuteAsync();

6. Then get the student’s StudentSubmission

var submissionsRequest = classroomService.Courses.CourseWork.StudentSubmissions.List
(
    lisResultSourcedId.CourseId,
    lisResultSourcedId.CourseWorkId
);
submissionsRequest.UserId = lisResultSourcedId.StudentId;
var submissionsResponse = await submissionsRequest.ExecuteAsync();
if (submissionsResponse.StudentSubmissions == null)
{
    response.StatusCode = StatusCodes.Status404NotFound;
    response.StatusDescription = "Submission was not found.";
    return response;
}
var submission = submissionsResponse.StudentSubmissions.FirstOrDefault();

7. Finally, set the AssignmentGrade and DraftGrade

LTI Results are always between 0.0 and 1.0. So, if the LTI Result is 0.51 and the CourseWork.MaxPoints are 100, then AssignedGrade = 51. Students will see 51/100.

if (submission == null)
{
    response.StatusCode = StatusCodes.Status404NotFound;
    response.StatusDescription = "Submission was not found.";
}
else
{
    submission.AssignedGrade = arg.Result.Score * courseWork.MaxPoints;
    submission.DraftGrade = submission.AssignedGrade;
 
    var patchRequest = classroomService.Courses.CourseWork.StudentSubmissions.Patch
    (
        submission,
        submission.CourseId,
        submission.CourseWorkId,
        submission.Id
    );
    patchRequest.UpdateMask = "AssignedGrade,DraftGrade";
    await patchRequest.ExecuteAsync();
    response.StatusDescription = $"Score={arg.Result.Score}, AssignedGrade={submission.AssignedGrade}.";
}

ReadResult

When the request is to ReadResult, OutcomesController performs similar steps, then returns {LTI Result} = StudentSubmission.AssignedGrade / CourseWork.MaxPoints.

Posted in Google, LTI | Tagged , , , , , | 1 Comment