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>
        /// Built-in clients.
        /// </summary>
        /// <returns></returns>
        public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                // Client for Swagger UI
                new Client
                {
                    ClientId = "swagger",
                    ClientSecrets = new List<Secret>
                    {
                        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>
        /// Returns the membership of a context.
        /// </summary>
        /// <param name="contextId">The context id.</param>
        /// <param name="limit">Optional limit to the number of members to return.</param>
        /// <param name="rlid">Optional resource link filter for members with access to resource link.</param>
        /// <param name="role">Optional role filter for members that have the specified role.</param>
        /// <returns>The members.</returns>

The complete definition of the Membership endpoint looks like this:

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

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

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

        /// <summary>
        /// Returns the membership of a context.
        /// </summary>
        /// <param name="contextId">The context id.</param>
        /// <param name="limit">Optional limit to the number of members to return.</param>
        /// <param name="rlid">Optional resource link filter for members with access to resource link.</param>
        /// <param name="role">Optional role filter for members that have the specified role.</param>
        /// <returns>The members.</returns>
        [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>> 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 />
    /// <summary>
    /// Request for token using signed jwt as client_credentials.
    /// </summary>
    /// <seealso cref="T:IdentityModel.Client.ClientCredentialsTokenRequest" />
    public class JwtClientCredentialsTokenRequest : ClientCredentialsTokenRequest
    {
        /// <summary>
        /// Gets or sets the JWT.
        /// </summary>
        /// <value>
        /// The JWT.
        /// </value>
        public string Jwt { get; set; }
    }

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

        /// <summary>
        /// Request a token based on client credentials with a signed JWT.
        /// </summary>
        /// <remarks>
        /// Based on https://www.imsglobal.org/spec/security/v1p0/#using-json-web-tokens-with-oauth-2-0-client-credentials-grant.
        /// </remarks>
        /// <param name="client">The client.</param>
        /// <param name="request">The request.</param>
        /// <param name="cancellationToken">The cancellation token.</param>
        /// <returns></returns>
        public static async Task<TokenResponse> 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>
        /// Get an access token from the issuer.
        /// </summary>
        /// <param name="issuer">The issuer.</param>
        /// <param name="scope">The scope to request.</param>
        /// <returns>The token response.</returns>
        public async Task<TokenResponse> 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>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

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

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

    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 =>
        {
            options.ConfigureDbContext = builder =>
                builder.UseSqlServer(connectionString,
                    sql => sql.MigrationsAssembly(migrationsAssembly));
        })
        // this adds the operational data from DB (codes, tokens, consents)
        .AddOperationalStore(options =>
        {
            options.ConfigureDbContext = builder =>
                builder.UseSqlServer(connectionString,
                    sql => sql.MigrationsAssembly(migrationsAssembly));

        });
        .AddAspNetIdentity<ApplicationUser>();
}

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

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

            if (loginHint.IsPresent() && subject != loginHint)
            {
                // Replace the subject with the person being impersonated in login_hint
                context.Result.ValidatedRequest.Subject = new ClaimsPrincipal(new ClaimsIdentity(new List<Claim>
                {
                    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 />
    /// <summary>
    /// Custom ProfileService to add LTI Advantage claims to id_token.
    /// </summary>
    /// <remarks>
    /// See https://damienbod.com/2016/11/18/extending-identity-in-identityserver4-to-manage-users-in-asp-net-core/.
    /// </remarks>
    public class LtiAdvantageProfileService : IProfileService
    {
        private readonly ApplicationDbContext _context;
        private readonly IHttpContextAccessor _httpContextAccessor;
        private readonly LinkGenerator _linkGenerator;
        private readonly ILogger<LtiAdvantageProfileService> _logger;

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

        /// <inheritdoc />
        /// <summary>
        /// Add LTI Advantage claims to id_token.
        /// </summary>
        /// <param name="context">The context.</param>
        /// <returns></returns>
        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>("id");
                    var user = await _context.GetUserAsync(_httpContextAccessor.HttpContext.User);
                    var course = message.Value<string>("courseId") == null ? null : user.Course;
                    var person = await _context.GetPersonAsync(personId);
                    var messageType = message.Value<string>("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>
                {
                    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>
        /// List of API's that are protected.
        /// </summary>
        /// <returns></returns>
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                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>> 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 =>
                .AddSecretValidator<PrivatePemKeyJwtSecretValidator>()

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

Using LTI Tools in Google Classroom

According to the the EdNet Insight report, Educational Technology Trends: State of the K-12 Market 2016”, 67% of US school districts are using Google Classroom as an LMS. Unfortunately, Google does not yet support the IMS LTI standards for interoperability. In this post I walk through a proof of concept (POC) that converts a request coming from Google Classroom into an LTI request to a Tool.

Here’s an overview:

  1. Use a Classroom share button to add a specially formatted link into the course. The link points to a .NET Controller (Gc2LtiController) that will create the LTI request and post it to the LTI Tool.
  2. When the Gc2LtiController  receives the get request from Google Classroom, it uses several Google APIs to get the data needed to form the LTI request.
  3. The Gc2LtiController also needs to determine which key and secret to use to sign the request. I imagine this can be done using the information collected above, but since this is all happening in a browser session, you could also prompt the user for a code or another clue to find the right key and secret.
  4. The Gc2LtiController then signs the LTI request and posts it to the Tool.

 

I used Visual Studio Community 2017 and .NET Core 2.0 for everything in this POC, and I’ve put the entire solution up in github. This post walks through some parts of the POC and explains how they work.

Share to Classroom

The Classroom share button needs to insert a link to the Gc2LtiController and pass the the URL of the actual LTI Tool. Something like this:

https://localhost:44319/gc2lti?url=https://lti.tools/test/tp.php

Google’s share button will create a Coursework resource with this link. The Gc2LtiController will use the request URL to look up the matching Coursework resource. If this link has been assigned more than once to the same course, then Gc2LtiController will use the most recent.

That is not a problem if your Tool does not treat each assignment uniquely. For example, if your Tool is a game without bookmarking or scores, then it does not matter which Coursework resource it was launched from.

But if your Tool needs to differentiate between multiple assignments of the same Tool (for example, if your Tool uses the LTI resource_link_id then it probably does)  then the link URL will need something to differentiate each assignment. I couldn’t find a way to modify the link URL with each click, but it was pretty easy to add a nonce to the link URL when the Classroom share button is rendered so it looks like this:

https://localhost:44319/gc2lti/9d362ba3feac48ffaa0cd1f1e2cd6e1c?url=http://lti.tools/test/tp.php

The data-url attribute of the Classroom share button gets the URL from the ShareUrl property of the PageModel,
classroom-share-button

The ShareUrl property is calculated when the page is rendered. Skip the nonce if you don’t need it, and use a URL to your actual LTI tool. You can use http://lti.tools/test/tp.php. It is a very handle Tool for testing that validates the LTI request and dumps the parameters.
resource-code

When you click on the Classroom share button and follow the prompts, you will end up with something like this,
poc-lti-tool

The preview image is broken because Google’s screen thumbnail capture service can’t access localhost. The Gc2LtiController will recognize when Google is trying to get a thumbnail image and you can supply whatever you want in return.

Now let’s walk through the Gc2LtiController.

Gc2LtiController

The Gc2LtiController (Google Classroom to LTI) will receive a get request when the teacher or a student in the classroom clicks on the Link. Gc2LtiController uses clues in the request (e.g. the Referer header), the Google Classroom API, and the Google Admin Directory API to fill in most of the required and useful parameters of an LTI request.

The only required parameters this POC version of Gc2LtiController cannot determine is the oauth_consumer_key and secret. I’ve hard coded the key and secret for https://lti.tools/test/tp.php. A real implementation will need to determine the key and secret based on the information gathered from Google, or by asking the user to supply a clue such as a code.

I found the Google Classroom API .NET Quickstart and the web application section of the OAuth 2.0 quide useful when writing Gc2LtiController. I ran into one glitch: Google’s quickstarts and .NET API libraries were created before .NET Core 2.0. This was really only an issue with the Google.Apis.Auth.Mvc library. For this POC, I used @buzallen‘s replacement he calls Google.Apis.Auth.AspMvcCore.

Prerequisites

To run this POC, you will need several things:

  • Visual Studio 2017.
  • Access to the internet and a web browser.
  • A Google account (for you as the developer of the project).
  • A second Google account with Google Classroom enabled for testing. A G Suite for Education account is preferred, but this works with other account types with some degradation.
  • Download @buzallen‘s Google.Apis.Auth.Mvc replacement he calls Google.Apis.Auth.AspMvcCore. You will probably need to fix the Google.Apis.Auth.AspMvcCore project reference in the gc2lti-poc solution so that it points where you downloaded the project.
  • Enable the Classroom API and the Admin SDK using the Google Developers Console. See Google’s Classroom Quickstart for details.
  • Create an OAuth Client ID for a web application, also using the Google Developers Console.
  • Add https://localhost:44319/AuthCallback as an authorized Redirect URL to the Client ID.
  • Store the Client ID and Secret for the Gc2LtiController using the Secret Manager:
    1. Right click on the gc2lti project and select Manage User Secrets.
    2. Store your Client ID and Secret in the secrets.json file.
      {
        "Authentication:Google:ClientId": "YOUR CLIENT ID",
        "Authentication:Google:ClientSecret": "YOUR SECRET"
      }
      

You should be able to run both the catalog and gc2lti projects now. The rest of this post walks through some of the interesting bits of Gc2LtiController.

The Gc2LtiController.Index Action

Links to Gc2LtiController looks like one of these:

https://localhost:44319/gc2lti?url=http://lti.tools/test/tp.php
https://localhost:44319/gc2lti/9d362ba3feac48ffaa0cd1f1e2cd6e1c?url=http://lti.tools/test/tp.php

To allow either format, the route for the default (Index) action makes the nonce optional,

And since nonce has no value to Gc2LtiController by itself, there is no matching parameter. The full request URL (including the nonce if provided) is used to find a matching Coursework resource later.

Google calls the default (Index) Action many times for different purposes. The first time is to capture a thumbnail sized screenshot of your LTI Tool. This is called by one of Google’s servers (not through a browser), so it is not a good time to actually launch your tool. I suggest returning a generic page that looks nice.

The next time the Index Action is called, the teacher or student has clicked on the link hoping to launch the Tool. To form an LTI request, Gc2LtiController needs a bunch of information from Google. That means the controller needs authorization.

Google’s authorization flow redirects away and back to the Index Action 2 or 3 times during the authorization flow (3 the first time a user interacts with your copy of Gc2Lti so they can agree to allow you access). The first bit of data is collected we need is collected from these requests: the alternate link for the course,

Once the app is authorized, it’s time to start forming the LTI request,

The first set of information comes from the Google Classroom API,

And the second from the Google Directory API,

At this point the LTI request has all the user, context, and resource information. It’s time look up the key and secret, sign the request, and post it to your LTI Tool,

FillInUserAndPersonInfo

This is very straightforward because Google stores very LTI-like information in the Classroom UserProfile,

FillInContextAndResourceInfo

This one is a little more complex because we need to find the matching Course and Coursework. When the teacher or student clicks on the link and if the scheme is https, then Google will include the Course Alternative Link in the request Referer header. To find the matching course, we need to search the list of this user’s courses, looking for the one with the matching AlternativeLink,

Once we have the context_id (= Google Classroom Course ID), then we can search the Coursework for one with a Link that matching the Request URL. This code will find the first (most recent) Coursework with a matching URL. If the teacher manually created the link or used the Classroom Reuse feature, the matching Coursework might not be the one that was originally assigned with the Classroom share button,

FillInContextRoleInfo

Getting the context role is also pretty straightforward…just check to see if the current user is in the list of course teachers,

FillInPersonSyncInfo

Google has 3 ways for schools to associate SIS information with users:

  1. Google School Directory Sync for G Suite for Education customers.
  2. Google Cloud Directory Sync for G Suite customers.
  3. G Suite Bulk Account Update for G Suite customers.

There is no way to add syncing information to normal Google accounts (i.e. anyone@gmail.com).

All 3 ways store the syncing information in the user’s Google Account as ExternalIds, which we can get to with the Google Directory API. The syncing id is stored in slightly different ways depending on the way it was captured,

What’s Next?

The next thing I’ll look at is how to handle LTI outcomes (and gradebook when that spec is public). I’m thinking the Gc2LtiController will need an outcomes endpoint that receives results from the LTI Tool and forwards them to Google Classroom using the Google Classroom API. I’m a little worried this may not be easy because of this statement in the documentation,

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

Splitting off LtiLibrary 1.6 Repository

Soon after releasing LtiLibrary 1.6, I started work on LtiLibrary 2.0 using Visual Studio 2017 which had much better tooling for .NET Core than VS 2015. I decided to also pay off some technical debt that had accrued. The outcome was a new naming library scheme (LtiLibrary.NetCore and LtiLibrary.AspNetCore), a new internal naming scheme (e.g. Lti\v1 instead of Lti1), and more consistent controller APIs. All the while, I kept the 1.6 code, solution, and project files in the same repository.

Recently Microsoft release .NET Core 2.0 and an update to VS 2017 with even better .NET Core tooling. Prior to updating LtiLibrary.NetCore and LtiLibrary.AspNetCore to .NET Core 2.0, I decided to split the two code bases. The LtiLibrary 1.6 codebase is now in its own repository. I don’t plan on actively maintaining this repository.

Posted in Uncategorized | 1 Comment

IMS LTI Outcomes 1.0 Versus 2.0

IMS LTI Outcomes 1.0

First introduced in March 2012 with LTI 1.1, Outcomes-1 has changed little over the years. It finally got its own spec in January 2015, and it’s own name: Learning Tools Interoperability Outcomes Management Service 1.0.

In a nutshell, the Tool Provider can read, write, and delete a decimal score between 0.0 and 1.0 as the result, or outcome, of an LTI launch. The Tool Provider can do this immediately, for example if the tool is a self-scoring game. Or later, for example if the teacher launches the tool later to manually grade an essay submitted in response to the original launch.

IMS LTI Outcomes 2.0

A draft version of Outcomes-2 was introduced in December 2014, but it has not been finalized yet. Outcomes-2 has a similarly long name: Learning Tools Interoperability Outcomes Management Service 2.0.

There are many differences between the specifications (see the table below), but as a product manager, I want to focus on the problems they solve. In this sense, Outcomes-2 is a superset of Outcomes-1: every problem you can solve with Outcomes-1 can also be solved by Outcomes-2. But Outcomes-2 can solve one pervasive problem much better than Outcomes-1.

Comparing Outcomes-1 to Outcomes-2

Outcomes-1 Outcomes-2
LTI compatibility LTI 1.x LTI 1.x and 2.x
Service endpoints One Two
Service methods POST
(data content determines action)
DELETE, GET, POST, and PUT
(method determines action in a RESTful way)
Data format XML JSON-LD
Data schemas A single result A single resultAll the results for a single assignment

A single assignment

All the assignments for a class

Result schema Score earned by the learner between 0.0 and 1.0 Score earned by the learner between 0.0 and 1.0The learner that earned the score

Penalty score to be deducted

Extra credit score to be added

Score constraints to be applied

Total score on the assignment

Resulting text score to be displayed

The result status

A comment associated with this result

Assignment schema Not applicable Gradebook labelThe Tool Provider’s activity that produces the associated results

Score constraints to be applied to all associated results

The associated results

What Problems do Outcomes-1 and Outcomes-2 Solve?

Not Enough Time in the Day

Both Outcomes-1 and Outcomes-2 help solve the most common problem teachers want solved: enough time to adjust their teaching to improve student learning. But manually gathering the information they need to adjust their teaching takes a lot of time…time that they are not teaching. Anything that frees up some of their time is a terrific boon. Tool Providers that automatically send information to the teacher are helping that teacher teach and his or her students learn. Even a simple score tells the teacher which students have completed an assignment and if they were able to complete it successfully.

But self-scoring activities and simple scores are not the right fit for all teaching and learning experiences, and that’s were Outcomes-2 starts to shine.

Demonstrating Competence

Teachers often use a style of teaching that asks students to demonstrate their understanding of a concept or their ability to synthesize multiple concepts. There are many ways of demonstrating competence, many of which don’t involve technology at all (such as giving a speech or performing a play). But technology can improve some existing techniques (turning in a document online versus a physically turning in a piece of paper removes the constraints of time and place), and make other techniques possible, such as producing an interactive timeline to demonstrate cause and effect relationships.

One characteristic shared by all demonstrations of competence is the need for an audience, and in particular the teacher. Someone to demonstrate to, and someone to get feedback from. And this is where Outcomes-2 has the advantage.

An Outcomes-2 result has two properties that enable online demonstrations of competence without wasting the teacher’s time and without the constraining either the student or teacher to time and place: the result status and comment. The result status allows the student to tell the teacher where they are in the process: “I’ve started working on assignment” and later, “I’ve completed the assignment”. The result comment can be used to provide more details such as “I’ve finished part 1 of 3”.

Once the result status is “Completed”, the teacher takes over to review the demonstration of competence. Once that review is complete, the teacher can tell the student the final grade and provide simple feedback with the comment (the Tool Provider should provide a mechanism for the teacher to provide detailed feedback if necessary).

Recommendation

Outcomes-2 won’t do anything if it is not built into the learning systems, tools, and content that schools use. Like LTI 2.x, it looks like we are in a stand-off until schools demand that vendors support Outcomes-2. If you are a school trying to implement any kind of online competency based learning, talk to your vendors about how their solution could improve if they implemented Outcomes-2. If you are a vendor with a cool tool that uses Outcomes-2, talk to your schools about how much better your tools would be if their other vendors also supported Outcomes-2.

Posted in Uncategorized | Tagged , , | 5 Comments