TableSasGenerator.java

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package com.azure.data.tables.implementation;

import com.azure.core.credential.AzureNamedKeyCredential;
import com.azure.core.util.logging.ClientLogger;
import com.azure.data.tables.TableServiceVersion;
import com.azure.data.tables.sas.TableSasIpRange;
import com.azure.data.tables.sas.TableSasPermission;
import com.azure.data.tables.sas.TableSasProtocol;
import com.azure.data.tables.sas.TableSasSignatureValues;

import java.time.OffsetDateTime;
import java.util.Locale;
import java.util.Objects;

import static com.azure.data.tables.implementation.TableSasUtils.computeHmac256;
import static com.azure.data.tables.implementation.TableSasUtils.formatQueryParameterDate;
import static com.azure.data.tables.implementation.TableSasUtils.tryAppendQueryParameter;

/**
 * A class containing utility methods for generating SAS tokens for the Azure Tables service.
 */
public class TableSasGenerator {
    private final ClientLogger logger = new ClientLogger(TableSasGenerator.class);
    private final OffsetDateTime expiryTime;
    private final OffsetDateTime startTime;
    private final String endPartitionKey;
    private final String endRowKey;
    private final String identifier;
    private final String sas;
    private final String startPartitionKey;
    private final String startRowKey;
    private final String tableName;
    private final TableSasProtocol protocol;
    private final TableSasIpRange sasIpRange;
    private String permissions;
    private String version;

    /**
     * Creates a new {@link TableSasGenerator} which will generate an table-level SAS signed with an
     * {@link AzureNamedKeyCredential}.
     *
     * @param sasValues The {@link TableSasSignatureValues} to generate the SAS token with.
     * @param tableName The table name.
     * @param azureNamedKeyCredential An {@link AzureNamedKeyCredential} whose key will be used to sign the SAS.
     */
    public TableSasGenerator(TableSasSignatureValues sasValues, String tableName,
                             AzureNamedKeyCredential azureNamedKeyCredential) {
        Objects.requireNonNull(sasValues, "'sasValues' cannot be null.");
        Objects.requireNonNull(azureNamedKeyCredential, "'azureNamedKeyCredential' cannot be null.");

        this.version = sasValues.getVersion();
        this.protocol = sasValues.getProtocol();
        this.startTime = sasValues.getStartTime();
        this.expiryTime = sasValues.getExpiryTime();
        this.permissions = sasValues.getPermissions();
        this.sasIpRange = sasValues.getSasIpRange();
        this.tableName = tableName;
        this.identifier = sasValues.getIdentifier();
        this.startPartitionKey = sasValues.getStartPartitionKey();
        this.startRowKey = sasValues.getStartRowKey();
        this.endPartitionKey = sasValues.getEndPartitionKey();
        this.endRowKey = sasValues.getEndRowKey();

        validateState();

        // Signature is generated on the un-url-encoded values.
        String canonicalName = getCanonicalName(azureNamedKeyCredential.getAzureNamedKey().getName());
        String stringToSign = stringToSign(canonicalName);
        String signature = computeHmac256(azureNamedKeyCredential.getAzureNamedKey().getKey(), stringToSign);

        this.sas = encode(signature);
    }

    /**
     * Get the SAS produced by this {@link TableSasGenerator}.
     *
     * @return The SAS produced by this {@link TableSasGenerator}.
     */
    public String getSas() {
        return sas;
    }

    private String encode(String signature) {
        /*
         * We should be url-encoding each key and each value, but because we know all the keys and values will encode to
         * themselves, we cheat except for the signature value.
         */
        StringBuilder sb = new StringBuilder();

        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_SERVICE_VERSION, this.version);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_START_TIME,
            formatQueryParameterDate(this.startTime));
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_EXPIRY_TIME,
            formatQueryParameterDate(this.expiryTime));
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_TABLE_NAME, tableName);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_SIGNED_PERMISSIONS, this.permissions);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_TABLE_START_PARTITION_KEY, startPartitionKey);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_TABLE_START_ROW_KEY, startRowKey);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_TABLE_END_PARTITION_KEY, endPartitionKey);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_TABLE_END_ROW_KEY, endRowKey);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_IP_RANGE, this.sasIpRange);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_PROTOCOL, this.protocol);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_SIGNED_IDENTIFIER, this.identifier);
        tryAppendQueryParameter(sb, StorageConstants.UrlConstants.SAS_SIGNATURE, signature);

        return sb.toString();
    }

    /**
     * Ensures that the builder's properties are in a consistent state.
     *
     * 1. If there is no version, use latest.
     * 2. If there is no identifier set, ensure expiryTime and permissions are set.
     * 4. Re-parse permissions depending on what the resource is. If it is an unrecognised resource, do nothing.
     */
    private void validateState() {
        if (version == null) {
            version = TableServiceVersion.getLatest().getVersion();
        }

        if (identifier == null) {
            if (expiryTime == null || permissions == null) {
                throw logger.logExceptionAsError(new IllegalStateException("If identifier is not set, expiry time "
                    + "and permissions must be set"));
            }
        }

        if (permissions != null) {
            if (tableName != null) {
                permissions = TableSasPermission.parse(permissions).toString();
            } else {
                // We won't re-parse the permissions if we don't know the type.
                logger.info("Not re-parsing permissions. Resource type is not table.");
            }
        }

        if ((startPartitionKey != null && startRowKey == null) || (startPartitionKey == null && startRowKey != null)) {
            throw logger.logExceptionAsError(new IllegalStateException("'startPartitionKey' and 'startRowKey' must "
                + "either be both provided or both null. One cannot be provided without the other."));
        }

        if ((endPartitionKey != null && endRowKey == null) || (endPartitionKey == null && endRowKey != null)) {
            throw logger.logExceptionAsError(new IllegalStateException("'endPartitionKey' and 'endRowKey' must either "
                + "be both provided or both null. One cannot be provided without the other."));
        }
    }

    /**
     * Computes the canonical name for a table resource for SAS signing.
     *
     * @param account Account of the storage account.
     *
     * @return Canonical name as a string.
     */
    private String getCanonicalName(String account) {
        // Table: "/table/account/tablename"
        return String.join("/", new String[]{"/table", account, tableName});
    }

    private String stringToSign(String canonicalName) {
        return String.join("\n",
            this.permissions == null ? "" : this.permissions,
            this.startTime == null ? "" : StorageConstants.ISO_8601_UTC_DATE_FORMATTER.format(this.startTime),
            this.expiryTime == null ? "" : StorageConstants.ISO_8601_UTC_DATE_FORMATTER.format(this.expiryTime),
            canonicalName.toLowerCase(Locale.ROOT),
            this.identifier == null ? "" : this.identifier,
            this.sasIpRange == null ? "" : this.sasIpRange.toString(),
            this.protocol == null ? "" : protocol.toString(),
            this.version == null ? "" : this.version,
            this.startPartitionKey == null ? "" : this.startPartitionKey,
            this.startRowKey == null ? "" : this.startRowKey,
            this.endPartitionKey == null ? "" : this.endPartitionKey,
            this.endRowKey == null ? "" : this.endRowKey
        );
    }
}