Search code examples
azure-blob-storageazure-ad-msalazure-sas

Issue generating Azure blob user delegation SAS via browser javascript


I've been trying to create a web page to allow me to:

  1. Generate a Windows Identity platform login popup using the msal-browser library to get a bearer token.
  2. Use that to get a User Delegation Key from the blob rest api, and
  3. use the key to generate a user delegation SAS and list my container's contents.

I've got to the step of generating a SAS code but the signature I'm generating is invalid. I've done a lot of searching for answers but can't identify the problem myself and need some help.

Starting at the point where I've got my bearer token and am retrieving a user delegation key (which works):

const blobDelegationKeyEndpoint =
  "https://MYACCOUNT.blob.core.windows.net/?restype=service&comp=userdelegationkey";

let sasKeyOID = "";
let sasKeyTID = "";
let sasKeyStart = "";
let sasKeyExpiry = "";
let sasKeyService = "";
let sasKeyVersion = "";
let sasKeyValue = "";

btnDelegationKey.addEventListener("click", async () => {
  const headers = new Headers();
  headers.append("Authorization", bearer);
  headers.append("x-ms-version", "2020-06-12");
  const options = {
    method: "POST",
    headers: headers,
    body: `<?xml version="1.0" encoding="utf-8"?>  
    <KeyInfo>  
        <Start>2021-03-27T09:20:00Z</Start>
        <Expiry>2021-03-27T12:30:00Z</Expiry>
    </KeyInfo>  `,
  };

 fetch(blobDelegationKeyEndpoint, options)
    .then((resp) => {
      return resp.text();
    })
    .then((data) => {
      const parser = new DOMParser();
      console.log(data);
      const xmlDoc = parser.parseFromString(data, "text/xml");
      sasKeyOID = xmlDoc.getElementsByTagName("SignedOid")[0].textContent;
      sasKeyTID = xmlDoc.getElementsByTagName("SignedTid")[0].textContent;
      sasKeyStart = xmlDoc.getElementsByTagName("SignedStart")[0].textContent;
      sasKeyExpiry = xmlDoc.getElementsByTagName("SignedExpiry")[0].textContent;
      sasKeyService = xmlDoc.getElementsByTagName("SignedService")[0]
        .textContent;
      sasKeyVersion = xmlDoc.getElementsByTagName("SignedVersion")[0]
        .textContent;
      sasKeyValue = xmlDoc.getElementsByTagName("Value")[0].textContent;
    });
});

Next I construct my "StringToSign" - with format based on https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas:

const sasStart = new Date().toISOString();
const sasExpiry = new Date(new Date().getTime() + 20 * 60 * 1000).toISOString();

btnSAS.addEventListener("click", () => {
  const StringToSign =
    "rl" + // signedPermissions
    "\n" +
    sasStart + // signedStart
    "\n" +
    sasExpiry + // signedExpiry
    "\n" +
    "/blob/MYACCOUNT/MYCONTAINER" + // canonicalizedResource
    "\n" +
    sasKeyOID + // signedKeyObjectId
    "\n" +
    sasKeyTID + // signedKeyTenantId
    "\n" +
    sasKeyStart + // signedKeyStart
    "\n" +
    sasKeyExpiry + // signedKeyExpiry
    "\n" +
    sasKeyService + // signedKeyService
    "\n" +
    sasKeyVersion + // signedKeyVersion
    "\n" +
    "" + // signedAuthorizedUserObjectId
    "\n" +
    "" + // signedUnauthorizedUserObjectId
    "\n" +
    "16ca0b63-869e-4d76-8bf7-f859dcf02070" + // signedCorrelationId
    "\n" +
    "" + // signedIP
    "\n" +
    "https,http" + // signedProtocol
    "\n" +
sasKeyVersion + // signedVersion
    "\n" +
    "c" + // signedResource
    "\n" +
    "" + // signedSnapshotTime
    "\n" +
    "" + // rscc
    "\n" +
    "" + // rscd
    "\n" +
    "" + // rsce
    "\n" +
    "" + // rscl
    "\n" +
    ""; // rsct;

According to msdn, having constructed our StringToSign we need to produce "HMAC-SHA256(URL.Decode(UTF8.Encode(StringToSign)))". I wish the documentation provided you with sample inputs and outputs so that you could verify the function if you were forced to create it.

Heres the HMAC function I've put together:

async function myHMAC(base64Key, plainTextMessage) {
  const decodedFromB64Key = atob(base64Key);
  const cryptoKeyObj = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(decodedFromB64Key), // convert key to ArrayBuffer
    { name: "HMAC", hash: "SHA-256" }, // HmacImportParams obj
    true, // extractable
    ["sign", "verify"]
  );

  // message to sign must be URL.Decode(UTF8.Encode(StringToSign))
  // but doing these things makes no difference to returned value so unused
  const utf8StringToSign = unescape(encodeURIComponent(plainTextMessage));
  const urlDecodedUft8StringToSign = decodeURIComponent(utf8StringToSign);

  const messageArrayBuffer = new TextEncoder().encode(
    plainTextMessage
  );
  const signature = await crypto.subtle.sign(
    "HMAC",
    cryptoKeyObj,
    messageArrayBuffer
  );
  // return base64(signature)
  return btoa(String.fromCharCode(...new Uint8Array(signature)));
}

Get an HMAC like so:

const signedString = await myHMAC(sasKeyValue, StringToSign);

And ultimately suffix that value on to this URL and use in Postman:

console.log(
    "https://MYACCOUNT.blob.core.windows.net/MYCONTAINER" +
      "?restype=container" +
      "&comp=list" +
      "&sp=rl" +
      "&st=" +
      sasStart +
      "&se=" +
      sasExpiry +
      "&spr=https,http" +
      "&sv=" +
      sasKeyVersion +
      "&sr=c" +
      "&sig="
  );

Response:

<?xml version="1.0" encoding="utf-8"?>
<Error>
    <Code>AuthenticationFailed</Code>
    <Message>Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature.
        RequestId: X
        Time:Y</Message>
    <AuthenticationErrorDetail>Signature fields not well formed.</AuthenticationErrorDetail>
</Error>

As is suggested there some problem with the fields. Should the fields in the StringToSign exactly match the fields passed as query parameters to the Blob REST API? I'm sure I've read that the StringToSign must include an empty string for any unused optional parameter - not sure how that would be dealt with as GET request query parameters. [1]: https://gauravmantri.com/2020/02/21/avoiding-authorizationfailed-error-when-hand-crafting-shared-access-signature-for-azure-storage/#disqus_thread


Solution

  • Try this code to create SAS token:

    StringToSign = 'xxxxxxx';
    
    let sig = crypto.createHmac('sha256', Buffer.from(key, 'base64')).update(StringToSign, 'utf8').digest('base64');
    let sasToken = `sv=${(signedversion)}&ss=${(signedservice)}&srt=${(signedresourcetype)}&sp=${(signedpermissions)}&se=${encodeURIComponent(signedexpiry)}&spr=${(signedProtocol)}&sig=${encodeURIComponent(sig)}`;
    
    console.log(sasToken)
    

    If you want to use SDK(@azure/storage-blob), you could use generateBlobSASQueryParameters method.

    // Generate user delegation SAS for a container
    const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
    const containerSAS = generateBlobSASQueryParameters({
        containerName, // Required
        permissions: ContainerSASPermissions.parse("racwdl"), // Required
        startsOn, // Optional. Date type
        expiresOn, // Required. Date type
        ipRange: { start: "0.0.0.0", end: "255.255.255.255" }, // Optional
        protocol: SASProtocol.HttpsAndHttp, // Optional
        version: "2018-11-09" // Must greater than or equal to 2018-11-09 to generate user delegation SAS
      },
      userDelegationKey, // UserDelegationKey
      accountName
    ).toString();