dvAzureStringToSign function

String dvAzureStringToSign({
  1. required String method,
  2. required String account,
  3. required String resourcePath,
  4. required Map<String, String> queryParameters,
  5. required Map<String, String> headers,
  6. int contentLength = 0,
})

Builds the string Azure signs for a Shared Key request.

resourcePath is the encoded path as it goes on the wire, not the decoded one. Exposed for testing: the ordering rules are the part that goes wrong, and a wrong one is only visible as a 403 from the far end.

Implementation

String dvAzureStringToSign({
  required String method,
  required String account,
  required String resourcePath,
  required Map<String, String> queryParameters,
  required Map<String, String> headers,
  int contentLength = 0,
}) {
  String header(String name) => headers[name.toLowerCase()] ?? '';

  // Azure wants a blank here for an empty body, not a zero. Sending "0" is a
  // well-formed request that fails to authenticate.
  final String length = contentLength == 0 ? '' : '$contentLength';

  final List<String> canonicalHeaders = headers.entries
      .where((MapEntry<String, String> e) => e.key.startsWith('x-ms-'))
      .map((MapEntry<String, String> e) =>
          '${e.key.toLowerCase()}:${e.value.trim()}')
      .toList()
    ..sort();

  final List<String> canonicalQuery = queryParameters.entries
      .map((MapEntry<String, String> e) =>
          '${e.key.toLowerCase()}:${e.value}')
      .toList()
    ..sort();

  return <String>[
    method.toUpperCase(),
    header('content-encoding'),
    header('content-language'),
    length,
    header('content-md5'),
    header('content-type'),
    // The Date header is empty because x-ms-date carries it, and sending both
    // makes Azure sign one and read the other.
    '',
    header('if-modified-since'),
    header('if-match'),
    header('if-none-match'),
    header('if-unmodified-since'),
    header('range'),
    ...canonicalHeaders,
    '/$account$resourcePath',
    ...canonicalQuery,
  ].join('\n');
}