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.

This entry was posted in LTI and tagged , , , , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.