Skip to content

6704: Azure Blob Storage URL Signing and Refresh Support#11137

Open
adamfisher wants to merge 5 commits intodanny-avila:mainfrom
adamfisher:new/feature/6704-add-azure-blob-private-container
Open

6704: Azure Blob Storage URL Signing and Refresh Support#11137
adamfisher wants to merge 5 commits intodanny-avila:mainfrom
adamfisher:new/feature/6704-add-azure-blob-private-container

Conversation

@adamfisher
Copy link
Copy Markdown

Summary

This PR adds signed URL (SAS token) support for Azure Blob Storage, bringing feature parity with the existing S3 implementation. When AZURE_STORAGE_PUBLIC_ACCESS=false, files are served via time-limited signed URLs that are automatically refreshed before expiration.

See: [Enhancement]: Azure Blob Storage private container support

Documentation PR: LibreChat-AI/librechat.ai#474

Features

  • Automatic URL signing when AZURE_STORAGE_PUBLIC_ACCESS=false
  • Two signing methods automatically selected based on configuration:
    • Account Key signing - when AZURE_STORAGE_CONNECTION_STRING is set
    • User Delegation SAS - when only AZURE_STORAGE_ACCOUNT_NAME is set (Managed Identity)
  • Automatic URL refresh for files and avatars before expiration
  • Dynamic refresh cache duration based on URL expiry time (fixes issue where 30-minute cache caused 2-minute URLs to expire)
  • Access mode transitions - handles switching between public/private access gracefully

Files Changed

Core Implementation

  • api/server/services/Files/Azure/crud.js

    • Added getSignedAzureURL() - generates SAS tokens via Account Key or User Delegation
    • Added getUserDelegationKey() - obtains and caches delegation keys (7-day lifespan, refreshed 5 min before expiry)
    • Added needsRefreshAzure() - checks if URL needs refresh based on expiry or access mode change
    • Added extractBlobPathFromAzureUrl() - extracts blob path from Azure URL
    • Added getNewAzureURL() - generates fresh signed or plain URL based on current access mode
    • Added refreshAzureFileUrls() - batch refresh for file arrays
    • Added refreshAzureUrl() - single file URL refresh
    • Modified saveBufferToAzure() - returns signed URL when private access enabled
    • Modified streamFileToAzure() - returns signed URL when private access enabled
  • api/server/services/Files/Azure/images.js

    • Fixed processAzureAvatar() - removed erroneous ?manual=true suffix that broke signed URLs

Route Integration

  • api/server/routes/files/files.js
    • Added Azure file URL refresh support parallel to S3
    • Added getRefreshCacheTime() helper for dynamic cache duration based on URL expiry
    • Uses separate cache key (AZURE_EXPIRY_INTERVAL) from S3

Controller Integration

  • api/server/controllers/UserController.js

    • Added Azure avatar refresh in getUserController() parallel to existing S3 logic
  • api/server/controllers/agents/v1.js

    • Added Azure avatar refresh for agent avatars in refreshListAvatars() and getAgentHandler()

Cache Configuration

  • packages/data-provider/src/config.ts

    • Added CacheKeys.AZURE_EXPIRY_INTERVAL enum value
  • api/cache/getLogStores.js

    • Added AZURE_EXPIRY_INTERVAL cache store with 30-minute TTL

Tests

  • api/test/server/services/Files/Azure/crud.test.js
    • Tests for needsRefreshAzure() with various URL states
    • Tests for extractBlobPathFromAzureUrl()

Documentation

  • pages/docs/configuration/cdn/azure.mdx
    • Added URL Signing Configuration section
    • Documented AZURE_URL_EXPIRY_SECONDS environment variable
    • Explained automatic signing method selection
    • Added Managed Identity role requirements
    • Added local testing instructions for signed URLs

Environment Variables

Variable Default Description
AZURE_URL_EXPIRY_SECONDS 120 SAS token validity period in seconds (max: 7 days). Recommended: 3600
AZURE_STORAGE_PUBLIC_ACCESS - false enables URL signing, true uses plain URLs
AZURE_STORAGE_CONNECTION_STRING - Triggers Account Key signing when present
AZURE_STORAGE_ACCOUNT_NAME - Triggers User Delegation signing when no connection string

Breaking Changes

None. Existing deployments with AZURE_STORAGE_PUBLIC_ACCESS=true continue to work unchanged.


Testing Steps

Prerequisites

  • Azure Storage Account with a container named files
  • LibreChat application with Azure Blob Storage configured

Test 1: Account Key Signing (Connection String)

Setup

# .env
AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=yourAccount;AccountKey=yourKey;EndpointSuffix=core.windows.net
AZURE_STORAGE_PUBLIC_ACCESS=false
AZURE_CONTAINER_NAME=files
AZURE_URL_EXPIRY_SECONDS=120

Steps

  1. Start LibreChat
  2. Upload an image in a chat conversation
  3. Inspect the image URL in browser dev tools - should contain SAS parameters (sv=, se=, sig=)
  4. Verify image displays correctly
  5. Wait for URL to approach expiry (or set AZURE_URL_EXPIRY_SECONDS=60 for faster testing)
  6. Refresh the file list or navigate away and back
  7. Verify the URL is refreshed with new expiry time

Expected Results

  • ✅ Image URL contains SAS token parameters
  • ✅ Image loads successfully
  • ✅ URL refreshes before expiration

Test 2: User Delegation SAS (Managed Identity)

Setup

Deploy to Azure Container Apps, App Service, or VM with:

# .env (NO connection string)
AZURE_STORAGE_ACCOUNT_NAME=yourAccount
AZURE_STORAGE_PUBLIC_ACCESS=false
AZURE_CONTAINER_NAME=files
AZURE_URL_EXPIRY_SECONDS=300

Azure Role Assignments

Assign to the Managed Identity:

  • Storage Blob Data Contributor
  • Storage Blob Delegator
# Example for Azure Container Apps
PRINCIPAL_ID=$(az containerapp show --name your-app --resource-group your-rg --query identity.principalId -o tsv)
STORAGE_SCOPE="/subscriptions/{sub-id}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{account}"

az role assignment create --assignee $PRINCIPAL_ID --role "Storage Blob Data Contributor" --scope $STORAGE_SCOPE
az role assignment create --assignee $PRINCIPAL_ID --role "Storage Blob Delegator" --scope $STORAGE_SCOPE

Steps

  1. Deploy application to Azure
  2. Upload an image in a chat conversation
  3. Inspect the image URL - should contain User Delegation SAS parameters (skoid=, sktid=, skt=, ske=)
  4. Verify image displays correctly
  5. Check logs for "User delegation key obtained" message
  6. Wait for URL to approach expiry
  7. Verify URL refresh works

Expected Results

  • ✅ Image URL contains User Delegation SAS parameters
  • ✅ Image loads successfully
  • ✅ Logs show delegation key caching
  • ✅ URL refreshes before expiration

Test 3: User Avatar Upload and Refresh

Steps

  1. Go to Settings → Account → Profile Picture
  2. Upload a new avatar image
  3. Verify avatar displays in the UI
  4. Inspect avatar URL - should be signed (when AZURE_STORAGE_PUBLIC_ACCESS=false)
  5. Verify URL does NOT contain ?manual=true suffix
  6. Log out and log back in
  7. Verify avatar URL is refreshed if near expiry

Expected Results

  • ✅ Avatar uploads successfully
  • ✅ Avatar URL is properly signed (no ?manual=true)
  • ✅ Avatar displays correctly
  • ✅ Avatar URL refreshes on user data fetch

Test 4: Agent Avatar Refresh

Steps

  1. Create an agent with a custom avatar
  2. Verify agent avatar displays in agent list
  3. Inspect avatar URL - should be signed
  4. Wait for URL to approach expiry
  5. Refresh agent list
  6. Verify avatar URL is refreshed

Expected Results

  • ✅ Agent avatar is signed
  • ✅ Agent avatar refreshes before expiration

Test 5: Public Access Mode

Setup

AZURE_STORAGE_PUBLIC_ACCESS=true

Steps

  1. Upload an image
  2. Inspect URL - should be plain (no SAS parameters)
  3. Verify image loads

Expected Results

  • ✅ URL has no SAS token
  • ✅ Image loads (container must allow public access)

Test 6: Access Mode Transition (Public → Private)

Steps

  1. With AZURE_STORAGE_PUBLIC_ACCESS=true, upload an image
  2. Note the plain URL
  3. Change to AZURE_STORAGE_PUBLIC_ACCESS=false and restart
  4. Navigate to the conversation with the image
  5. Refresh or wait for refresh cycle

Expected Results

  • ✅ Plain URL is detected as needing signing
  • ✅ URL is updated to signed version
  • ✅ Image continues to display

Test 7: Dynamic Cache Duration

Setup

AZURE_URL_EXPIRY_SECONDS=120  # 2 minutes

Steps

  1. Upload a file
  2. Check server logs for refresh activity
  3. Verify refresh happens within ~1 minute (half of expiry time)

Setup 2

AZURE_URL_EXPIRY_SECONDS=7200  # 2 hours

Steps

  1. Upload a file
  2. Verify refresh check is cached for ~30 minutes (half of expiry, but capped internally)

Expected Results

  • ✅ Short expiry = more frequent refresh checks
  • ✅ Long expiry = less frequent refresh checks

Test 8: Local Development with Azurite

Setup

AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
AZURE_STORAGE_PUBLIC_ACCESS=false
AZURE_URL_EXPIRY_SECONDS=300

Steps

  1. Start Azurite (VS Code extension, Docker, or npm)
  2. Start LibreChat
  3. Upload an image
  4. Verify signed URL works with local Azurite

Expected Results

  • ✅ Signed URLs work with Azurite emulator

Test 9: Unit Tests

cd api
npm test -- test/server/services/Files/Azure/crud.test.js

Expected Results

  • ✅ All tests pass
  • needsRefreshAzure correctly identifies expired URLs
  • needsRefreshAzure correctly identifies valid URLs
  • extractBlobPathFromAzureUrl extracts paths correctly

Rollback Plan

If issues occur, revert to public access:

AZURE_STORAGE_PUBLIC_ACCESS=true

Or revert to connection string if Managed Identity issues:

AZURE_STORAGE_CONNECTION_STRING=your-connection-string

@sidanaabhi
Copy link
Copy Markdown

BUMP

@ronniekolk
Copy link
Copy Markdown

@adamfisher Thats very cool and exactly what we are looking for, regarding some Security Risks Wokring with "Public" Azure Blob Storage. Any Timeline Idea on this ?

@adamfisher
Copy link
Copy Markdown
Author

I'm not sure when they will get to reviewing this PR. I've brought it up many times on discord in two different channels asking for an ETA. It might help if others are asking about it too on discord.

Comment thread api/server/routes/files/files.js Outdated
Comment thread api/server/services/Files/Azure/crud.js
Comment thread api/server/controllers/agents/v1.js Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements signed URL (SAS token) support for Azure Blob Storage to enable private container access, achieving feature parity with the existing S3 implementation. When AZURE_STORAGE_PUBLIC_ACCESS=false, files are served via time-limited signed URLs that automatically refresh before expiration. The implementation supports two signing methods that are automatically selected based on configuration: Account Key signing (when connection string is set) and User Delegation SAS (when using Managed Identity).

Changes:

  • Added Azure Blob Storage URL signing with automatic refresh functionality
  • Implemented dynamic cache duration based on URL expiry time to prevent premature expiration
  • Fixed avatar URL generation to remove erroneous ?manual=true suffix that broke signed URLs
  • Integrated Azure URL refresh logic into file routes, user controller, and agent controllers parallel to existing S3 implementation

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 19 comments.

Show a summary per file
File Description
api/server/services/Files/Azure/crud.js Core implementation: added URL signing, refresh logic, and helper functions for SAS token generation
api/server/services/Files/Azure/images.js Fixed avatar processing to return clean URLs without manual flag suffix
api/server/routes/files/files.js Added Azure file URL refresh support with dynamic cache time calculation
api/server/controllers/UserController.js Integrated Azure avatar URL refresh for user profile pictures
api/server/controllers/agents/v1.js Added Azure avatar refresh for agent avatars in list and detail views
packages/data-provider/src/config.ts Added AZURE_EXPIRY_INTERVAL cache key enum
api/cache/getLogStores.js Configured Azure expiry interval cache store with 30-minute TTL
api/test/server/services/Files/Azure/crud.test.js Added unit tests for URL refresh and blob path extraction logic

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread api/server/controllers/agents/v1.js Outdated
Comment thread api/test/server/services/Files/Azure/crud.test.js
Comment thread api/server/services/Files/Azure/crud.js Outdated
Comment thread api/server/services/Files/Azure/crud.js
Comment thread api/server/services/Files/Azure/crud.js
Comment thread api/server/services/Files/Azure/crud.js Outdated
Comment thread api/server/services/Files/Azure/crud.js
Comment thread api/server/routes/files/files.js Outdated
Comment thread api/server/routes/files/files.js Outdated
Comment thread api/test/server/services/Files/Azure/crud.test.js
…ainer

# Conflicts:
#	api/server/controllers/agents/v1.js
#	api/server/services/Files/Azure/crud.js
@adamfisher
Copy link
Copy Markdown
Author

danny-avila's CHANGES_REQUESTED (the critical ones):

  1. Helper in dedicated module (files.js route → utils/getFileStrategy.js)
    • Moved getFileUrlRefreshCacheTime to api/server/utils/getFileStrategy.js, renamed to getFileURLRefreshCacheTime (consistent URL capitalization), added JSDoc explaining the * 500 half-expiry logic
    • Route now imports it; Time import removed from route since it's no longer needed there
    • Error log now includes the strategy name for better debugging
  2. Dependency injection pattern (v1.js and UserController.js)
    • v1.js: Added urlRefreshersBySource map; getAgentHandler now dispatches via urlRefreshersBySource[source] instead of an if/else chain; getListAgentsHandler now passes refreshAzureUrl to refreshListAvatars and applies the urlCache for all signed sources (not just S3)
    • UserController.js: Replaced duplicated if/else S3/Azure blocks with avatarNeedsRefreshBySource and getNewAvatarUrlBySource dispatch maps
  3. packages/api/src/agents/avatars.ts: Added optional refreshAzureUrl param (backward-compatible); filter and refresh logic now handle both S3 and Azure blob sources via the injected functions

Copilot actionable comments:

  1. Redundant dynamic imports (crud.js): BlobSASPermissions, generateBlobSASQueryParameters, StorageSharedKeyCredential removed from the dynamic import() inside getSignedAzureURL — only BlobServiceClient is dynamically imported now
  2. Missing JSDoc: Added complete JSDoc for getUserDelegationKey, getSignedAzureURL, needsRefreshAzure, extractBlobPathFromAzureUrl, getNewAzureURL, refreshAzureFileUrls, and refreshAzureUrl
  3. config.ts: Fixed indentation and capitalization of the AZURE_EXPIRY_INTERVAL comment
  4. Tests: Added test suite for getNewAzureURL (invalid URL and public-access path) and a dedicated needsRefreshAzure public→private transition test

@sidanaabhi
Copy link
Copy Markdown

@danny-avila Are we going to merge this?

@rahepler2
Copy link
Copy Markdown

Would love to see this implemented.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants