diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index a904504fa..e3f9da76b 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -11,6 +11,10 @@
+
+
+
+
@@ -200,11 +204,13 @@
+
+
@@ -264,11 +270,13 @@
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8c2d31084..128c62348 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,6 +5,7 @@
+
@@ -69,6 +70,7 @@
+
diff --git a/README.md b/README.md
index c5f35f90d..658467488 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,8 @@ This repository contains the source code for the Aspire Community Toolkit, a col
| - **Learn More**: [`Hosting.SqlDatabaseProjects`][sql-database-projects-integration-docs] - Stable ๐ฆ: [![CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects][sql-database-projects-shields]][sql-database-projects-nuget] - Preview ๐ฆ: [![CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects][sql-database-projects-shields-preview]][sql-database-projects-nuget-preview] | A hosting integration for the SQL Databases Projects. |
| - **Learn More**: [`Hosting.Rust`][rust-integration-docs] - Stable ๐ฆ: [![CommunityToolkit.Aspire.Hosting.Rust][rust-shields]][rust-nuget] - Preview ๐ฆ: [![CommunityToolkit.Aspire.Hosting.Rust][rust-shields-preview]][rust-nuget-preview] | A hosting integration for the Rust apps. |
| - **Learn More**: [`Hosting.Bun`][bun-integration-docs] - Stable ๐ฆ: [![CommunityToolkit.Aspire.Hosting.Bun][bun-shields]][bun-nuget] - Preview ๐ฆ: [![CommunityToolkit.Aspire.Hosting.Bun][bun-shields-preview]][bun-nuget-preview] | **Deprecated**: use the core `Aspire.Hosting.JavaScript` `AddBunApp(...)` integration. |
+| - **Learn More**: [`Hosting.Bitwarden.SecretManager`][bitwarden-secret-manager-integration-docs] - Stable ๐ฆ: [![CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager][bitwarden-secret-manager-hosting-shields]][bitwarden-secret-manager-hosting-nuget] - Preview ๐ฆ: [![CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager][bitwarden-secret-manager-hosting-shields-preview]][bitwarden-secret-manager-hosting-nuget-preview] | A hosting integration for Bitwarden Secrets Manager projects and managed secrets. |
+| - **Learn More**: [`Bitwarden.SecretManager`][bitwarden-secret-manager-integration-docs] - Stable ๐ฆ: [![CommunityToolkit.Aspire.Bitwarden.SecretManager][bitwarden-secret-manager-client-shields]][bitwarden-secret-manager-client-nuget] - Preview ๐ฆ: [![CommunityToolkit.Aspire.Bitwarden.SecretManager][bitwarden-secret-manager-client-shields-preview]][bitwarden-secret-manager-client-nuget-preview] | A client integration for authenticating and using the Bitwarden Secrets Manager SDK from Aspire applications. |
| - **Learn More**: [`Hosting.Perl`][perl-integration-docs] - Stable ๐ฆ: [![CommunityToolkit.Aspire.Hosting.Perl][perl-shields]][perl-nuget] - Preview ๐ฆ: [![CommunityToolkit.Aspire.Hosting.Perl][perl-shields-preview]][perl-nuget-preview] | A hosting integration for Perl scripts and APIs. |
| - **Learn More**: [`Hosting.Python.Extensions`][python-ext-integration-docs] - Stable ๐ฆ: [![CommunityToolkit.Aspire.Python.Extensions][python-ext-shields]][python-ext-nuget] - Preview ๐ฆ: [![CommunityToolkit.Aspire.Hosting.Python.Extensions][python-ext-shields-preview]][python-ext-nuget-preview] | An integration that contains some additional extensions for running python applications |
| - **Learn More**: [`Hosting.KurrentDB`][kurrentdb-integration-docs] - Stable ๐ฆ: [![CommunityToolkit.Aspire.Hosting.KurrentDB][kurrentdb-shields]][kurrentdb-nuget] - Preview ๐ฆ: [![CommunityToolkit.Aspire.Hosting.KurrentDB][kurrentdb-shields-preview]][kurrentdb-nuget-preview] | An Aspire hosting integration leveraging the [KurrentDB](https://www.kurrent.io) container. |
@@ -154,6 +156,15 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org)
[bun-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Bun/
[bun-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Bun?label=nuget%20(preview)
[bun-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Bun/absoluteLatest
+[bitwarden-secret-manager-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-bitwarden-secret-manager
+[bitwarden-secret-manager-hosting-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager
+[bitwarden-secret-manager-hosting-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/
+[bitwarden-secret-manager-hosting-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager?label=nuget%20(preview)
+[bitwarden-secret-manager-hosting-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/absoluteLatest
+[bitwarden-secret-manager-client-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Bitwarden.SecretManager
+[bitwarden-secret-manager-client-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Bitwarden.SecretManager/
+[bitwarden-secret-manager-client-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Bitwarden.SecretManager?label=nuget%20(preview)
+[bitwarden-secret-manager-client-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Bitwarden.SecretManager/absoluteLatest
[perl-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-perl
[perl-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.Perl
[perl-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Perl/
diff --git a/examples/bitwarden-secret-manager/.gitignore b/examples/bitwarden-secret-manager/.gitignore
new file mode 100644
index 000000000..3bd37b71f
--- /dev/null
+++ b/examples/bitwarden-secret-manager/.gitignore
@@ -0,0 +1,2 @@
+aspire-output
+.bitwarden/
\ No newline at end of file
diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService.csproj b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService.csproj
new file mode 100644
index 000000000..b277053a8
--- /dev/null
+++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService.csproj
@@ -0,0 +1,12 @@
+
+
+
+ enable
+ enable
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Program.cs b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Program.cs
new file mode 100644
index 000000000..e4d15fc84
--- /dev/null
+++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Program.cs
@@ -0,0 +1,44 @@
+using Bitwarden.Sdk;
+using CommunityToolkit.Aspire.Bitwarden.SecretManager;
+using Microsoft.AspNetCore.Mvc;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Register the Bitwarden secret manager client with Aspire configuration binding.
+// Use the same connection name as the Bitwarden project in the AppHost
+// Configuration is injected as Aspire:Bitwarden:SecretManager:{connection_name}:{setting}
+// (e.g. Aspire:Bitwarden:SecretManager:secrets:AccessToken).
+builder.AddBitwardenSecretManagerClient(connectionName: "secrets", settings =>
+{
+ // You can optionally override Aspire injected values here or set additional client settings.
+ settings.IdentityUrl = "https://vault.bitwarden.com/identity";
+ settings.ApiUrl = "https://vault.bitwarden.com/api";
+ settings.DisableHealthChecks = true;
+});
+
+var app = builder.Build();
+
+app.MapGet("/", ([FromQuery] string? apiKey, BitwardenClient client, BitwardenSecretManagerClientSettings settings, IConfiguration configuration) =>
+{
+ Guid secretId = configuration.GetValue("DEMO_API_KEY_SECRET_ID");
+ SecretResponse secret = client.Secrets.Get(secretId);
+ if (string.IsNullOrEmpty(apiKey))
+ {
+ return Results.Problem("Missing apiKey query parameter.", statusCode: StatusCodes.Status401Unauthorized);
+ }
+ else if (secret.Value != apiKey)
+ {
+ return Results.Problem("Invalid apiKey.", statusCode: StatusCodes.Status401Unauthorized);
+ }
+
+ return Results.Text("""
+ Access granted to protected resource!
+
+ But please don't use query parameters for API keys in real applications... this is just a demo!
+ Consider using an HTTP header or similar approach to keep secrets out of URLs and logs.
+ """);
+});
+
+app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
+
+app.Run();
\ No newline at end of file
diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Properties/launchSettings.json b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Properties/launchSettings.json
new file mode 100644
index 000000000..b22ea277e
--- /dev/null
+++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.ApiService/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5278",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7298;http://localhost:5278",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj
new file mode 100644
index 000000000..7810551c5
--- /dev/null
+++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ enable
+ enable
+ true
+ 632cd204-f3fe-4c77-98e1-fa65b87b5fa9
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Program.cs b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Program.cs
new file mode 100644
index 000000000..078213da9
--- /dev/null
+++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Program.cs
@@ -0,0 +1,105 @@
+using Aspire.Hosting.Docker.Resources.ServiceNodes;
+using Projects;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+var compose = builder.AddDockerComposeEnvironment("compose")
+ .WithDashboard(false);
+
+var organizationId = builder.AddParameter("bitwarden-organization-id");
+var project = builder.AddParameter("bitwarden-project");
+var accessToken = builder.AddParameter("bitwarden-access-token", secret: true);
+
+// Set up a secrets project within the specified organization using the provided management access token.
+// Project can be specified by name (for managed projects) or ID (for existing projects).
+// The management token MUST have write permissions to the project if it already exists.
+// If the project doesn't exist, it will be automatically created with write access for the provided token.
+var bitwarden = builder.AddBitwardenSecretManager("secrets", project, organizationId, accessToken);
+
+// For self-hosted installations, configure your API and Identity URLs here.
+// (Self-hosting requires an enterprise plan, so this example uses the default cloud-hosted Bitwarden instance.)
+var bitwardenApiServer = builder.AddExternalService("bitwarden-api", "https://api.bitwarden.com");
+var bitwardenIdentityServer = builder.AddExternalService("bitwarden-identity", "https://identity.bitwarden.com");
+
+bitwarden.WithApiUrl(bitwardenApiServer)
+ .WithIdentityUrl(bitwardenIdentityServer);
+
+// Optional: override the AppHost cache file location.
+// The cache stores the Bitwarden project ID and secret ID mappings between runs so the integration
+// can reuse existing Bitwarden resources rather than creating duplicates.
+// Override to share the cache across multiple AppHost projects, or to store it in a CI cache directory.
+// Relative paths are resolved from the AppHost directory.
+// Default: .bitwarden/{resourceName}.{environment}.json relative to the AppHost directory.
+bitwarden.WithCacheFile($".bitwarden/secrets.{builder.Environment.EnvironmentName}.json");
+
+// Optional: override the AppHost auth cache directory.
+// The auth cache stores the Bitwarden SDK auth session between runs so the integration can reuse the
+// session and avoid re-authenticating on every run.
+// Override to share the cache across multiple AppHost projects, or to store it in a CI cache directory.
+// Relative paths are resolved from the Aspire store directory.
+// Default: .bitwarden relative to the Aspire store directory.
+bitwarden.WithAuthCacheDirectory(".bitwarden");
+
+// Add a secret to the project with the value of the generated secret parameter.
+// Configure this value with `Parameters:secrets-demo-api-key`, or let Aspire prompt for it.
+// The secret is created or updated on each run. Use `GetSecret` if you only want to read an existing secret.
+var demoApiKeySecret = bitwarden.AddSecret("demo-api-key");
+var demoDbPasswordSecret = bitwarden.AddSecret("demo-db-password");
+
+// Register an API service that references the Bitwarden secret manager
+// There are two ways to reference secrets from the Bitwarden secret manager in Aspire.
+var api = builder.AddProject("api")
+ .WithHttpHealthCheck("/health")
+ .WithExternalHttpEndpoints();
+
+// 1. Using the secret manager client in code, which allows you to retrieve secrets at runtime and
+// supports dynamic secret retrieval without redeploying the application when secrets change.
+// (See ApiService/Program.cs for an example of retrieving secrets from the client in code.)
+api.WithReference(bitwarden)
+ .WaitForCompletion(bitwarden)
+ .WithEnvironment("DEMO_API_KEY_SECRET_ID", demoApiKeySecret.AsSecretId())
+ .WithEnvironment("DEMO_DB_PASSWORD_SECRET_ID", demoDbPasswordSecret.AsSecretId())
+ // Recommended: supply a least-privilege read-only access token so the client does not receive the management token.
+ // IMPORTANT: the client token must be granted read permissions to the Bitwarden project.
+ // This cannot be automated: Bitwarden does not expose an API for granting project access to a service account.
+ // You must grant the service account read access to the project manually in the Bitwarden web vault or CLI.
+ // For a newly created project this must be done after the first AppHost run that creates the project.
+ .WithBitwardenAccessToken(bitwarden, accessToken /* replace with least privilege token */);
+
+// Optional: override the client auth cache directory (separate from the AppHost auth cache).
+// The client auth cache stores the Bitwarden SDK auth session between restarts so the client can
+// reuse the session and avoid re-authenticating on every start (which would hit Bitwarden rate limits).
+if (builder.ExecutionContext.IsPublishMode)
+{
+ api.WithBitwardenAuthCacheDirectory(bitwarden, "/bitwarden/auth-cache");
+}
+
+compose.ConfigureComposeFile(root =>
+{
+ root.AddVolume(new Volume
+ {
+ Name = "bitwarden-auth-cache"
+ });
+});
+
+api.PublishAsDockerComposeService((resource, service) =>
+{
+ service.AddVolume(new Volume
+ {
+ Name = "bitwarden-auth-cache",
+ Type = "volume",
+ Source = "bitwarden-auth-cache",
+ Target = "/bitwarden/auth-cache"
+ });
+});
+
+// 2. Using direct secret references in the project configuration, which injects the secret value as an environment variable at runtime.
+// This approach is simpler (no Bitwarden code in the application) but requires redeploying the application whenever the secret value changes.
+var client = builder.AddContainer("client", "curlimages/curl")
+ .WithReference(api)
+ .WaitForCompletion(bitwarden)
+ .WithEnvironment("DEMO_API_KEY", demoApiKeySecret)
+ .WithEntrypoint("sh")
+ .WithArgs("-c", "curl -sv $API_HTTP?apiKey=$DEMO_API_KEY");
+
+builder.Build().Run();
diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Properties/launchSettings.json b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..4d08ac079
--- /dev/null
+++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17082;http://localhost:15082",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21082",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23082",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22082"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15082",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19082",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18082",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20082"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/aspire.config.json b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/aspire.config.json
new file mode 100644
index 000000000..2e143256c
--- /dev/null
+++ b/examples/bitwarden-secret-manager/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.AppHost.csproj"
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs
new file mode 100644
index 000000000..7408d96b1
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/AspireBitwardenSecretManagerExtensions.cs
@@ -0,0 +1,171 @@
+using Aspire;
+using Bitwarden.Sdk;
+using CommunityToolkit.Aspire.Bitwarden.SecretManager;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.Extensions.Hosting;
+
+///
+/// Extension methods for registering .
+///
+public static class AspireBitwardenSecretManagerExtensions
+{
+ private const string ConfigurationSection = "Aspire:Bitwarden:SecretManager";
+
+ ///
+ /// Registers a from structured Aspire configuration.
+ ///
+ /// The host application builder.
+ /// The connection name under Aspire:Bitwarden:SecretManager.
+ /// Optional settings override callback.
+ /// The host application builder.
+ public static void AddBitwardenSecretManagerClient(
+ this IHostApplicationBuilder builder,
+ string connectionName,
+ Action? configureSettings = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectionName);
+
+ AddBitwardenSecretManagerClient(builder, $"{ConfigurationSection}:{connectionName}", configureSettings, connectionName, serviceKey: null);
+ }
+
+ ///
+ /// Registers a keyed from structured Aspire configuration.
+ ///
+ /// The host application builder.
+ /// The connection name under Aspire:Bitwarden:SecretManager. The same value is also used as the DI service key.
+ /// Optional settings override callback.
+ public static void AddKeyedBitwardenSecretManagerClient(
+ this IHostApplicationBuilder builder,
+ string name,
+ Action? configureSettings = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ AddBitwardenSecretManagerClient(builder, $"{ConfigurationSection}:{name}", configureSettings, connectionName: name, serviceKey: name);
+ }
+
+ private static void AddBitwardenSecretManagerClient(
+ IHostApplicationBuilder builder,
+ string configurationSectionName,
+ Action? configureSettings,
+ string connectionName,
+ string? serviceKey)
+ {
+ BitwardenSecretManagerClientSettings settings = new();
+ builder.Configuration.GetSection(configurationSectionName).Bind(settings);
+ configureSettings?.Invoke(settings);
+ ValidateSettings(settings, connectionName);
+
+ if (serviceKey is null)
+ {
+ builder.Services.AddSingleton(settings);
+ builder.Services.AddSingleton(_ => CreateClient(settings));
+ }
+ else
+ {
+ builder.Services.AddKeyedSingleton(serviceKey, settings);
+ builder.Services.AddKeyedSingleton(serviceKey, (_, _) => CreateClient(settings));
+ }
+
+ RegisterHealthCheck(builder, settings, connectionName, serviceKey);
+ }
+
+ private static BitwardenClient CreateClient(BitwardenSecretManagerClientSettings settings)
+ {
+ BitwardenClient client = new(new BitwardenSettings
+ {
+ ApiUrl = settings.ApiUrl,
+ IdentityUrl = settings.IdentityUrl
+ });
+
+ string authCacheFile = string.Empty;
+ if (settings.AuthCacheDirectory is { Length: > 0 } authCacheDirectory)
+ {
+ Directory.CreateDirectory(authCacheDirectory);
+ string tokenId = ParseTokenId(settings.AccessToken) ?? "auth-cache";
+ authCacheFile = Path.Combine(authCacheDirectory, tokenId);
+ }
+
+ client.Auth.LoginAccessToken(settings.AccessToken, authCacheFile);
+ return client;
+ }
+
+ // Access token format: 0..:
+ // Returns the UUID component used as the auth cache filename, matching the AppHost convention.
+ private static string? ParseTokenId(string accessToken)
+ {
+ ReadOnlySpan span = accessToken.AsSpan();
+ int firstDot = span.IndexOf('.');
+ if (firstDot >= 0)
+ {
+ ReadOnlySpan rest = span[(firstDot + 1)..];
+ int secondDot = rest.IndexOf('.');
+ if (secondDot >= 0 && Guid.TryParse(rest[..secondDot], out Guid tokenId))
+ {
+ return tokenId.ToString("D");
+ }
+ }
+ return null;
+ }
+
+ private static void RegisterHealthCheck(
+ IHostApplicationBuilder builder,
+ BitwardenSecretManagerClientSettings settings,
+ string connectionName,
+ string? serviceKey)
+ {
+ if (settings.DisableHealthChecks)
+ {
+ return;
+ }
+
+ string healthCheckName = $"BitwardenSecretManager_{connectionName}";
+ builder.TryAddHealthCheck(new HealthCheckRegistration(
+ healthCheckName,
+ serviceProvider => new BitwardenSecretManagerHealthCheck(
+ serviceKey is null
+ ? serviceProvider.GetRequiredService()
+ : serviceProvider.GetRequiredKeyedService(serviceKey),
+ serviceKey is null
+ ? serviceProvider.GetRequiredService()
+ : serviceProvider.GetRequiredKeyedService(serviceKey)),
+ failureStatus: default,
+ tags: default,
+ timeout: settings.HealthCheckTimeout));
+ }
+
+ private static void ValidateSettings(BitwardenSecretManagerClientSettings settings, string connectionName)
+ {
+ if (settings.OrganizationId == Guid.Empty)
+ {
+ throw new InvalidOperationException($"Bitwarden client connection '{connectionName}' is missing a valid organization identifier.");
+ }
+
+ if (settings.ProjectId == Guid.Empty)
+ {
+ throw new InvalidOperationException($"Bitwarden client connection '{connectionName}' is missing a valid project identifier.");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.AccessToken))
+ {
+ throw new InvalidOperationException($"Bitwarden client connection '{connectionName}' is missing an access token.");
+ }
+
+ ValidateAbsoluteUri(settings.ApiUrl, nameof(settings.ApiUrl), connectionName);
+ ValidateAbsoluteUri(settings.IdentityUrl, nameof(settings.IdentityUrl), connectionName);
+ }
+
+ private static void ValidateAbsoluteUri(string value, string propertyName, string connectionName)
+ {
+ if (string.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value, UriKind.Absolute, out _))
+ {
+ throw new InvalidOperationException($"Bitwarden client connection '{connectionName}' has an invalid {propertyName} value.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs
new file mode 100644
index 000000000..3bd271042
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerClientSettings.cs
@@ -0,0 +1,52 @@
+using Bitwarden.Sdk;
+
+namespace CommunityToolkit.Aspire.Bitwarden.SecretManager;
+
+///
+/// Settings used to configure a .
+///
+public sealed class BitwardenSecretManagerClientSettings
+{
+ ///
+ /// Gets or sets the Bitwarden organization identifier.
+ ///
+ public Guid OrganizationId { get; set; }
+
+ ///
+ /// Gets or sets the Bitwarden project identifier.
+ ///
+ public Guid ProjectId { get; set; }
+
+ ///
+ /// Gets or sets the Bitwarden access token.
+ ///
+ public string AccessToken { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the Bitwarden API URL.
+ ///
+ public string ApiUrl { get; set; } = "https://api.bitwarden.com";
+
+ ///
+ /// Gets or sets the Bitwarden identity URL.
+ ///
+ public string IdentityUrl { get; set; } = "https://identity.bitwarden.com";
+
+ ///
+ /// Gets or sets the optional directory where the Bitwarden SDK stores its auth cache file to persist the session across restarts.
+ /// Set this to a persistent directory (e.g. /data/bitwarden) to avoid re-authenticating on every start, which would
+ /// otherwise hit Bitwarden's rate limits. The filename within the directory is derived from the access token UUID and is
+ /// managed automatically. Injected by the AppHost integration via the AuthCacheDirectory configuration key.
+ ///
+ public string? AuthCacheDirectory { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether health checks should be disabled.
+ ///
+ public bool DisableHealthChecks { get; set; }
+
+ ///
+ /// Gets or sets the optional health check timeout.
+ ///
+ public TimeSpan? HealthCheckTimeout { get; set; }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerHealthCheck.cs b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerHealthCheck.cs
new file mode 100644
index 000000000..68a6569ba
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/BitwardenSecretManagerHealthCheck.cs
@@ -0,0 +1,22 @@
+using Bitwarden.Sdk;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace CommunityToolkit.Aspire.Bitwarden.SecretManager;
+
+internal sealed class BitwardenSecretManagerHealthCheck(
+ BitwardenClient client,
+ BitwardenSecretManagerClientSettings settings) : IHealthCheck
+{
+ public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _ = client.Secrets.Sync(settings.OrganizationId, null);
+ return Task.FromResult(HealthCheckResult.Healthy());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy(exception: ex));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj
new file mode 100644
index 000000000..7e4c29e6b
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/CommunityToolkit.Aspire.Bitwarden.SecretManager.csproj
@@ -0,0 +1,18 @@
+
+
+
+ bitwarden secrets secret-manager client
+ An Aspire client integration for Bitwarden Secrets Manager.
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md
new file mode 100644
index 000000000..c0732d2cb
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Bitwarden.SecretManager/README.md
@@ -0,0 +1,46 @@
+# CommunityToolkit.Aspire.Bitwarden.SecretManager
+
+## Overview
+
+`CommunityToolkit.Aspire.Bitwarden.SecretManager` registers authenticated `BitwardenClient` instances using structured Aspire configuration.
+
+## Installation
+
+```bash
+dotnet add package CommunityToolkit.Aspire.Bitwarden.SecretManager
+```
+
+## Configuration
+
+The client integration expects configuration under `Aspire:Bitwarden:SecretManager:{connectionName}`.
+
+When used with the hosting integration, call `WithReference(bitwarden)` in the AppHost and then register the client in the consuming application:
+
+```csharp
+builder.AddBitwardenSecretManagerClient("bitwarden");
+```
+
+The configuration section includes:
+
+- `OrganizationId`
+- `ProjectId`
+- `AccessToken`
+- `ApiUrl`
+- `IdentityUrl`
+- `AuthCacheFile` _(optional)_ โ path to the Bitwarden SDK auth cache file inside the app. Set via `WithAuthCacheFile(...)` in the AppHost. Persist the auth session to a durable storage path to avoid re-authenticating on every app restart.
+
+## Usage
+
+```csharp
+builder.AddBitwardenSecretManagerClient("bitwarden");
+
+WebApplication app = builder.Build();
+
+app.MapGet("/sync", (Bitwarden.Sdk.BitwardenClient client, BitwardenSecretManagerClientSettings settings) =>
+{
+ var sync = client.Secrets.Sync(settings.OrganizationId, null);
+ return Results.Ok(sync.Data.Count);
+});
+```
+
+Use `AddKeyedBitwardenSecretManagerClient(...)` when you need multiple Bitwarden clients in the same application.
diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md
new file mode 100644
index 000000000..6a37dff35
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ARCHITECTURE.md
@@ -0,0 +1,245 @@
+# Architecture
+
+Bitwarden Secrets Manager is modeled as a declared AppHost resource graph.
+The graph is the primary contract. Deployment happens through explicit Aspire pipeline steps that materialize the declared graph in Bitwarden.
+
+- `BitwardenSecretManagerResource` declares a Bitwarden project and its configuration.
+- `BitwardenSecretResource` declares either a managed secret (created or updated on every run, `IsManaged = true`) or a reference-only secret (read from an existing Bitwarden secret, `IsManaged = false`). Both modes inherit `ParameterResource` and are returned by `AddSecret` and `GetSecret` respectively as `IResourceBuilder`. `AddSecret` has two overloads: a single-name form where the Aspire resource name and the Bitwarden secret name are the same, and a two-name form (`name`, `remoteName`) where they differ. `GetSecret` has three: those same two name-based forms, plus a GUID form (`name`, `secretId`) that locates a specific secret by its Bitwarden identifier. The GUID form is an escape hatch for when multiple secrets in the project share the same name โ Bitwarden does not enforce remote name uniqueness. To inject the resolved secret value into a dependent resource, pass the secret builder directly to `WithEnvironment`. To inject the secret ID instead, call `.AsSecretId()` on the builder and pass the result to `WithEnvironment`.
+- Dependent resources must call `.WaitForCompletion(bitwarden)` explicitly to block until provisioning completes.
+
+This design intentionally treats custom publish-manifest schema as legacy. The integration does not rely on a bespoke manifest payload as its architectural center.
+
+## Architectural Principles
+
+1. Declared graph first:
+ AppHost resources are the source of truth.
+
+2. Publish-time materialization:
+ Publishing registers six Bitwarden pipeline steps per declared Bitwarden resource.
+ The steps collectively deploy the graph by pre-syncing existing values, authenticating, provisioning the project, provisioning secrets, and patching environment files.
+
+3. Provisioner as implementation detail:
+ Provisioning logic is the internal mechanism used by the publishing steps (and local run path), not part of the public architecture contract.
+
+4. Consumer contract parity:
+ The user experience follows the Azure Key Vault style where declaration and references are first-class, and deployment materializes the declaration.
+
+## Publishing
+
+`aspire deploy` is the deployment moment for Bitwarden resources.
+Each declared Bitwarden resource contributes six pipeline steps via
+`WithPipelineStepFactory(...)`.
+
+The steps run in order and are scoped to the resource by name:
+
+| # | Step name | What it does |
+| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | `bitwarden-pre-sync-managed-{name}` | Prompts for any missing credentials, authenticates, and fetches existing managed secret values from Bitwarden; writes everything to the deployment state before `process-parameters` evaluates them |
+| 2 | `bitwarden-authenticate-{name}` | Resolves credentials, loads the AppHost cache, authenticates with Bitwarden |
+| 3 | `bitwarden-provision-project-{name}` | Creates or updates the remote Bitwarden project; binds the resolved project ID |
+| 4 | `bitwarden-sync-managed-secrets-{name}` | Binds upstream values for managed secrets whose local parameter values are missing |
+| 5 | `bitwarden-provision-secrets-{name}` | Creates or updates managed secrets, validates declared references, saves cache |
+| 6 | `bitwarden-patch-env-{name}` | Patches Bitwarden-resolved values into Docker Compose `.env.{env}` files |
+
+Step 1 must run before `process-parameters` because step 2 (`bitwarden-authenticate`) depends on `DeployPrereq`, which depends on `process-parameters` โ there is no way to place a step that formally depends on authentication before the parameter prompt. Step 1 therefore performs its own inline authentication, prompting for any missing credentials via `ParameterProcessor` and saving them to the deployment state so `process-parameters` does not re-prompt for them. `process-parameters` is made to depend on step 1 (via `WithPipelineConfiguration`) so all values are written to the deployment state and reflected in `IConfiguration` before `ParameterProcessor` evaluates them.
+
+Step 2 depends on `DeployPrereq`; steps 3โ5 form a chain where each step depends on the previous. Steps 3โ5 are tagged `ProvisionInfrastructure`. Step 5 is also required by `Deploy`. Because steps 2โ5 carry no dependency on `prepare-{env}`, they can run concurrently with the Docker image prepare phase.
+
+Step 6 is a Docker Compose workaround: `PrepareAsync` in `Aspire.Hosting.Docker` only resolves `ParameterResource` and `ContainerImageReference` sources, leaving Bitwarden-derived env vars blank. Step 6 patches those blanks after `prepare-{env}` runs and before `docker-compose-up-{env}` starts. It will be removed once the upstream issue is resolved.
+
+Happy path:
+
+1. Declare the Bitwarden project with `AddBitwardenSecretManager(...)`.
+2. Declare any managed secrets with `AddSecret(...)`.
+3. Reference the Bitwarden resource from dependent resources with `WithReference(...)` or `WithEnvironment(...)`.
+4. Run `aspire deploy`.
+5. During pipeline execution, the six Bitwarden steps materialize the declared graph in Bitwarden.
+6. The deployed graph is stable and available for consumers.
+
+## Run Mode
+
+For local run scenarios, the same declared graph is used. The implementation invokes reconciliation during resource initialization (`OnInitializeResource`) to keep local state aligned. This run-mode behavior is separate from deploy-time step execution and does not change the architecture: declaration and pipeline-step deployment remain the primary model.
+
+### State machine
+
+The resource reports the following states during a run:
+
+| State | Style | Meaning |
+| ---------------------- | ------- | ---------------------------------------------------------------- |
+| `NotStarted` | โ | Resource registered, initialization not yet started |
+| `Waiting` | โ | Waiting for one or more parameter values (see phases below) |
+| `Running` | โ | All values collected; actively provisioning project and secrets |
+| `Finished` | Success | Provisioning succeeded; dependent resources may start |
+| `Exited` (exit code 1) | Error | Authentication or provisioning failed; dependent resources error |
+
+Dependent resources must call `.WaitForCompletion(bitwarden)` explicitly in apphost code. `Exited` with a non-zero exit code propagates the failure to dependents; `Finished` unblocks them.
+
+### Four-phase parameter collection
+
+Run-mode initialization collects parameters in four phases, all expressed as `Waiting` state to the dashboard until all inputs are ready:
+
+**Phase 1 โ authentication inputs only.**
+The resource waits for the management access token (and the auth cache path, which is derived from it). It then authenticates with Bitwarden immediately. A bad or missing token fails fast here โ before the user is asked for project name or secret values โ and the resource transitions to `Exited` (exit code 1).
+
+**Phase 2 โ upstream managed secret sync.**
+After a successful authentication, the resource resolves the project, then checks Bitwarden for existing managed secrets whose local parameter values are missing. For each secret that has an upstream value, the provisioner:
+
+1. Binds the value into the resource's resolved-secret cache (`BindResolvedSecret`).
+2. Calls `WaitForValueTcs.TrySetResult(value)` on the `BitwardenSecretResource`'s underlying `ParameterResource` so any caller awaiting `GetValueAsync()` unblocks immediately.
+3. Removes the secret from `ParameterProcessor._unresolvedParameters` and cancels the prompt loop if the list becomes empty.
+4. Publishes a `ResourceNotificationService` snapshot update to show the parameter state as `Running` in the dashboard.
+
+The net effect: the "Parameters need values" banner disappears automatically after upstream sync for any secrets whose values already exist in Bitwarden.
+
+**Phase 2.5 โ upstream reference secret sync.**
+After managed secret sync, the resource fetches current values for all reference-only secrets (declared via `GetSecret`). For each one, the provisioner binds the value and calls `ResolveWaitForValue` so `ParameterProcessor` sees the value and does not prompt. If a referenced secret does not exist in Bitwarden, the provisioner throws here rather than letting the dashboard ask for a value that the user cannot meaningfully supply.
+
+**Phase 3 โ remaining parameters.**
+After upstream sync, the resource waits for any remaining project, organization, or managed secret parameter values. Managed secrets with no upstream value are still in `ParameterProcessor._unresolvedParameters` at this point, so the dashboard's interactive prompt remains active for those only. Once every value is available, the resource transitions to `Running` and provisioning continues.
+
+The resource never enters `Running` while parameters are still pending. `Running` strictly means "all inputs gathered, provisioning in progress."
+
+### Sync command
+
+The "Reprovision" command repeats the full initialization sequence on demand. It is available in any state except `NotStarted`. Because parameter values are typically already resolved from the initial run, the resource moves through `Waiting` quickly before re-entering `Running`.
+
+### BitwardenSecretResource as ParameterResource
+
+`BitwardenSecretResource` inherits `ParameterResource`. Both managed (`IsManaged = true`, from `AddSecret`) and reference-only (`IsManaged = false`, from `GetSecret`) instances use this same type. The `IsManaged` flag drives provisioner dispatch and value-resolution behavior.
+
+**`GetSecret` deduplication.** When `GetSecret` is called with a remote name that matches an existing secret (managed or previously registered reference-only), the same `BitwardenSecretResource` is returned โ no second resource is created. The match is on `RemoteName`, so `AddSecret("app-key", "shared-secret")` followed by `GetSecret("shared-secret")` yields the same resource. This allows different parts of the AppHost to declare the write path and the read path independently without registering duplicates. `GetSecret(name, secretId)` bypasses this deduplication: it matches by `ExistingSecretId` instead, so it always produces a distinct resource even when another secret with the same remote name is already registered. Use this form when Bitwarden contains multiple secrets with the same name in the same project โ Bitwarden does not enforce remote name uniqueness, so genuine duplicates can exist.
+
+**Dashboard visibility.** Both kinds use `ResourceType = "Parameter"` in their initial snapshot so they appear in the Aspire dashboard parameters tab. For managed secrets, the `Source` property shows the configuration key (`Parameters:{resourceName}`) where a value can be pre-supplied. For reference-only secrets, the `Source` shows `Bitwarden: {remoteName}` (the Bitwarden secret name) to signal that the value comes exclusively from Bitwarden.
+
+**`ParameterProcessor` integration.** Aspire's built-in `ParameterProcessor` processes every `ParameterResource` on startup. For **managed secrets**: the value getter throws `MissingParameterValueException` when no config key is set, so the secret is added to `_unresolvedParameters`; Phase 2 sync removes resolved secrets from that list. For **reference-only secrets**: the value getter returns `string.Empty` (never throws), so `ParameterProcessor` resolves the TCS immediately with an empty string and never adds the secret to `_unresolvedParameters`. The real value flows through `IValueProvider.GetValueAsync`, which reads from the Bitwarden resolved-secret cache populated by Phase 2.5.
+
+**Value resolution order.** Both `IValueProvider.GetValueAsync(CancellationToken)` and `IValueProvider.GetValueAsync(ValueProviderContext, CancellationToken)` are explicitly overridden on `BitwardenSecretResource`. The context overload is necessary because `ParameterResource` implements it to call the public (non-interface) `GetValueAsync(CancellationToken)`, which goes directly to `WaitForValueTcs` and bypasses the Bitwarden cache. The override redirects the framework dispatch path through the interface implementation, which applies the priority below:
+
+1. Bitwarden resolved-secret cache (populated by `BindResolvedSecret` after Phase 2/2.5 sync or Phase 4 provisioning).
+2. **Managed only** โ `ParameterResource.GetValueAsync` โ waits on `WaitForValueTcs`, which is set by either `ParameterProcessor` (from config or user input) or by the provisioner (from upstream sync).
+3. **Reference-only** โ returns `null` if the Bitwarden cache is empty (pre-Phase 2.5); Phase 2.5 always populates the cache before the resource enters `Running`.
+
+The Bitwarden cache always takes precedence because it represents the authoritative remote state. The `ParameterResource` fallback for managed secrets serves as the write path: the value the user or config supplies is what the provisioner pushes to Bitwarden when the secret does not yet exist.
+
+## Deploy-mode parameter suppression
+
+`process-parameters` (the Aspire built-in step that prompts for unresolved parameters) runs before `DeployPrereq` and therefore before all Bitwarden steps that depend on it. `bitwarden-authenticate` depends on `DeployPrereq`, so there is no way to formally place authentication before the parameter prompt. This creates a timing problem: managed secrets that exist in Bitwarden cannot be resolved through the normal pipeline ordering before `process-parameters` evaluates them, causing `aspire deploy` to prompt for values it could have fetched automatically.
+
+The `bitwarden-pre-sync-managed-{name}` step addresses this by running before `process-parameters` (wired via `WithPipelineConfiguration`). It has no formal pipeline dependencies and performs its own inline authentication. It:
+
+1. Reads any missing credentials (access token, organization ID) from `IConfiguration`. If a credential is absent and `IInteractionService` is available, prompts via `IInteractionService.PromptInputAsync` directly โ **not** via `ParameterProcessor.SetParameterAsync` / `PromptAsync`, which calls `ValueInternal` and would permanently cache `MissingParameterValueException` in `_lazyValue` (see `_lazyValue` hazard below). After collecting, saves to the deployment state and sets `WaitForValueTcs` via `InitializeWaitForValue` + `ResolveWaitForValue` so in-process callers (`GetResolvedManagementAccessTokenAsync`, `GetResolvedOrganizationIdAsync`) see the value before `IConfiguration` is reloaded.
+2. Authenticates with Bitwarden using the resolved credentials; looks up each managed secret's current value โ by project ID from the AppHost cache when available, or by the `projectNameOrId` parameter if its current config value is a GUID โ and writes each found value to the deployment state via `IDeploymentStateManager`. When neither a cached ID nor a GUID config value is available (project is identified by name only, first deploy), the step skips managed secret pre-sync; values are fetched after `bitwarden-provision-project` creates the project. When a project ID is known and the `projectNameOrId` parameter config value is absent, the step also fetches the project name from Bitwarden and pre-populates the parameter so `process-parameters` does not prompt for it. If the cached project ID no longer exists in Bitwarden (stale cache), a warning is logged and the secrets loop finds nothing; `bitwarden-provision-project` detects the missing project and creates a replacement.
+3. Calls `IConfigurationRoot.Reload()` in a `finally` block to force the JSON configuration provider (which loaded the deployment state file at startup with `reloadOnChange: false`) to re-read the updated file. The `finally` ensures the reload happens even when the step exits early (e.g. interaction unavailable, cancelled prompt, Bitwarden auth failure after credentials were saved).
+
+When `process-parameters` then calls `ParameterProcessor.InitializeParametersAsync`, each managed secret's `_valueGetter` reads `IConfiguration[key]` and finds the Bitwarden value โ no prompt.
+
+**`_lazyValue` hazard.** `ParameterResource._lazyValue` is a `Lazy` with `LazyThreadSafetyMode.ExecutionAndPublication` (the default). This mode permanently caches exceptions: if the factory throws `MissingParameterValueException` on the first call, all subsequent calls re-throw the same cached exception, even after `IConfiguration` is reloaded. Therefore the pre-sync step must never call `HasValue()`, `ValueInternal`, or any path that evaluates `_lazyValue` on the parameters it is pre-resolving. The step reads `IConfiguration[key]` directly to check whether a local value is already present.
+
+The first `aspire deploy` is prompt-minimizing: credentials are asked for once in step 1, and `process-parameters` finds them in `IConfiguration` and does not ask again. Managed secrets that exist in Bitwarden (even when the project ID is not yet cached) are pre-populated so `process-parameters` does not prompt for them either. Secrets that do not yet exist in Bitwarden still require a value โ those are prompted by `process-parameters` as usual.
+
+## Access Tokens
+
+The integration uses two distinct access tokens with different scopes:
+
+- **Management token** โ supplied to `AddBitwardenSecretManager(...)`. Used exclusively by the AppHost provisioner to create and update the Bitwarden project and its secrets. It must have write permissions to the project.
+- **Client token** โ optionally supplied via `WithBitwardenAccessToken(bitwarden, token)` (chained after `WithReference`). Injected into the dependent resource as `AccessToken` under `Aspire:Bitwarden:SecretManager:{connectionName}`. Defaults to the management token when omitted.
+
+The client token only needs read permissions to the project. Because Bitwarden does not expose an API for granting project access to a service account, this grant must be performed manually in the Bitwarden web vault. For a newly created project the grant must be done after the first AppHost run that creates the project.
+
+The AppHost provisioner never reads the client token. The deployed app never reads the management token.
+
+## URL Configuration
+
+The API and identity URLs are stored on `BitwardenSecretManagerResource` as `ReferenceExpression` properties, initialized to the public Bitwarden cloud defaults:
+
+```
+ApiUrl = https://api.bitwarden.com
+IdentityUrl = https://identity.bitwarden.com
+```
+
+`WithApiUrl` and `WithIdentityUrl` each accept four forms:
+
+- **String literal** โ a fixed URL known at build time.
+- **`ParameterResource`** โ defers resolution until the parameter value is available. Intended for self-hosted instances whose URL varies by environment.
+- **`ExternalServiceResource`** โ extracts the URL (static or parameter-backed) from an `AddExternalService` resource and calls `WaitFor` automatically. Preferred for self-hosted instances because the external service also provides dashboard visibility and health checks.
+- **`EndpointReference`** โ points at an endpoint exposed by another resource in the AppHost. The Bitwarden resource calls `WaitFor` on that resource so authentication cannot start until it is running.
+
+`ReferenceExpression` is used as the unified backing type because all four inputs are compatible with it and because it is the type Aspire expects when injecting values into dependent resources via `IValueProvider`. The provisioner, TLS validator, and `ApplyReferenceConfiguration` all resolve the URL through a single `GetValueAsync()` call regardless of which form was used.
+
+The resolved URLs are published as `UrlSnapshot` entries in every `CustomResourceSnapshot` state update, so they appear as clickable links in the Aspire dashboard.
+
+## Cache Files
+
+The integration maintains two cache files on the AppHost, and one optional cache file in the deployed app.
+
+### AppHost cache
+
+The AppHost cache (`{resourceName}.{environment}.json` in `.bitwarden/`) is the integration's own bookkeeping file. It persists the Bitwarden project ID and secret ID mappings between runs so the AppHost can locate existing resources without querying Bitwarden on every start. Format: JSON, written and read by this integration.
+
+Located in `.bitwarden/` relative to the AppHost directory by default, so it is naturally tracked in version control alongside the AppHost. Override with `WithCacheFile(...)`; relative paths resolve from the AppHost directory. Use the override to share the cache across multiple AppHost projects or to store it in a CI cache directory.
+
+### AppHost auth cache
+
+The AppHost auth cache persists the Bitwarden SDK authentication session between AppHost runs. Its primary purpose is to circumvent Bitwarden's rate limits on frequent logins: without the cache the AppHost would call the identity server on every run, which triggers rate limiting under rapid iteration or CI pipelines. Format: opaque encrypted text (Bitwarden `EncString`), written and read by the Bitwarden SDK โ not plain JSON.
+
+Located in `.bitwarden/` under the Aspire store by default, keyed by the UUID component embedded in the access token (the second `.`-delimited segment of `0..:`). The per-token filename means rotating the access token automatically produces a fresh cache file; the old one is silently abandoned.
+
+Override with `WithAuthCacheDirectory(...)`; relative paths resolve from the Aspire store. Use the override to reuse a cached session across CI runs.
+
+### Auth cache file internals
+
+Both auth cache files (AppHost and app-side) are written and read exclusively by the Bitwarden SDK. The file on disk is a Bitwarden `EncString` โ an AES-256-CBC-HMAC encrypted blob encoded as a single-line text string:
+
+```
+2.||
+```
+
+The decrypted payload is JSON with three fields:
+
+| Field | Contents |
+| ---------------- | ------------------------------------------------------------------- |
+| `version` | Format version integer (currently `1`) |
+| `token` | The JWT bearer token returned by the Bitwarden identity server |
+| `encryption_key` | The organization's symmetric encryption key used to decrypt secrets |
+
+Source: [`crates/bitwarden-core/src/secrets_manager/state.rs`](https://github.com/bitwarden/sdk-internal/blob/aa8316827d1ea17f258b342ad2933286bcf2be1f/crates/bitwarden-core/src/secrets_manager/state.rs) in `bitwarden/sdk-internal`.
+
+**Per-token isolation.** The file is encrypted using a key derived from the access token string itself. An access token has the structure `0..:`, and the embedded `` is HKDF-expanded into the AES-256-CBC-HMAC key that protects the cache file. Every distinct token string therefore produces a distinct encryption key. If a process tries to read a cache file written by a different token, AES-CBC-HMAC decryption fails and the SDK silently falls back to a fresh network authentication, overwriting the file โ no crash, no error surfaced to the caller, just one extra round-trip.
+
+The `bws` CLI uses this same convention: it names each state file after the token's UUID component (`~/.config/bws/state/`). This integration follows suit, using the UUID from the second `.`-delimited segment of the access token string as the cache filename.
+
+**Token rotation.** Rotating the access token changes the UUID, so the new token automatically gets a fresh cache file and the old one is never read again. However, the cached JWT inside the old file remains valid until its natural expiry (typically around one hour for Bitwarden). There is no server-side revocation check during cache file load โ the SDK performs only a local expiry check against the stored JWT. This is Bitwarden SDK behaviour, not a gap in this integration.
+
+### App auth cache
+
+The app auth cache persists the Bitwarden SDK authentication session inside the deployed app. Its primary purpose is the same as the AppHost auth cache: circumventing Bitwarden's rate limits on frequent logins. Without it the app calls the identity server on every start. Format: opaque encrypted text (Bitwarden `EncString`), written and read by the Bitwarden SDK โ not plain JSON. This is independent of the AppHost auth cache; the two run in different processes and on different machines.
+
+The path is injected into the app via the `AuthCacheDirectory` key under `Aspire:Bitwarden:SecretManager:{connectionName}`. Three configuration paths exist:
+
+All three configuration paths accept a **directory**; the filename within that directory is always `auth-cache`, managed by the integration.
+
+**`WithBitwardenAuthCacheVolume(bitwarden)`** mounts a named Docker volume at `/var/lib/bitwarden` and sets the auth cache path to `/var/lib/bitwarden/auth-cache`. The volume name defaults to `{resourceName}-{connectionName}-bitwarden-auth` and can be overridden. Requires the destination to be a container resource. Preferred for container resources because no host-specific path is involved.
+
+**`WithBitwardenAuthCacheDirectory(bitwarden, parameter)`** injects a parameter-backed directory path. The parameter resolves from user secrets or configuration in run mode, and the deploy tooling resolves it per environment. Use when the directory must differ between developer machines or deployment targets.
+
+**`WithBitwardenAuthCacheDirectory(bitwarden, string)`** injects a fixed directory path. Safe only when the app always runs as a container and the directory is the same everywhere. Does not warn if a host-specific path is passed โ that is a silent misconfiguration; use the parameter overload instead.
+
+The AppHost reconciler never reads the app auth cache path. The deployed app never reads the AppHost cache files.
+
+## Audit Trail
+
+Every secret creation and update writes a timestamped audit entry to the Bitwarden secret's note field via `SecretUpdateAudit`. The record type owns three responsibilities:
+
+- **Comparison** (`SecretUpdateAudit.Compare`) โ derives which of value, key, and project changed by comparing the current remote state against the desired state. Captures the previous value for all three fields.
+- **Guard** (`RequiresUpdate`) โ short-circuits the update path when nothing changed, so no write is issued and the note is not mutated.
+- **Note construction** (`PrependTo`, `CreationNote`) โ `CreationNote` produces the initial `Created` entry. `PrependTo` builds the update entry from the set of detected changes, recording the previous value for each (`key renamed (previous: โฆ)`, `project changed (previous: โฆ)`, `value changed (previous: โฆ)`), then prepends it to the existing note with a newline separator.
+
+The note field is the only persistent record of what changed and when. It is stored in Bitwarden alongside the current value and is visible in the Bitwarden web vault and CLI.
+
+## Non-Goals
+
+- Defining a new custom manifest schema as the primary deployment contract.
+- Using eventing subscribers as the deployment integration point for publishing.
+- Making runtime reconciliation the primary architectural concept.
+
+The intended design is pipeline-step-first, declared-resource-first.
diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md
new file mode 100644
index 000000000..b1f3f22c7
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/ASPIRE-INTERNALS.md
@@ -0,0 +1,124 @@
+# Aspire Internal API Usage
+
+This integration reaches into several experimental and private Aspire APIs to deliver its UX guarantees. This document explains each one: the problem it solves, why no public API covers it, what breaks if Aspire changes it, and how to detect breakage.
+
+---
+
+## Experimental APIs (diagnostic suppressions)
+
+These are public APIs guarded by `[Experimental]` attributes. They are stable enough to ship on but carry an explicit "may change" signal.
+
+### `ASPIREATS001` โ `[AspireExport]`
+
+**Files:** `BitwardenSecretManagerResource.cs`, `BitwardenSecretResource.cs`, `BitwardenSecretManagerExtensions.cs`
+
+**What it does:** Registers types and methods with the Aspire Type System (ATS) so they are callable from polyglot apphosts (TypeScript, Python, etc.) via the JSON-RPC remote host. Both resource types carry `[AspireExport]` to register their type IDs. The exported extension methods (`AddBitwardenSecretManager`, `GetSecret`, `AddSecret`, `WithReference`, etc.) carry `[AspireExport]` or `[AspireExportIgnore]` to define the polyglot API surface. C# ergonomics overloads and methods with ATS-incompatible parameters (`EndpointReference`, generic callback types) are marked `[AspireExportIgnore]` with an explicit reason.
+
+**Why needed:** Without `[AspireExport]` on extension methods, the integration has no ATS-callable surface and is inaccessible from non-C# apphosts. The ATS analyzer (ASPIREEXPORT008) also warns on extension methods that extend builder/resource types but lack either attribute โ `[AspireExportIgnore]` documents a deliberate decision not to export, distinguishing it from an oversight.
+
+**Breakage signal:** `ASPIREATS001` diagnostic stops compiling.
+
+---
+
+### `ASPIREPIPELINES001`, `ASPIREPIPELINES004` โ Pipeline step registration
+
+**Files:** `BitwardenSecretManagerExtensions.cs`, `BitwardenSecretManagerDeploymentStep.cs`
+
+**What it does:** `ASPIREPIPELINES001` covers `WithPipelineStepFactory`, `WithPipelineConfiguration`, and the `PipelineStep` type, which are used to register the six Bitwarden pipeline steps per resource. `ASPIREPIPELINES004` covers `IPipelineOutputService` and `IComputeEnvironmentResource`, used by the env-file patch step to locate Docker Compose output directories and patch `.env.{env}` files after `prepare-{env}` writes them.
+
+**Why needed:** The patch step is a workaround for Aspire's `PrepareAsync` (in `Aspire.Hosting.Docker`) only resolving `ParameterResource` and `ContainerImageReference` sources. Custom `IValueProvider` implementations like `BitwardenSecretResource` are skipped, leaving Bitwarden-derived env vars blank. The patch step fills those blanks after prepare runs. Remove it once Aspire handles all `IValueProvider` sources generically in `PrepareAsync`.
+
+**Breakage signal:** Pipeline step registration diagnostics. More subtly: `IPipelineOutputService.GetOutputDirectory()` may change signature, or compute environment types may be renamed.
+
+---
+
+### `ASPIREPIPELINES002` โ `IDeploymentStateManager`
+
+**Files:** `BitwardenSecretManagerProvisioner.cs`
+
+**What it does:** Reads and writes the per-environment deployment state file (`~/.aspire/deployments/{sha}/{env}.json`) that Aspire uses to persist parameter values across `aspire deploy` runs.
+
+**Why needed:** The pre-sync step (`bitwarden-pre-sync-managed-{name}`) must run before `process-parameters` because the formal authentication step depends on `DeployPrereq`, which depends on `process-parameters` โ there is no public way to insert a step after authentication but before the parameter prompt. The pre-sync step therefore performs inline authentication and writes resolved Bitwarden values (and prompted credential values) to the deployment state so that `process-parameters` finds them via `IConfiguration` and does not prompt the user again.
+
+The deployment state file is loaded as a JSON configuration source at AppHost startup (`AddJsonFile(..., reloadOnChange: false)`). After writing values to the state and calling `IConfigurationRoot.Reload()`, the updated values are visible to `ParameterResource._lazyValue` when it is first evaluated by `ParameterProcessor.InitializeParametersAsync` in the `process-parameters` step.
+
+**Breakage signal:** `ASPIREPIPELINES002` diagnostic stops compiling. State-file path or JSON structure changes would silently break the round-trip: written values might not be picked up by `IConfiguration` on reload.
+
+---
+
+### `ASPIREINTERACTION001` โ `ParameterProcessor`
+
+**Files:** `BitwardenSecretManagerProvisioner.cs`, `ParameterResourceExtensions.cs`
+
+**What it does:** Drives the dashboard's parameter-resolution UI. Used for banner dismissal: after the provisioner resolves a secret value from Bitwarden in run mode, it calls `MarkParameterResolved` (which removes the parameter from `ParameterProcessor._unresolvedParameters` and cancels `_allParametersResolvedCts`). Without this, the "Parameters need values" banner stays open even though all values are now available.
+
+**Why `SetParameterAsync` is not used for credential prompting:** `ParameterProcessor.SetParameterAsync` (available as `parameter.PromptAsync(...)`) calls `ValueInternal` internally to pre-fill the input form. `ValueInternal` evaluates `_lazyValue`, permanently caching `MissingParameterValueException` if the value is absent. After that call, no amount of saving to the deployment state and calling `IConfigurationRoot.Reload()` prevents `process-parameters` from re-prompting โ the cached exception always re-throws. The pre-sync step uses `IInteractionService.PromptInputAsync` directly instead, which never touches `_lazyValue`.
+
+**Breakage signal:** `ASPIREINTERACTION001` diagnostic stops compiling. The `ParameterProcessor` constructor signature or internal fields accessed via `MarkParameterResolved` may change.
+
+---
+
+## `UnsafeAccessor` workarounds
+
+These access private members of `ParameterResource` and `ParameterProcessor` that have no public equivalent. If Aspire renames or removes any of these members, the AppHost will throw `MissingMethodException` or `MissingFieldException` at runtime. Run the AppHost against a new Aspire version before shipping a NuGet update.
+
+---
+
+### `get_WaitForValueTcs` โ read `ParameterResource.WaitForValueTcs`
+
+**File:** `ParameterResourceExtensions.cs`
+
+**Why needed:** Three uses:
+
+1. **`HasValue()`** โ synchronously checks whether a parameter has already been resolved. `WaitForValueTcs` is set by `ParameterProcessor.InitializeParametersAsync`; if it is completed and non-empty the parameter has a value. If it is null, the lazy value-getter is invoked synchronously instead (with `MissingParameterValueException` caught and mapped to `false`).
+
+2. **`ResolveWaitForValue(value)`** โ called by the provisioner after resolving a secret from Bitwarden in run mode, to unblock any code awaiting `GetValueAsync()` without waiting for the dashboard prompt. `TrySetResult` is called only if the TCS is pending, so it never overwrites a value the user has already supplied interactively.
+
+3. **`GetResolvedWaitForValue()`** โ reads back the value stored by `PromptAsync` in the pre-sync step, after `InitializeWaitForValue()` pre-created the TCS (see `set_WaitForValueTcs` below).
+
+**Breakage:** `MissingMethodException` at runtime.
+
+---
+
+### `set_WaitForValueTcs` โ write `ParameterResource.WaitForValueTcs`
+
+**File:** `ParameterResourceExtensions.cs`
+
+**Why needed:** After the pre-sync step collects a credential via `IInteractionService.PromptInputAsync` and saves it to the deployment state, in-process callers within the same pre-sync execution (e.g. `ResolveAuthCachePathAsync` โ `GetResolvedManagementAccessTokenAsync`) need the value before `IConfigurationRoot.Reload()` has been called. The step calls `InitializeWaitForValue()` to create the TCS, then immediately `ResolveWaitForValue(value)` to complete it with the collected value. Callers that read credentials via `GetValueAsync` then return the TCS result rather than attempting to re-prompt.
+
+`GetResolvedWaitForValue()` is no longer used in this flow: the value is captured directly in the prompting code and passed to `ResolveWaitForValue`. The `GetResolvedWaitForValue()` helper is still present for symmetry but is currently unused.
+
+**Why `_lazyValue` cannot be used instead:** `ParameterResource._lazyValue` is a `Lazy` with `LazyThreadSafetyMode.ExecutionAndPublication` (the default), which permanently caches exceptions. If the lazy factory is evaluated before `process-parameters` creates the TCS and the config key is absent, it throws and caches `MissingParameterValueException`. All subsequent calls โ including `ParameterProcessor.ProcessParameterAsync` after `Reload()` โ re-throw the cached exception and never see the updated `IConfiguration` value. The pre-sync step therefore reads `IConfiguration` directly and never calls `HasValue()`, `ValueInternal`, or any path that evaluates `_lazyValue` on the parameters it is pre-resolving.
+
+**Breakage:** `MissingMethodException` at runtime.
+
+---
+
+### `_unresolvedParameters` โ `ParameterProcessor` private field
+
+**File:** `ParameterResourceExtensions.cs`
+
+**Why needed:** `ParameterProcessor.InitializeParametersAsync` adds parameters that throw `MissingParameterValueException` to `_unresolvedParameters`, then calls `HandleUnresolvedParametersAsync` which shows the "Parameters need values" banner and prompt loop. After the provisioner resolves a secret value from Bitwarden in run mode, the secret must be removed from this list. Without removal, the banner stays open and the prompt loop continues even though the value is now available. There is no public method to remove a specific parameter from the unresolved list.
+
+**Breakage:** `MissingFieldException` at runtime. Banner stays open permanently for parameters resolved by Bitwarden.
+
+---
+
+### `_allParametersResolvedCts` โ `ParameterProcessor` private field
+
+**File:** `ParameterResourceExtensions.cs`
+
+**Why needed:** `HandleUnresolvedParametersAsync` waits on a `CancellationToken` derived from `_allParametersResolvedCts`. When the last unresolved parameter is removed from `_unresolvedParameters`, cancelling this token causes the banner/prompt loop to exit immediately. Without cancellation, the loop continues until the user manually dismisses the banner, even if all parameters are now resolved.
+
+**Breakage:** `MissingFieldException` at runtime. Banner dismissal is delayed; users must dismiss it manually.
+
+---
+
+## Upgrade checklist
+
+When upgrading Aspire:
+
+1. Check whether `ASPIREATS001`, `ASPIREPIPELINES001`, `ASPIREPIPELINES002`, `ASPIREPIPELINES004`, `ASPIREINTERACTION001` have moved from `[Experimental]` to stable โ if so, remove the corresponding `#pragma` suppressions.
+2. Run the AppHost and check for `MissingMethodException` / `MissingFieldException` from the `UnsafeAccessor` targets above.
+3. Run `aspire deploy` end-to-end and verify that (a) managed secrets are not prompted when they exist in Bitwarden, (b) reference secrets are not prompted, and (c) the "Parameters need values" banner disappears automatically in run mode.
+4. Check whether `DistributedApplicationBuilder.cs` still adds the deployment state file as a JSON configuration source (`AddJsonFile`), and whether the state file format produced by `FileDeploymentStateManager` is still compatible with the JSON configuration provider key format.
diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/AssemblyInfo.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/AssemblyInfo.cs
new file mode 100644
index 000000000..893cf8400
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager.Tests")]
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs
new file mode 100644
index 000000000..2a449121c
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Bitwarden.SecretManager/BitwardenConfiguredValues.cs
@@ -0,0 +1,13 @@
+namespace Aspire.Hosting.ApplicationModel;
+
+internal sealed class BitwardenProjectIdReference(BitwardenSecretManagerResource resource) : IManifestExpressionProvider, IValueProvider, IValueWithReferences
+{
+ public string ValueExpression => $"{{{resource.Name}.projectId}}";
+
+ IEnumerable