I am currently developing a quiz game to work as an external tool within Moodle. I am following the IMS LTI specification and using OAuth for authentication, as it requires.
I recently managed to authenticate the launch POST request from Moodle, and am now trying to send grades back to Moodle from my tool by using LTI Basic Outcome Service.
Here is where I have encountered a problem: I build POX messages, sign the request, and send it, but for some reason they are succeeding just once every several attempts (30% of the time, more or less). For the rest of them, Moodle responds with a failure POX message that includes "Message signature not valid" as its description.
Obviously, I want to trust my requests will succeed whenever I need them to.
For illustration purposes, I show you here below an example of a successful request and a failed one, along with each one's Base String (the one OAuth takes for signing):
For the successful request:
- Base String of the request (line breaks are for display purposes only):
POST&http%3A%2F%2F127.0.0.1%2Fmoodle%2Fmod%2Flti%2Fservice.php
&oauth_body_hash%3DhPssgohenJEvtKta2so7Y27p3kU%253D%26oauth_callback%3Dabout%253Ablank
%26oauth_consumer_key%3Dkey%26oauth_nonce%3D63cc0764c4cc4701abe28fa5fd406378
%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1525940779%26oauth_version%3D1.0
Body of the response:
<?xml version="1.0" encoding="UTF-8"?> <imsx_POXEnvelopeResponse xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> <imsx_POXHeader> <imsx_POXResponseHeaderInfo> <imsx_version>V1.0</imsx_version> <imsx_messageIdentifier>1602471533</imsx_messageIdentifier> <imsx_statusInfo> <imsx_codeMajor>success</imsx_codeMajor> <imsx_severity>status</imsx_severity> <imsx_description>Result read</imsx_description> <imsx_messageRefIdentifier>1339905165</imsx_messageRefIdentifier> <imsx_operationRefIdentifier>readResultRequest</imsx_operationRefIdentifier> </imsx_statusInfo> </imsx_POXResponseHeaderInfo> </imsx_POXHeader> <imsx_POXBody> <readResultResponse> <result> <resultScore> <language>en</language> <textString>0.5</textString> </resultScore> </result> </readResultResponse> </imsx_POXBody> </imsx_POXEnvelopeResponse>
For the failed request:
- Base String of the request (line breaks are for display purposes only):
POST&http%3A%2F%2F127.0.0.1%2Fmoodle%2Fmod%2Flti%2Fservice.php
&oauth_body_hash%3DYLigJE%252B8wr7rCwOITqdc1IP3zFs%253D%26oauth_callback%3Dabout%253Ablank
%26oauth_consumer_key%3Dkey%26oauth_nonce%3D6de4380ce2ab4d9a90e3fe1723dc5141
%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1525940816%26oauth_version%3D1.0
Body of the response:
<?xml version="1.0" encoding="UTF-8"?> <imsx_POXEnvelopeResponse xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> <imsx_POXHeader> <imsx_POXResponseHeaderInfo> <imsx_version>V1.0</imsx_version> <imsx_messageIdentifier>1688463600</imsx_messageIdentifier> <imsx_statusInfo> <imsx_codeMajor>failure</imsx_codeMajor> <imsx_severity>status</verity> <imsx_description>Message signature not valid</imsx_description> <imsx_messageRefIdentifier/> <imsx_operationRefIdentifier>unknownRequest</imsx_operationRefIdentifier> </imsx_statusInfo> </imsx_POXResponseHeaderInfo> </imsx_POXHeader> <imsx_POXBody> <unknownResponse/> </imsx_POXBody> </imsx_POXEnvelopeResponse>
As you can see, both Base Strings are quite similar, and I can only find differences in the parts that are supposed to be different (e.g. timestamp or nonce), so I can't figure out why some of the request are being rejected while some others get accepted.
Any ideas on the reason this could be happening? Any suggestions on how to find out?
Thank you in advance for your help.
EDIT: I am adding here the code I use in the signing process
private IEnumerator SendGradeRequest(XmlDocument xml)
{
string url = parametrosIniciales["lis_outcome_service_url"];
byte[] entityBody = Encoding.UTF8.GetBytes(xml.OuterXml);
string bodyHash = oAuth.GetBodyHash(entityBody);
Dictionary<string, string> oAuthParameters = oAuth.PrepareOAuthParameters(oAuth.GetSessionOAuthParameters(parametrosIniciales));
oAuthParameters.Add("oauth_body_hash", bodyHash);
Dictionary<string, string> headers = new Dictionary<string, string>();
headers.Add("Content-Type", "application/xml");
headers.Add("Authorization", oAuth.GetAuthorizationHeader(oAuthParameters));
string signature = oAuth.GetRequestSignature(url, headers, entityBody);
oAuthParameters.Add("oauth_signature", signature);
headers["Authorization"] = oAuth.GetAuthorizationHeader(oAuthParameters);
WWW web = new WWW(url, entityBody, headers);
// ... Code to manage the response
}
public string GetRequestSignature(string url, Dictionary<string, string> headers, byte[] body)
{
string baseString = GetBaseString(url, headers, body);
string key = GetKeyParts(JsonToDict(GameManager.Instance.parametrosIniciales));
return ComputeHMACSHA1(key, baseString);
}
private string GetBaseString(string url, Dictionary<string, string> headers, object body = null)
{
string[] parts = { UrlEncodeRFC3986 (GetNormalizedHttpMethod(body)),
UrlEncodeRFC3986 (GetNormalizedHttpURL(url)),
UrlEncodeRFC3986 (GetSignableParameters(url, headers, body)) };
return String.Join("&", parts);
}
private string GetKeyParts(Dictionary<string, string> oAuthParameters)
{
string consumer, token, consumerSecret, tokenSecret;
if (oAuthParameters.TryGetValue("oauth_consumer_key", out consumer))
consumerSecret = UrlEncodeRFC3986(secrets[consumer]);
else
throw new Exception("Not consumer key found. If not using any encription, this method should not be called");
if (oAuthParameters.TryGetValue("oauth_token", out token))
tokenSecret = UrlEncodeRFC3986(secrets[token]);
else
tokenSecret = "";
return consumerSecret + "&" + tokenSecret;
}
private string GetSignableParameters(string url, Dictionary<string, string> headers, object body = null)
{
List<String> parametros = new List<string>();
// 1. Parameters in the OAuth HTTP Authorization header excluding the realm parameter
string oAuthHeader;
if (headers.TryGetValue("Authorization", out oAuthHeader))
{
oAuthHeader = oAuthHeader.Substring(oAuthHeader.IndexOf(" ") + 1).Replace("\"", "");
string[] pairs = oAuthHeader.Split(',');
for (int i = 0; i < pairs.Length; i++)
{
string pair = pairs[i];
string[] temp = pair.Split(new char[] { '=' }, 2);
temp = UrlEncodeRFC3986(temp);
pairs[i] = String.Join("=", temp);
}
parametros.InsertRange(parametros.Count, pairs);
}
// 2. HTTP GET parameters added to the URLs in the query part (as defined by [RFC3986] section 3)
if (url.Contains("?"))
parametros.InsertRange(parametros.Count, url.Substring(url.IndexOf("?")).Split('&'));
// 3. Parameters in the HTTP POST request body (with a content-type of application/x-www-form-urlencoded)
if (body != null && headers["Content-Type"] == "application/x-www-form-urlencoded" && body is Dictionary<string, string>)
{
Dictionary<string, string> cuerpo = RemoveSpareParameters((Dictionary<string, string>)body);
parametros.InsertRange(parametros.Count, DictToPairsList(cuerpo, true));
}
// 4. Sort all parameters by lexicographical value
parametros.Sort();
// 5. Concatenate all parameters with '&'
return String.Join("&", parametros.ToArray());
}