Reputation: 351
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
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