Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageVersion Include="Aspire.Hosting.NodeJs" Version="9.5.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.12" />
<PackageVersion Include="Microsoft.Identity.Web.Certificate" Version="4.15.0" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="10.0.12" />
<!-- Azure -->
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.5.4" />
Expand Down
109 changes: 108 additions & 1 deletion Documentation/configuration/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ This allows a common callback endpoint while still restoring tenant-specific beh
| `Type` | `string` | Provider type hint (`Microsoft`, `Google`, or `Custom`). |
| `Authority` | `string` | OIDC authority URL. |
| `ClientId` | `string` | OAuth 2.0 client ID. |
| `ClientSecret` | `string` | OAuth 2.0 client secret. |
| `ClientSecret` | `string` | OAuth 2.0 client secret. Leave empty when `ClientCredential` selects a certificate or federated credential. |
| `ClientCredential` | `object` | Optional certificate or federated credential used instead of `ClientSecret`. See [Client credentials](#client-credentials-certificates-and-federated-credentials). |
| `Scopes` | `string[]` | Additional scopes to request (beyond `openid`, `profile`, `email`). |
| `ResponseMode` | `string` | How the provider returns the authorization code: `Query` (default) or `FormPost`. See below. |

Expand All @@ -120,6 +121,112 @@ correlation and nonce cookies to `SameSite=None; Secure` — a cross-site POST o
cookies, and `None` requires HTTPS. Do not choose `FormPost` for providers that support `Query`; it trades
away the `Lax` hardening for nothing.

#### Client credentials: certificates and federated credentials

By default AuthProxy authenticates to a provider's token endpoint with `ClientSecret`. Many organizations
disallow long-lived client secrets, and Microsoft recommends certificates or workload identity federation for
Microsoft Entra ID confidential clients. Set `ClientCredential` on the provider to authenticate with a
`client_assertion` ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)) instead. Leave `ClientSecret`
empty: AuthProxy refuses to start when both are configured.

AuthProxy presents the credential during authorization-code redemption at the provider's token endpoint and
in pushed authorization requests at the provider's PAR endpoint when the provider supports them. The credential loaders come from
[Microsoft.Identity.Web](https://github.com/AzureAD/microsoft-identity-web/wiki/Certificates),
so certificate stores, Key Vault, workload identity and managed identity behave as they do in any other
Microsoft.Identity.Web application.

| `Source` | Credential | Required properties |
| -------- | ---------- | ------------------- |
| `ClientSecret` | `ClientSecret` sent as `client_secret` (the default). | — |
| `CertificateFile` | A PKCS#12 (`.pfx`) file with the private key signs the assertion. | `CertificatePath`; `CertificatePassword` when the file has one. |
| `CertificateStore` | A certificate found by thumbprint in a certificate store signs the assertion. | `CertificateThumbprint`; `CertificateStorePath` defaults to `CurrentUser/My`. |
| `KeyVaultCertificate` | A certificate downloaded from Azure Key Vault signs the assertion. | `KeyVaultUrl` (https), `KeyVaultCertificateName`. |
| `FederatedTokenFile` | A platform-issued federated token read from a file is the assertion (Kubernetes workload identity). | `TokenFilePath`, or the `AZURE_FEDERATED_TOKEN_FILE` environment variable. |
| `ManagedIdentity` | An Azure managed identity token for the token-exchange audience is the assertion. | None. `ManagedIdentityClientId` selects a user-assigned identity. |

A certificate assertion is a short-lived JWT signed with `RS256` (RSA keys), or `ES256`, `ES384` or `ES512`
(ECDSA P-256, P-384 or P-521 keys respectively). Its issuer and subject are `ClientId`, its audience is the
provider's token endpoint, and its header carries the certificate thumbprint (`x5t`). Upload the
certificate's public part to the app registration. AuthProxy loads a certificate once and loads it again
after it expires. For `CertificateFile` or `KeyVaultCertificate`, put the renewed certificate in the same
file or vault entry before the old one expires, or restart AuthProxy to pick it up straight away. If the
replacement is still expired, sign-in fails and AuthProxy retries loading at most once per minute.
For `CertificateStore`, renewal changes the thumbprint: update `CertificateThumbprint` to the new
certificate's thumbprint and restart AuthProxy.

`KeyVaultCertificate` authenticates to Key Vault with the default Azure credential chain. Set
`ManagedIdentityClientId` (or `AZURE_CLIENT_ID`) to use a user-assigned managed identity. The identity needs
both certificate-get and secret-get permissions (for example, the Key Vault Certificate User and Key Vault
Secrets User roles), because the loader reads the certificate and the secret containing its private key.
The certificate must have an exportable private key; a non-exportable Key Vault certificate cannot sign
client assertions in AuthProxy.

`FederatedTokenFile` and `ManagedIdentity` need a federated identity credential on the app registration
that trusts the platform issuer: the cluster's OIDC issuer and service account for workload identity, or the
managed identity. `ManagedIdentity` requests its token for `api://AzureADTokenExchange` (or the national-cloud
equivalent resolved from `Authority`). Set `TokenExchangeAudience` to override it.

**Certificate from Key Vault:**

```json
{
"Cratis": {
"AuthProxy": {
"Authentication": {
"OidcProviders": [
{
"Name": "Microsoft",
"Type": "Microsoft",
"Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"ClientId": "<client-id>",
"ClientCredential": {
"Source": "KeyVaultCertificate",
"KeyVaultUrl": "https://<vault-name>.vault.azure.net",
"KeyVaultCertificateName": "authproxy-client"
}
}
]
}
}
}
}
```

**Managed identity on Azure Container Apps or App Service:**

```json
{
"Cratis": {
"AuthProxy": {
"Authentication": {
"OidcProviders": [
{
"Name": "Microsoft",
"Type": "Microsoft",
"Authority": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"ClientId": "<client-id>",
"ClientCredential": {
"Source": "ManagedIdentity",
"ManagedIdentityClientId": "<user-assigned-identity-client-id>"
}
}
]
}
}
}
}
```

With environment variables, the same settings are
`Cratis__AuthProxy__Authentication__OidcProviders__0__ClientCredential__Source=ManagedIdentity` and so on.

If the credential cannot be loaded or produces no assertion during authorization-code redemption, the
sign-in is handled as a [failed sign-in](failed-sign-ins.md). AuthProxy logs credential-loading and
assertion-provider errors with the provider and credential source. A credential failure during a pushed
authorization request happens while starting the sign-in challenge, outside the callback's failed-sign-in
handling, and returns an HTTP 500 response instead. OAuth 2.0 providers (below) still authenticate with
`ClientSecret` only.

### Canonical federated identity

Provider registrations can opt into a stable, provider-aware account tuple. Without this section,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Cratis.AuthProxy.Authentication.for_AuthenticationServiceCollectionExtensions;

public class when_an_oidc_provider_uses_a_certificate_credential : Specification
{
OpenIdConnectOptions _options;
IServiceProvider _services;

void Establish()
{
var builder = WebApplication.CreateBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Workforce",
[$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.microsoftonline.com/tenant/v2.0",
[$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-id",
[$"{C.Authentication.SectionKey}:OidcProviders:0:ClientCredential:Source"] = "KeyVaultCertificate",
[$"{C.Authentication.SectionKey}:OidcProviders:0:ClientCredential:KeyVaultUrl"] = "https://contoso.vault.azure.net",
[$"{C.Authentication.SectionKey}:OidcProviders:0:ClientCredential:KeyVaultCertificateName"] = "authproxy"
});

builder.AddIngressAuthentication();
_services = builder.Services.BuildServiceProvider();
}

void Because() => _options = _services.GetRequiredService<IOptionsMonitor<OpenIdConnectOptions>>().Get("workforce");

[Fact] void should_configure_no_client_secret() => _options.ClientSecret.ShouldBeNull();
[Fact] void should_authenticate_the_code_redemption() => _options.Events.OnAuthorizationCodeReceived.ShouldNotBeNull();
[Fact] void should_authenticate_pushed_authorization_requests() => _options.Events.OnPushAuthorization.ShouldNotBeNull();
[Fact] void should_provide_client_assertions() => _services.GetRequiredService<IOidcClientAssertions>().ShouldBeOfExactType<OidcClientAssertions>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace Cratis.AuthProxy.Authentication.for_CertificateClientAssertion;

public class when_signing_with_an_ecdsa_certificate : Specification
{
readonly List<TokenValidationResult> _validations = [];
readonly List<string> _algorithms = [];
readonly List<string> _thumbprints = [];
readonly List<string> _expectedThumbprints = [];

async Task Because()
{
foreach (var curve in new[] { ECCurve.NamedCurves.nistP256, ECCurve.NamedCurves.nistP384, ECCurve.NamedCurves.nistP521 })
{
using var key = ECDsa.Create(curve);
var request = new CertificateRequest("CN=authproxy-client", key, HashAlgorithmName.SHA256);
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
using var publicKey = certificate.GetECDsaPublicKey();

// Repeated signing also verifies that disposing one signing-key handle does not poison a cached provider.
for (var index = 0; index < 2; index++)
{
var serialized = CertificateClientAssertion.Create(certificate, "client-id", "https://login.example.com/token", DateTimeOffset.UtcNow);
var assertion = new JsonWebToken(serialized);
_algorithms.Add(assertion.Alg);
_thumbprints.Add(assertion.X5t);
_expectedThumbprints.Add(Base64UrlEncoder.Encode(certificate.GetCertHash()));
_validations.Add(await new JsonWebTokenHandler().ValidateTokenAsync(serialized, new TokenValidationParameters
{
ValidIssuer = "client-id",
ValidAudience = "https://login.example.com/token",
IssuerSigningKey = new ECDsaSecurityKey(publicKey)
{
CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false }
}
}));
}
}
}

[Fact] void should_carry_signatures_the_certificates_verify() => _validations.TrueForAll(_ => _.IsValid).ShouldBeTrue();
[Fact] void should_select_the_algorithm_for_each_curve() => _algorithms.ShouldEqual(new[] { "ES256", "ES256", "ES384", "ES384", "ES512", "ES512" });
[Fact] void should_preserve_the_certificate_thumbprints() => _thumbprints.ShouldEqual(_expectedThumbprints);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Security.Cryptography.X509Certificates;
using Cratis.AuthProxy.Authentication.given;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace Cratis.AuthProxy.Authentication.for_CertificateClientAssertion;

public class when_signing_with_an_rsa_certificate : Specification
{
const string ClientId = "client-id";
const string TokenEndpoint = "https://login.example.com/tenant/oauth2/v2.0/token";

X509Certificate2 _certificate;
DateTimeOffset _now;
JsonWebToken _assertion;
TokenValidationResult _validation;

void Establish()
{
_certificate = ClientCertificates.Rsa();
_now = DateTimeOffset.UtcNow;
}

async Task Because()
{
var serialized = CertificateClientAssertion.Create(_certificate, ClientId, TokenEndpoint, _now);
_assertion = new JsonWebToken(serialized);
_validation = await new JsonWebTokenHandler().ValidateTokenAsync(serialized, new TokenValidationParameters
{
ValidIssuer = ClientId,
ValidAudience = TokenEndpoint,
IssuerSigningKey = new X509SecurityKey(_certificate)
});
}

void Destroy() => _certificate.Dispose();

[Fact] void should_carry_a_signature_the_certificate_verifies() => _validation.IsValid.ShouldBeTrue();
[Fact] void should_sign_with_rs256() => _assertion.Alg.ShouldEqual(SecurityAlgorithms.RsaSha256);
[Fact] void should_be_issued_by_the_client() => _assertion.Issuer.ShouldEqual(ClientId);
[Fact] void should_be_about_the_client() => _assertion.Subject.ShouldEqual(ClientId);
[Fact] void should_be_addressed_to_the_token_endpoint() => _assertion.Audiences.ShouldContainOnly(TokenEndpoint);
[Fact] void should_carry_a_unique_identifier() => string.IsNullOrEmpty(_assertion.Id).ShouldBeFalse();
[Fact] void should_name_the_certificate_by_thumbprint() => _assertion.X5t.ShouldEqual(Base64UrlEncoder.Encode(_certificate.GetCertHash()));
[Fact] void should_expire_shortly() => (_assertion.ValidTo - _assertion.IssuedAt).ShouldEqual(CertificateClientAssertion.Lifetime);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Security.Cryptography.X509Certificates;
using Cratis.AuthProxy.Authentication.given;

namespace Cratis.AuthProxy.Authentication.for_CertificateClientAssertion;

public class when_the_certificate_has_no_private_key : Specification
{
X509Certificate2 _certificate;
Exception _error;

void Establish()
{
using var withKey = ClientCertificates.Rsa();
_certificate = X509CertificateLoader.LoadCertificate(withKey.Export(X509ContentType.Cert));
}

void Because() => _error = Catch.Exception(() => CertificateClientAssertion.Create(_certificate, "client-id", "https://login.example.com/token", DateTimeOffset.UtcNow));

void Destroy() => _certificate.Dispose();

[Fact] void should_refuse_to_sign() => _error.ShouldBeOfExactType<OidcClientCredentialUnavailable>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Web;

namespace Cratis.AuthProxy.Authentication.for_OidcClientAssertions.given;

/// <summary>
/// Provides <see cref="OidcClientAssertions"/> over the real Microsoft.Identity.Web credential loader and a scratch
/// directory for credential files.
/// </summary>
public class oidc_client_assertions : Specification
{
protected const string Scheme = "workforce";
protected const string TokenEndpoint = "https://login.example.com/tenant/oauth2/v2.0/token";

protected string _directory;
protected ICredentialsLoader _loader;
protected C.OidcProvider _provider;
protected OidcClientAssertions _assertions;

void Establish()
{
_directory = Directory.CreateTempSubdirectory("authproxy-client-credential-").FullName;
_loader = new DefaultCredentialsLoader(NullLogger<DefaultCredentialsLoader>.Instance);
_provider = new()
{
Name = "Workforce",
Authority = "https://login.example.com/tenant/v2.0",
ClientId = "client-id",
ClientCredential = new()
};
_assertions = new(_loader, TimeProvider.System, NullLogger<OidcClientAssertions>.Instance);
}

void Destroy() => Directory.Delete(_directory, recursive: true);
}
Loading