Azure App Secret Rotation

Automatically rotate the secret of an Azure app using Keeper Secrets Manager rotations

Overview

This documentation explains how to rotate Azure application secrets using Keeper Secrets Manager rotation’s “NOOP mode”. This is a flag set in the Keeper record which tells the Gateway to skip the primary rotation method and directly execute the post-rotation script attached to the PAM User record in the vault.

This guide includes prerequisites, step-by-step instructions, and a Python script example. The script ensures secure application secrets rotation, including deletion of previous application secrets, and stores the new application secret in Keeper. This new secret is automatically available to all already allowed KSM applications and users.

Prerequisites

  1. KSM role: Ensure that the Keeper user has a role providing access to Keeper Secrets Manager, and Keeper Secrets Manager rotations.

  2. A Linux instance to run the Keeper Gateway: The Gateway can be deployed in any private Cloud or on-prem environment. The gateway host will need to have a supported Python version installed with the 2 dependencies below:

pip3 install keeper_secrets_manager_core
pip3 install azure.identity

Rotation Script Logic Flow

1. Admin Credentials Retrieval

The script retrieve admin credentials in three ways:

  1. Record directly attached to the post rotation script.

  2. The access key provided to the Azure PAM config selected for the rotation. This will be used if no access key is found in the record(s) attached (method 1 above) to the post rotation script.

Attaching another record containing the admin application secret to the PAM Script will allow to easily rotate this admin application secrets in that other record using the same process described in this documentation.

2. Secret Rotation Logic

The script will:

  1. Retrieve an admin application secret either from an attached record to the PAM Script or from the PAM Config.

  2. Get a Microsoft Graph access token using the admin application secret found at the step above.

  3. Create a new client secret on the Azure application defined in the PAM User record.

  4. Delete all other existing secrets for the defined Azure application. Only the one generated at the step above will be kept.

  5. Update the Keeper PAM User record with the new secret, and secret ID.

PAM User Record - Fields Requirements

You need to create a PAM User record where the rotation will be configured later on. The fields below need to be created.

Fields required:

Field Name
Description

Login

This mandatory field is not used in this script. You can use the field to store any useful information, like the name of the Azure app that will be rotated.

Password

It will be a dummy value in this case. The password field gets automatically rotated, but it is not used anywhere. This is still required field.

Custom fields required:

Custom Field Label
Field Type
Description
application_object_id

Text

This field is used to specify which application in Azure you want to rotate. You need to retrieve the application object ID of the application to rotate from the Azure portal > App Registration > Overview tab of your app > Application (client) ID.

client_secret_id

Text

This field will receive the new client secret ID after the rotation.

client_secret

Hidden Field

This field will receive the new client secret after the rotation.

expires

Text

This field will receive the expiration date of the new secret after the rotation.

NOOP

Text

This rotation requires the gateway to only execute the rotation script, and not try to rotate something using the built-in rotation features.

The value has to be:

True
Private Key Type

Text

Second field to enable NOOP.

The value has to be:

rsa-ssh

Instead of creating the PAM User record manually using the details above, you could also import the csv file below. It will create a template record you can amend and duplicate as needed.

Importing the file will generate a Login record type: make sure to convert it to PAM User.

Setting Up the Rotation in the Keeper Vault

The script require an admin application secret to authenticate against Azure and rotate another application's secret. Here we will be using the admin app secret provided in the Azure PAM Configuration.

Configuration From the Keeper Vault:

  1. Create a shared folder in the vault

  2. Create a PAM User record in the shared folder with the fields and custom fields described above.

  1. In the Secret Manager tab of the Keeper vault, create a new application for the gateway if there is no gateway yet.

  2. Make sure the Application has edit permissions on the shared folder created above.

  3. Provision the gateway (gateway tab after selecting the application) on a Linux box. Simply run the install command provided by the Keeper vault and make sure Python and the dependencies listed above are installed.

  4. In the Secret Manager tab of the Keeper vault, go to the PAM Configurations tab. Create a new PAM configuration if needed.

  5. Under Environment, please select “Azure”, select the Gateway, select the shared folder, provide the “Entra ID” name (arbitrary name of your Entra ID environment), the admin application “Client ID” (Overview tab of the admin application in the Azure portal), “Client Secret” (Certificates & secrets tab of the admin application in the Azure portal), "Subscription ID" and "Tenant ID".

  1. Edit the PAM User record previously described in this documentation:

    • Password Rotation Settings: select your desired schedule and the PAM configuration created above.

    • Add PAM Script to the record: select the provided file below and make sure to specify the script command:

python3

Python Script

import logging
import requests
import sys
import base64
import json
from azure.identity import ClientSecretCredential
from datetime import datetime, timedelta, timezone
from keeper_secrets_manager_core import SecretsManager
from keeper_secrets_manager_core.storage import FileKeyValueStorage

# sys.stdin is not an array, it cannot be subscripted (i.e., sys.stdin[0])
for base64_params in sys.stdin:
    # Retrieve params from the gateway
    params = json.loads(base64.b64decode(base64_params).decode('utf-8'))
    break

# Retrieve records attached to the post-rotation script in Keeper
records = json.loads(base64.b64decode(params.get('records')).decode('utf-8'))

# Initiate Keeper Secrets Manager SDK using the gateway configuration
secrets_manager = SecretsManager(config=FileKeyValueStorage('/etc/keeper-gateway/gateway-config.json'))

# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

GRAPH_API_URL = "https://graph.microsoft.com/v1.0"

# Function to get Microsoft Graph access token using ClientSecretCredential
def get_access_token(tenant_id, client_id, client_secret):
    try:
        credential = ClientSecretCredential(tenant_id=tenant_id, client_id=client_id, client_secret=client_secret)
        token = credential.get_token("https://graph.microsoft.com/.default")
        logger.info("Authentication successful.")
        return token.token
    except Exception as e:
        logger.error(f"Authentication failed: {e}")
        raise

# Function to create a new client secret and return its ID and secret text
def create_client_secret(access_token, target_app_id, secret_expiry_days=365):
    try:
        # Set expiry time for the secret using timezone-aware datetime
        expiry_time = (datetime.now(timezone.utc) + timedelta(days=secret_expiry_days)).isoformat()

        # API request body to create a new client secret
        body = {
            "passwordCredential": {
                "displayName": "Client Secret Rotated by Keeper",
                "endDateTime": expiry_time
            }
        }

        headers = {
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json"
        }

        response = requests.post(f"{GRAPH_API_URL}/applications/{target_app_id}/addPassword", json=body, headers=headers)
        
        if response.status_code == 200:
            response_json = response.json()
            new_client_secret = response_json['secretText']
            secret_id = response_json['keyId']  # Fetch the ID of the new secret

            # Log both the new secret and its ID
            logger.info(f"New client secret created for Application {target_app_id}. Secret ID: {secret_id}.")
            return secret_id, new_client_secret, expiry_time
        else:
            logger.error(f"Failed to create client secret: {response.status_code}, {response.text}")
            raise Exception(f"Failed to create client secret: {response.status_code}")
    except Exception as e:
        logger.error(f"An error occurred: {e}")
        raise

# Function to retrieve existing client secrets
def get_existing_client_secrets(access_token, target_app_id):
    try:
        headers = {
            "Authorization": f"Bearer {access_token}"
        }

        response = requests.get(f"{GRAPH_API_URL}/applications/{target_app_id}", headers=headers)
        if response.status_code == 200:
            app = response.json()
            logger.info(f"Retrieved existing client secrets for Application {target_app_id}.")
            return app.get('passwordCredentials', [])
        else:
            logger.error(f"Failed to retrieve client secrets: {response.status_code}, {response.text}")
            raise Exception(f"Failed to retrieve client secrets: {response.status_code}")
    except Exception as e:
        logger.error(f"An error occurred: {e}")
        raise

# Function to delete old client secrets, excluding the newly created one
def delete_old_secrets(access_token, target_app_id, new_client_secret_id):
    try:
        headers = {
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json"
        }

        existing_secrets = get_existing_client_secrets(access_token, target_app_id)
        
        # Iterate through all secrets and delete the ones that are not the newly created secret
        for secret in existing_secrets:
            if secret['keyId'] != new_client_secret_id:
                body = {
                    "keyId": secret['keyId']
                }
                response = requests.post(f"{GRAPH_API_URL}/applications/{target_app_id}/removePassword", json=body, headers=headers)

                if response.status_code == 204:
                    logger.info(f"Deleted secret with ID: {secret['keyId']} for Application {target_app_id}.")
                else:
                    logger.error(f"Failed to delete secret: {response.status_code}, {response.text}")
                    raise Exception(f"Failed to delete secret: {response.status_code}")
    except Exception as e:
        logger.error(f"An error occurred: {e}")
        raise

# Main function
def rotate_client_secret(tenant_id, client_id, client_secret, target_app_id, secret_expiry_days=365, delete_old_secrets_flag=False):
    try:
        # Step 1: Get access token
        access_token = get_access_token(tenant_id, client_id, client_secret)
        
        # Step 2: Create a new client secret and get its ID
        new_client_secret_id, new_client_secret, expiry_time = create_client_secret(access_token, target_app_id, secret_expiry_days)
        if not new_client_secret:
            logger.error("Failed to create a new client secret. Exiting.")
            return None, None, None  # Prevent further execution if secret creation failed
        
        logger.info(f"New client secret id: {new_client_secret_id}. Expiration date: {expiry_time}")
        
        # Step 3: Optionally delete old secrets, but exclude the newly created one
        if delete_old_secrets_flag:
            delete_old_secrets(access_token, target_app_id, new_client_secret_id)

        return new_client_secret_id, new_client_secret, expiry_time
    except Exception as e:
        logger.error(f"An error occurred during the secret rotation process: {e}")
        return None, None, None  # Return None in case of an error

# Inputs from the gateway and full PAM user query using KSM
pam_user_to_update = secrets_manager.get_secrets([params.get('userRecordUid')])[0]

# Retrieve app object id to rotate
try:
    target_app_id = pam_user_to_update.custom_field('application_object_id', single=True)
except Exception as e:
    raise RuntimeError(f"No app object id found, the script will exit after this error: {e}")

# Retrieve admin user account access key
admin_cred_provided = False
for record in records:
    if "application_client_id" in record:
        client_id = record["application_client_id"]
        client_secret = record["client_secret"]
        tenant_id = record["tenant_id"]
        admin_cred_provided = True
        logger.info(f"Admin creds provided. Using secret_id {client_id}, from attached record UID {record['uid']}.")
        break
    elif "clientId" in record:
        client_id = record["clientId"]
        client_secret = record["clientSecret"]
        tenant_id = record["tenantId"]
        admin_cred_provided = True
        logger.info(f"Admin creds provided. Using secret_id {client_id}, from attached PAM Config record UID {record['uid']}.")
        break

# If there is no app key provided to an attached record, then use the PAM config.
if admin_cred_provided == False:
    try:
        pam_config = secrets_manager.get_secrets([params.get('providerRecordUid')])[0]
        client_id = pam_config.field('clientId', single=True)
        client_secret = pam_config.field('clientSecret', single=True)
        tenant_id = pam_config.field('tenantId', single=True)
        admin_cred_provided = True
        logger.info(f"Using PAM config creds. Using secret_id {client_id}, from PAM Config UID.")
    except Exception as e:
        logger.error(f"Error retrieving admin creds from PAM config: {e}")

# Exit if no credentials were provided
if not admin_cred_provided:
    raise RuntimeError("No admin creds provided. The script will exit.")

# Rotate client secret and handle success/failure
new_client_secret_id, new_client_secret, expiry_time = rotate_client_secret(
    tenant_id, client_id, client_secret, target_app_id, secret_expiry_days=365, delete_old_secrets_flag=True
)

if new_client_secret_id and new_client_secret:
    logger.info("\nRotation Result:")
    logger.info(f"New secret id: {new_client_secret_id}")

    # Update the PAM User record in Keeper
    try:
        pam_user_to_update.custom_field('client_secret_id', new_client_secret_id)
        pam_user_to_update.custom_field('client_secret', new_client_secret)
        pam_user_to_update.custom_field('expires', expiry_time)
        secrets_manager.save(pam_user_to_update)
    except Exception as e:
        logger.error(f"Error while updating PAM User record in Keeper: {e}")

Last updated