Pauldb
Pauldb

Reputation: 351

LTI 1.3 activityProgress Update

I'm working on a C# implementation for a tool that should be able to communicate with the platform.

I got the launch part working, and now I'm working on sending an update for the activityProgress. However I'm getting a 401 Unauthorized when testing the method using https://lti-ri.imsglobal.org/ as a simulated platform. Does anyone see what I'm doing wrong. This is the relevant code:

LoginController.cs:

       [HttpPost]
       public async Task<IActionResult> Authorize(
           [FromForm(Name = "id_token")] string idToken,
           [FromForm(Name = "state")] string state = null)
       {
           var handler = new JwtSecurityTokenHandler();
           if (string.IsNullOrEmpty(idToken) || !handler.CanReadToken(idToken))
           {
               return NotFound();
           }

           var jwt = handler.ReadJwtToken(idToken);
           JwtHeader = jwt.Header;

           var clientId = LtiJwtTokenService.GetClaimValue("aud", jwt);

           // Authentication Response Validation
           // See https://www.imsglobal.org/spec/security/v1p0/#authentication-response-validation

           // The ID Token MUST contain a nonce Claim.
           var nonce = LtiJwtTokenService.GetClaimValue("nonce", jwt);

           if (string.IsNullOrEmpty(nonce))
           {
               return NotFound();
           }

           // If the launch was initiated with a 3rd party login, then there will be a state
           // entry for the nonce.
           var memorizedState = _stateContext.GetState(nonce);
           if (memorizedState == null)
           {
               return NotFound();
           }

           // The state should be echoed back by the AS without modification
           if (memorizedState.Value != state)
           {
               return NotFound();
           }

           // Look for the platform with platformId in the redirect URI
           var platform =
               await _ltiConfigurationService.GetLtiConfigurationByClientIdAsync(
                   EncryptionExtensions.Encrypt(clientId));
           if (platform == null)
           {
               return NotFound();
           }

           // Using the JwtSecurityTokenHandler.ValidateToken method, validate four things:
           //
           // 1. The Issuer Identifier for the Platform MUST exactly match the value of the iss
           //    (Issuer) Claim (therefore the Tool MUST previously have been made aware of this
           //    identifier.
           // 2. The Tool MUST Validate the signature of the ID Token according to JSON Web Signature
           //    RFC 7515, Section 5; using the Public Key for the Platform which collected offline.
           // 3. The Tool MUST validate that the aud (audience) Claim contains its client_id value
           //    registered as an audience with the Issuer identified by the iss (Issuer) Claim. The
           //    aud (audience) Claim MAY contain an array with more than one element. The Tool MUST
           //    reject the ID Token if it does not list the client_id as a valid audience, or if it
           //    contains additional audiences not trusted by the Tool.
           // 4. The current time MUST be before the time represented by the exp Claim;

           RSAParameters rsaParameters;

           try
           {
               var httpClient = _httpClientFactory.CreateClient();
               var keySetJson =
                   await httpClient.GetStringAsync(EncryptionExtensions.Decrypt(platform.JwkSetUrl));
               var keySet = JsonConvert.DeserializeObject<JsonWebKeySet>(keySetJson);
               var key = keySet.Keys.SingleOrDefault(k => k.Kid == jwt.Header.Kid);

               if (key == null)
               {
                   return NotFound();
               }

               rsaParameters = new RSAParameters
               {
                   Modulus = Base64UrlEncoder.DecodeBytes(key.N),
                   Exponent = Base64UrlEncoder.DecodeBytes(key.E)
               };
           }
           catch (Exception e)
           {
               return NotFound();
           }

           var validationParameters = new TokenValidationParameters
           {
               ValidateTokenReplay = true,
               ValidateAudience = true,
               ValidateIssuer = true,
               RequireSignedTokens = true,
               ValidateIssuerSigningKey = true,

               ValidAudience = EncryptionExtensions.Decrypt(platform.ClientId),
               ValidIssuer = EncryptionExtensions.Decrypt(platform.Issuer),
               IssuerSigningKey = new RsaSecurityKey(rsaParameters),

               ValidateLifetime = true,
               ClockSkew = TimeSpan.FromMinutes(5.0)
           };

           try
           {
               handler.ValidateToken(idToken, validationParameters, out _);

               var inputModel = new LtiStatusUpdateInputModel()
               {
                   Timestamp = DateTime.UtcNow,
                   UserId = LtiJwtTokenService.GetClaimValue("sub", jwt),
                   ActivityProgress = LtiProgressionStatusEnum.Completed,
                   GradingProgress = LtiProgressionStatusEnum.NotStarted,
                   EndpointClaim = LtiJwtTokenService.GetEndPointClaim(jwt)
               };
               
               await _ltiStatusUpdateService.UpdateLtiStatusAsync(inputModel, jwt);
           }
           catch (Exception e)
           {
               return NotFound();
           }

// redirect to view

       }

LtiStatusUpdateService.cs:

public class LtiStatusUpdateService : ILtiStatusUpdateService
{
   private readonly IHttpClientFactory _clientFactory;

   public LtiStatusUpdateService(IHttpClientFactory clientFactory)
   {
       _clientFactory = clientFactory;
   }

   public async Task UpdateLtiStatusAsync(LtiStatusUpdateInputModel inputModel, JwtSecurityToken token)
   {
       using var client = _clientFactory.CreateClient();
       var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
       var jwtToken = jwtSecurityTokenHandler.WriteToken(token);
       var request = new HttpRequestMessage(HttpMethod.Post, inputModel.EndpointClaim.LineItemScoreEndpoint);
       request.Headers.Add("Authorization", "Bearer " + jwtToken);

       var payload = new
       {
           timestamp = inputModel.Timestamp.ToString("s"),
           userId = inputModel.UserId,
           activityProgress = inputModel.ActivityProgress.ToString(),
           gradingProgress = inputModel.GradingProgress.ToString()
       };

       var jsonPayload = JsonSerializer.Serialize(payload);
       var content = new StringContent(jsonPayload, Encoding.UTF8, "application/vnd.ims.lis.v1.score+json");

       request.Content = content;

       var response = await client.SendAsync(request);

       if (!response.IsSuccessStatusCode)
       {
           throw new Exception("Failed to update LTI status");
       }
   }
}

LtiStatusUpdateInputModel.cs:

public class LtiStatusUpdateInputModel
{
   public LtiEndPointClaim EndpointClaim { get; set; } = default!;
   public DateTime Timestamp { get; set; } = default!;
   public string UserId { get; set; } = default!;
   public LtiProgressionStatusEnum? ActivityProgress { get; set; }
   public LtiProgressionStatusEnum? GradingProgress { get; set; }
   
}

LtiEndPointClaim.cs
public class LtiEndPointClaim
{
   [JsonPropertyName("lineitem")]
   public string LineItem { get; set; } = default!;
   
   [JsonPropertyName("scope")]
   public List<string>? Scope { get; set; } = default!;
   
   [JsonPropertyName("lineitems")]
   public string LineItems { get; set; } = default!;
   
   public string LineItemScoreEndpoint => $"{LineItem}/scores";
   
}


Any help will be appreciated!

EDIT: After received answer I've added a method to receive the accesstoken, however this returns "Internal server error", so I got closer, but I need this to work first:

    public async Task<TokenResponse> GetAccessTokenAsync(string scope, LtiConfiguration platform)
    {
        if (string.IsNullOrEmpty(scope))
        {
            throw new ArgumentNullException(nameof(scope));
        }

        if (platform is null)
        {
            throw new ArgumentNullException(nameof(platform));
        }

        var payload = new JwtPayload
        {
            { "iss", _encryptionService.Decrypt(platform.ClientId) },
            { "sub", _encryptionService.Decrypt(platform.ClientId) },
            { "aud", _encryptionService.Decrypt(platform.AccessTokenUrl) },
            { "iat", EpochTime.GetIntDate(DateTime.UtcNow).ToString() },
            { "exp", EpochTime.GetIntDate(DateTime.UtcNow.AddMinutes(5)).ToString() },
            { "Nbf", EpochTime.GetIntDate(DateTime.UtcNow.AddSeconds(-5)).ToString() },
            { "jti", CryptoRandom.CreateRandomKey(32).ToString() }
        };


        var handler = new JwtSecurityTokenHandler();
        SigningCredentials credentials =
            PemHelper.SigningCredentialsFromPemString(_encryptionService.Decrypt(platform.PrivateKey));
        string jwt = handler.WriteToken(new JwtSecurityToken(new JwtHeader(credentials), payload));
        
        
        
        using HttpClient httpClient = _clientFactory.CreateClient();
        var content = new FormUrlEncodedContent(new[]
        {
            new KeyValuePair<string, string>("client_id", _encryptionService.Decrypt(platform.ClientId)),
            new KeyValuePair<string, string>("grant_type", "client_credentials"),
            new KeyValuePair<string, string>("client_assertion_type",
                "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
            new KeyValuePair<string, string>("scope", scope),
            new KeyValuePair<string, string>("assertion", jwt)
        });
        httpClient.DefaultRequestHeaders.Add("typ", "JWT");
        httpClient.DefaultRequestHeaders.Add("alg", "RS256");

        HttpResponseMessage response = await httpClient.PostAsync(_encryptionService.Decrypt(platform.AccessTokenUrl), content);

        if (response.IsSuccessStatusCode)
        {
            string responseContent = await response.Content.ReadAsStringAsync();
            var tokenResponse = JsonSerializer.Deserialize<TokenResponse>(responseContent);
            return tokenResponse;
        }
        
        else
        {
            throw new Exception("Failed to get access token");
        }

Upvotes: 0

Views: 141

Answers (1)

snake
snake

Reputation: 61

At a glance, it looks as though you're calling the service with the id_token you received in the launch, instead of an access token. I.e. you're passing jwt into UpdateLtiStatusAsync().

The id_token will contain the AGS claim, which will in turn list the scopes supported by that service (i.e. whether it supports score updates, line item creation etc.).

I think you need to first request an access token using the relevant AGS scope, then use that in the header when trying to post the score update.

Upvotes: 1

Related Questions