From 7f23252e9867a51f905088a24c847ec9139c1194 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 11 Aug 2026 18:40:21 +0200 Subject: [PATCH 1/3] Extract one attestation signing stack from the invitation issuer The invitation issuer already minted RS256 envelopes with key rotation, issuer and audience binding, a lifetime and a random identifier, but it did so behind a private method, so a second caller had no way to reach it without duplicating the signing or the configuration checks that guard it. Lifting it out is deliberate: one signing implementation is one place to audit, one place where a key is loaded, and one place a mistake can live. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UWugkmN6NmoemKeTvSKNDg --- .../AttestationConfigurationValidation.cs | 95 +++++++++++++++++++ .../Attestations/AttestationSigner.cs | 90 ++++++++++++++++++ .../AttestationSigningContract.cs | 24 +++++ ...tationAttestationConfigurationValidator.cs | 73 +++----------- .../Invites/InvitationAttestationIssuer.cs | 55 +++-------- 5 files changed, 233 insertions(+), 104 deletions(-) create mode 100644 Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs create mode 100644 Source/AuthProxy/Attestations/AttestationSigner.cs create mode 100644 Source/AuthProxy/Attestations/AttestationSigningContract.cs diff --git a/Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs b/Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs new file mode 100644 index 0000000..5fccf21 --- /dev/null +++ b/Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs @@ -0,0 +1,95 @@ +// 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; + +namespace Cratis.AuthProxy.Attestations; + +/// +/// The shared configuration checks every AuthProxy-signed protocol applies to its signing settings. +/// +/// +/// Signed protocols configure their own sections, so each owns its own . +/// What a signing section has to prove is identical for all of them — bounded, trimmed, control-character-free +/// values that are safe to carry in a signed assertion, and RSA key material large enough to sign with — so +/// those checks live here once rather than once per protocol. +/// +static class AttestationConfigurationValidation +{ + /// + /// The upper bound on any single configured value that ends up inside a signed assertion. + /// + internal const int MaximumValueLength = 2048; + + /// + /// Validates that a configured endpoint is an absolute HTTPS URL carrying no credentials or fragment. + /// + /// The configured value. + /// The configuration path reported on failure. + /// The accumulated failures. + internal static void ValidateAbsoluteEndpoint(string value, string path, List failures) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) + || (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && !(string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && uri.IsLoopback)) + || !string.IsNullOrEmpty(uri.UserInfo) + || !string.IsNullOrEmpty(uri.Fragment)) + { + failures.Add($"{path} must be an absolute HTTPS URL (HTTP is allowed only for loopback development)."); + } + } + + /// + /// Validates that a configured value is nonempty, trimmed, bounded and free of control characters. + /// + /// The configured value. + /// The configuration path reported on failure. + /// The accumulated failures. + internal static void ValidateBoundedValue(string value, string path, List failures) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > MaximumValueLength + || !string.Equals(value, value.Trim(), StringComparison.Ordinal) + || value.Any(char.IsControl)) + { + failures.Add($"{path} must be nonempty, trimmed, and no longer than {MaximumValueLength} characters."); + } + } + + /// + /// Validates one configured signing key's identifier and RSA private key material. + /// + /// The configured key identifier. + /// The configured PEM-encoded RSA private key. + /// The configuration path of the signing key collection, reported on failure. + /// The accumulated failures. + internal static void ValidateSigningKey(string keyId, string privateKeyPem, string path, List failures) + { + ValidateBoundedValue(keyId, $"{path}.KeyId", failures); + if (keyId.Length > 128 + || keyId.Any(_ => !(char.IsAsciiLetterOrDigit(_) || _ is '.' or '_' or '-'))) + { + failures.Add($"{path}.KeyId must be a bounded ASCII identifier using letters, digits, periods, underscores, or hyphens."); + } + if (string.IsNullOrWhiteSpace(privateKeyPem)) + { + failures.Add($"{path}.PrivateKeyPem is required."); + return; + } + + try + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(privateKeyPem); + _ = rsa.ExportParameters(true); + if (rsa.KeySize < 2048) + { + failures.Add($"{path}.PrivateKeyPem must contain an RSA key of at least 2048 bits."); + } + } + catch (Exception exception) when (exception is CryptographicException or ArgumentException) + { + failures.Add($"{path}.PrivateKeyPem must contain a valid RSA private key."); + } + } +} diff --git a/Source/AuthProxy/Attestations/AttestationSigner.cs b/Source/AuthProxy/Attestations/AttestationSigner.cs new file mode 100644 index 0000000..5c41f7f --- /dev/null +++ b/Source/AuthProxy/Attestations/AttestationSigner.cs @@ -0,0 +1,90 @@ +// 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 Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Attestations; + +/// +/// Mints the RS256 JWS assertions AuthProxy signs for its server-to-server protocols. +/// +/// +/// This is the one signing implementation in AuthProxy. It owns the bindings every signed AuthProxy assertion +/// carries — provenance (iss plus the kid header selecting the key), audience (aud), +/// freshness (iat, nbf, exp) and replay resistance (a random 256-bit jti) — and +/// leaves every protocol-specific binding to the caller's claims. Signing never throws on unusable key +/// material; it reports failure so a caller can refuse to send rather than fall back to an unsigned call. +/// +public static class AttestationSigner +{ + /// + /// Creates a cryptographically random 256-bit opaque value. + /// + /// A base64url-encoded opaque value. + public static string CreateOpaqueValue() + { + Span value = stackalloc byte[32]; + RandomNumberGenerator.Fill(value); + return Base64UrlEncoder.Encode(value.ToArray()); + } + + /// + /// Tries to sign one assertion carrying the supplied protocol claims. + /// + /// The resolved signing parameters. + /// The instant the assertion is issued, from which iat, nbf and exp are derived. + /// The protocol claims to bind, extended in place with the generated jti. + /// The compact signed JWS when successful; otherwise an empty string. + /// when the assertion was signed; otherwise . + /// + /// A result means the configured key material could not be used. It is never a + /// reason to proceed unsigned — a caller that has been configured to sign must refuse to send instead. + /// + public static bool TryIssue( + AttestationSigningContract contract, + DateTimeOffset issuedAt, + IDictionary claims, + out string attestation) + { + attestation = string.Empty; + if (!TryCreateSigningCredentials(contract, out var credentials)) + { + return false; + } + + claims[JwtRegisteredClaimNames.Jti] = CreateOpaqueValue(); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = contract.Issuer, + Audience = contract.Audience, + IssuedAt = issuedAt.UtcDateTime, + NotBefore = issuedAt.UtcDateTime, + Expires = issuedAt.Add(contract.Lifetime).UtcDateTime, + Claims = claims, + SigningCredentials = credentials, + }; + + attestation = new JsonWebTokenHandler().CreateToken(descriptor); + return true; + } + + static bool TryCreateSigningCredentials(AttestationSigningContract contract, out SigningCredentials credentials) + { + credentials = default!; + try + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(contract.PrivateKeyPem); + var securityKey = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = contract.KeyId }; + credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256); + return true; + } + catch (Exception exception) when (exception is CryptographicException or ArgumentException) + { + return false; + } + } +} diff --git a/Source/AuthProxy/Attestations/AttestationSigningContract.cs b/Source/AuthProxy/Attestations/AttestationSigningContract.cs new file mode 100644 index 0000000..33c19bb --- /dev/null +++ b/Source/AuthProxy/Attestations/AttestationSigningContract.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Attestations; + +/// +/// Represents the resolved signing parameters for one AuthProxy-signed assertion. +/// +/// The issuer written to the assertion's iss claim, naming the AuthProxy deployment that signed it. +/// The audience written to the assertion's aud claim, naming the single application entitled to consume it. +/// The identifier of the resolved active signing key, written to the JWS kid header so a verifier can select the matching public key. +/// The PEM-encoded RSA private key belonging to . +/// The lifetime applied to the assertion, from which its exp claim is derived. +/// +/// The contract is the boundary between a configuration section and . Each +/// signed protocol resolves its own active key from its own configuration and hands the result over, so one +/// signing implementation serves every protocol without any of them sharing a configuration shape. +/// +public sealed record AttestationSigningContract( + string Issuer, + string Audience, + string KeyId, + string PrivateKeyPem, + TimeSpan Lifetime); diff --git a/Source/AuthProxy/Invites/InvitationAttestationConfigurationValidator.cs b/Source/AuthProxy/Invites/InvitationAttestationConfigurationValidator.cs index 5c9f4c7..3e4ec5d 100644 --- a/Source/AuthProxy/Invites/InvitationAttestationConfigurationValidator.cs +++ b/Source/AuthProxy/Invites/InvitationAttestationConfigurationValidator.cs @@ -1,7 +1,7 @@ // 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 Cratis.AuthProxy.Attestations; using Microsoft.Extensions.Options; using C = Cratis.AuthProxy.Configuration; @@ -12,8 +12,6 @@ namespace Cratis.AuthProxy.Invites; /// sealed class InvitationAttestationConfigurationValidator : IValidateOptions { - const int MaximumValueLength = 2048; - /// /// Validates one AuthProxy configuration instance. /// @@ -30,11 +28,11 @@ public ValidateOptionsResult Validate(string? name, C.AuthProxy options) } var failures = new List(); - ValidateAbsoluteEndpoint(invite!.StageUrl, "Invite.StageUrl", failures); - ValidateAbsoluteEndpoint(invite.ExchangeUrl, "Invite.ExchangeUrl", failures); - ValidateBoundedValue(attestation.Issuer, "Invite.Attestation.Issuer", failures); - ValidateBoundedValue(attestation.Audience, "Invite.Attestation.Audience", failures); - ValidateBoundedValue(attestation.ActiveKeyId, "Invite.Attestation.ActiveKeyId", failures); + AttestationConfigurationValidation.ValidateAbsoluteEndpoint(invite!.StageUrl, "Invite.StageUrl", failures); + AttestationConfigurationValidation.ValidateAbsoluteEndpoint(invite.ExchangeUrl, "Invite.ExchangeUrl", failures); + AttestationConfigurationValidation.ValidateBoundedValue(attestation.Issuer, "Invite.Attestation.Issuer", failures); + AttestationConfigurationValidation.ValidateBoundedValue(attestation.Audience, "Invite.Attestation.Audience", failures); + AttestationConfigurationValidation.ValidateBoundedValue(attestation.ActiveKeyId, "Invite.Attestation.ActiveKeyId", failures); if (attestation.Lifetime < TimeSpan.FromSeconds(10) || attestation.Lifetime > TimeSpan.FromSeconds(60)) { @@ -53,7 +51,11 @@ public ValidateOptionsResult Validate(string? name, C.AuthProxy options) foreach (var key in attestation.SigningKeys) { - ValidateSigningKey(key, failures); + AttestationConfigurationValidation.ValidateSigningKey( + key.KeyId, + key.PrivateKeyPem, + "Invite.Attestation.SigningKeys", + failures); } if (attestation.SigningKeys.Count(_ => string.Equals(_.KeyId, attestation.ActiveKeyId, StringComparison.Ordinal)) != 1) @@ -75,57 +77,4 @@ public ValidateOptionsResult Validate(string? name, C.AuthProxy options) ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); } - - static void ValidateAbsoluteEndpoint(string value, string path, List failures) - { - if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) - || (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) - && !(string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && uri.IsLoopback)) - || !string.IsNullOrEmpty(uri.UserInfo) - || !string.IsNullOrEmpty(uri.Fragment)) - { - failures.Add($"{path} must be an absolute HTTPS URL (HTTP is allowed only for loopback development)."); - } - } - - static void ValidateBoundedValue(string value, string path, List failures) - { - if (string.IsNullOrWhiteSpace(value) - || value.Length > MaximumValueLength - || !string.Equals(value, value.Trim(), StringComparison.Ordinal) - || value.Any(char.IsControl)) - { - failures.Add($"{path} must be nonempty, trimmed, and no longer than {MaximumValueLength} characters."); - } - } - - static void ValidateSigningKey(C.InvitationAttestationSigningKey key, List failures) - { - ValidateBoundedValue(key.KeyId, "Invite.Attestation.SigningKeys.KeyId", failures); - if (key.KeyId.Length > 128 - || key.KeyId.Any(_ => !(char.IsAsciiLetterOrDigit(_) || _ is '.' or '_' or '-'))) - { - failures.Add("Invite.Attestation.SigningKeys.KeyId must be a bounded ASCII identifier using letters, digits, periods, underscores, or hyphens."); - } - if (string.IsNullOrWhiteSpace(key.PrivateKeyPem)) - { - failures.Add("Invite.Attestation.SigningKeys.PrivateKeyPem is required."); - return; - } - - try - { - using var rsa = RSA.Create(); - rsa.ImportFromPem(key.PrivateKeyPem); - _ = rsa.ExportParameters(true); - if (rsa.KeySize < 2048) - { - failures.Add("Invite.Attestation.SigningKeys.PrivateKeyPem must contain an RSA key of at least 2048 bits."); - } - } - catch (Exception exception) when (exception is CryptographicException or ArgumentException) - { - failures.Add("Invite.Attestation.SigningKeys.PrivateKeyPem must contain a valid RSA private key."); - } - } } diff --git a/Source/AuthProxy/Invites/InvitationAttestationIssuer.cs b/Source/AuthProxy/Invites/InvitationAttestationIssuer.cs index b19cfc4..ff9f15b 100644 --- a/Source/AuthProxy/Invites/InvitationAttestationIssuer.cs +++ b/Source/AuthProxy/Invites/InvitationAttestationIssuer.cs @@ -1,10 +1,8 @@ // 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 Cratis.AuthProxy.Attestations; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.Invites; @@ -51,29 +49,7 @@ public bool TryIssueComplete(InvitationEntryState state, InvitationVerifiedIdent /// Creates a cryptographically random 256-bit opaque value. /// /// A base64url-encoded opaque value. - internal static string CreateOpaqueValue() - { - Span value = stackalloc byte[32]; - RandomNumberGenerator.Fill(value); - return Base64UrlEncoder.Encode(value.ToArray()); - } - - static bool TryCreateSigningCredentials(C.InvitationAttestationSigningKey key, out SigningCredentials credentials) - { - credentials = default!; - try - { - using var rsa = RSA.Create(); - rsa.ImportFromPem(key.PrivateKeyPem); - var securityKey = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = key.KeyId }; - credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256); - return true; - } - catch (Exception exception) when (exception is CryptographicException or ArgumentException) - { - return false; - } - } + internal static string CreateOpaqueValue() => AttestationSigner.CreateOpaqueValue(); bool TryIssue(InvitationEntryState state, Dictionary claims, out string attestation) { @@ -86,31 +62,26 @@ bool TryIssue(InvitationEntryState state, Dictionary claims, out var key = settings.SigningKeys.SingleOrDefault(_ => string.Equals(_.KeyId, settings.ActiveKeyId, StringComparison.Ordinal)); - if (key is null || !TryCreateSigningCredentials(key, out var credentials)) + if (key is null) { return false; } - claims[JwtRegisteredClaimNames.Jti] = CreateOpaqueValue(); claims[InvitationAttestationClaims.TenantId] = state.TenantId; claims[InvitationAttestationClaims.InvitationId] = state.InvitationId; claims[InvitationAttestationClaims.InvitationTransaction] = state.InvitationTransaction; claims[InvitationAttestationClaims.InvitationChallenge] = state.InvitationChallenge; claims[InvitationAttestationClaims.CapabilityHash] = state.CapabilityHash; - var now = timeProvider.GetUtcNow(); - var descriptor = new SecurityTokenDescriptor - { - Issuer = settings.Issuer, - Audience = settings.Audience, - IssuedAt = now.UtcDateTime, - NotBefore = now.UtcDateTime, - Expires = now.Add(settings.Lifetime).UtcDateTime, - Claims = claims, - SigningCredentials = credentials, - }; - - attestation = new JsonWebTokenHandler().CreateToken(descriptor); - return true; + return AttestationSigner.TryIssue( + new AttestationSigningContract( + settings.Issuer, + settings.Audience, + key.KeyId, + key.PrivateKeyPem, + settings.Lifetime), + timeProvider.GetUtcNow(), + claims, + out attestation); } } From ef8dff117dbe43be10935bee55ba7ab655dff38a Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 11 Aug 2026 18:40:21 +0200 Subject: [PATCH 2/3] Let an application authenticate the sign-in it is told about The sign-in notification was posted as unsigned JSON, so anything that could reach that endpoint chose which user the application recorded as having signed in, including the subject and the identity provider. The notification can now carry an envelope binding six facts: who signed it, which application it is for, the method and target it was sent to, a digest of the exact bytes posted, when it was issued, and a single-use identifier. Route and body use the RFC 9449 claim names so this is a profile of an existing scheme rather than a private one. The digest is taken over the serialized bytes rather than the object they came from, because only the former is what the verifier will actually see. It is opt-in and gated on configuration alone, so a deployment that does not configure it keeps today's behavior byte for byte. When it is configured and an envelope cannot be issued, nothing is posted at all -- a notification that cannot be authenticated is worth less than none. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UWugkmN6NmoemKeTvSKNDg --- Documentation/configuration/sign-in.md | 120 ++++++++++++++- .../SignIns/RecordingHttpMessageHandler.cs | 13 ++ .../a_sign_in_attestation_configuration.cs | 44 ++++++ ...an_undersized_signing_key_is_configured.cs | 19 +++ .../and_it_is_complete.cs | 19 +++ .../and_signing_is_not_configured.cs | 25 ++++ ...he_active_key_identifier_matches_no_key.cs | 19 +++ .../and_the_lifetime_is_out_of_bounds.cs | 28 ++++ ..._url_is_not_an_absolute_secure_endpoint.cs | 28 ++++ .../given/a_sign_in_notification_signer.cs | 104 +++++++++++++ .../and_it_has_outlived_its_lifetime.cs | 29 ++++ .../and_signing_is_not_configured.cs | 24 +++ .../and_the_active_key_is_not_configured.cs | 24 +++ .../and_the_body_differs.cs | 34 +++++ .../and_the_route_differs.cs | 34 +++++ .../and_the_same_request_is_signed_twice.cs | 27 ++++ .../and_the_signing_key_is_malformed.cs | 25 ++++ .../and_the_verifier_pins_another_audience.cs | 27 ++++ .../and_the_verifier_pins_another_issuer.cs | 27 ++++ .../and_the_verifier_pins_another_key.cs | 28 ++++ ...he_verifier_uses_the_published_contract.cs | 41 ++++++ .../given/a_signed_sign_in_notifier.cs | 138 ++++++++++++++++++ .../when_inspecting_public_constructors.cs | 9 ++ .../and_no_signer_was_supplied.cs | 23 +++ .../and_the_body_is_altered_in_flight.cs | 34 +++++ ...s_compared_to_the_released_notification.cs | 29 ++++ .../and_the_envelope_cannot_be_signed.cs | 23 +++ .../and_the_envelope_is_verified.cs | 41 ++++++ .../and_the_target_is_configured_elsewhere.cs | 30 ++++ .../when_signing_is_not_configured.cs | 34 +++++ .../when_adding_sign_ins.cs | 3 + ...tifying_through_the_registered_services.cs | 77 ++++++++++ Source/AuthProxy/Configuration/SignIn.cs | 8 + .../Configuration/SignInAttestation.cs | 59 ++++++++ .../SignInAttestationSigningKey.cs | 24 +++ .../SignIns/ISignInNotificationSigner.cs | 30 ++++ .../SignIns/SignInAttestationClaims.cs | 42 ++++++ ...SignInAttestationConfigurationValidator.cs | 73 +++++++++ .../SignIns/SignInNotificationSigner.cs | 68 +++++++++ Source/AuthProxy/SignIns/SignInNotifier.cs | 42 +++++- .../SignIns/SignInNotifierLogging.cs | 3 + .../SignInsServiceCollectionExtensions.cs | 24 ++- 42 files changed, 1543 insertions(+), 10 deletions(-) create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/given/a_sign_in_attestation_configuration.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_an_undersized_signing_key_is_configured.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_it_is_complete.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_signing_is_not_configured.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_active_key_identifier_matches_no_key.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_lifetime_is_out_of_bounds.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_is_not_an_absolute_secure_endpoint.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/given/a_sign_in_notification_signer.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_it_has_outlived_its_lifetime.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_signing_is_not_configured.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_active_key_is_not_configured.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_body_differs.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_route_differs.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_same_request_is_signed_twice.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_signing_key_is_malformed.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_audience.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_issuer.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_key.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_uses_the_published_contract.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotifier/given/a_signed_sign_in_notifier.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_no_signer_was_supplied.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_body_is_altered_in_flight.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_body_is_compared_to_the_released_notification.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_envelope_cannot_be_signed.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_envelope_is_verified.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_target_is_configured_elsewhere.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_is_not_configured.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInsServiceCollectionExtensions/when_notifying_through_the_registered_services.cs create mode 100644 Source/AuthProxy/Configuration/SignInAttestation.cs create mode 100644 Source/AuthProxy/Configuration/SignInAttestationSigningKey.cs create mode 100644 Source/AuthProxy/SignIns/ISignInNotificationSigner.cs create mode 100644 Source/AuthProxy/SignIns/SignInAttestationClaims.cs create mode 100644 Source/AuthProxy/SignIns/SignInAttestationConfigurationValidator.cs create mode 100644 Source/AuthProxy/SignIns/SignInNotificationSigner.cs diff --git a/Documentation/configuration/sign-in.md b/Documentation/configuration/sign-in.md index 23f7458..2acf8ac 100644 --- a/Documentation/configuration/sign-in.md +++ b/Documentation/configuration/sign-in.md @@ -73,8 +73,8 @@ subject and `identityProvider` becomes the same compatibility value as `provider guessed. - **`userAgent`** — the raw header, so the application can do its own richer parsing if it wants to. -Unlike the invite and link exchanges, the sign-in notification carries **no bearer token** — there is no -user-supplied token in this flow. It relies on the endpoint being network-isolated (see [Security](#security)). +By default the notification carries **no credential** and relies on the endpoint being network-isolated (see +[Security](#security)). Configure [the signed envelope](#the-signed-envelope) to authenticate it instead. Canonical identity is opt-in per provider, so an application migrating provider registrations must accept both body shapes. A notification says that a provider authenticated the tuple; it does not grant application @@ -129,9 +129,117 @@ posted). --- +## The signed envelope + +Without further configuration the notification body is the *only* evidence the application has, so anything +that can reach the endpoint chooses which user gets recorded as signed in — including the `subject`, +`providerKey` and `issuer`. Set `Cratis:AuthProxy:SignIn:Attestation` and AuthProxy signs a short-lived RS256 +JWS over each notification and sends it as `Authorization: Bearer`. + +**The body is unchanged.** The envelope travels in a header, so an application already consuming +notifications keeps parsing exactly the same JSON. + +### What the envelope binds + +The envelope is a profile of [RFC 9449 (DPoP)](https://www.rfc-editor.org/rfc/rfc9449) rather than a scheme of +its own. Six facts are bound: + +| Fact | Carried by | Meaning | +|---|---|---| +| Provenance | `iss` + the `kid` JWS header | which AuthProxy deployment signed it, and under which key | +| Audience | `aud` | the single application entitled to consume it | +| Route | `htm`, `htu` | the method and target URI of the request it accompanies | +| Body | `body_hash` | base64url SHA-256 of the exact bytes posted | +| Time | `iat`, `nbf`, `exp` | the window it is valid in | +| Replay | `jti` | a random 256-bit identifier, unique per notification | + +A `purpose` claim of `sign-in-notification` separates the envelope from every other assertion AuthProxy signs, +so an invitation attestation can never be presented in its place. + +Two details a verifier must implement exactly: + +- **`htu` follows RFC 9449** — the target URI *without* query and fragment. Compare it against the + query-stripped request URI, not the raw target. +- **`body_hash` is an AuthProxy extension** — RFC 9449 defines no body digest. It uses the identical + construction to that specification's `ath` claim: unpadded base64url of the SHA-256 of the raw request body. + +### Verifying a notification + +1. Reject the request outright if the `Authorization: Bearer` header is missing. +2. Select the public key by the JWS `kid` header from your pinned key set, and require RS256. +3. Validate `iss`, `aud`, `exp` and `nbf` with no clock skew allowance beyond your own tolerance. +4. Require `purpose` to be `sign-in-notification`. +5. Compare `htm` to the request method and `htu` to the request URI with query and fragment removed. +6. Read the raw request body **before** deserializing it, and compare `body_hash` to its SHA-256 digest. +7. Reject a `jti` already seen inside the envelope lifetime. + +> **AuthProxy publishes no JWKS document.** The verifying application pins the public keys by its own +> configuration and selects one by `kid` — the same way the invitation authority consumes invitation +> attestations. Key rotation is therefore a coordinated configuration change on both sides. + +### Configuring it + +```json +{ + "Cratis": { + "AuthProxy": { + "SignIn": { + "NotifyUrl": "https://studio.example.com/api/internal/sign-ins", + "Attestation": { + "Issuer": "https://auth.example.com", + "Audience": "studio", + "ActiveKeyId": "sign-in-2026-08", + "Lifetime": "00:00:60", + "SigningKeys": [ + { + "KeyId": "sign-in-2026-08", + "PrivateKeyPem": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" + } + ] + } + } + } + } +} +``` + +| Setting | Meaning | +|---|---| +| `Cratis:AuthProxy:SignIn:Attestation:Issuer` | written to `iss`; required, and required to match at the verifier | +| `Cratis:AuthProxy:SignIn:Attestation:Audience` | written to `aud`; names the one application entitled to the notification | +| `Cratis:AuthProxy:SignIn:Attestation:ActiveKeyId` | the key new envelopes are signed with; must name exactly one configured key | +| `Cratis:AuthProxy:SignIn:Attestation:SigningKeys` | the available keys, each a `KeyId` and a PEM-encoded RSA `PrivateKeyPem` of at least 2048 bits | +| `Cratis:AuthProxy:SignIn:Attestation:Lifetime` | the envelope lifetime; between 10 and 60 seconds, defaulting to 60 | + +Supply `PrivateKeyPem` through a secret provider. AuthProxy never returns or logs it — publish only the +matching public key to the application. + +**Key rotation.** Add the new key to `SigningKeys`, publish its public half to the application, then move +`ActiveKeyId` to it. Keep the previous key configured until every envelope it signed has expired. + +### Compatibility and failure behavior + +- **Leaving the section unset changes nothing.** No `Authorization` header is added and the body is byte-for-byte + what it has always been. +- **Once configured, AuthProxy never downgrades.** If an envelope cannot be signed — unusable key material, + an `ActiveKeyId` naming no key — the notification is *not posted at all* and the failure is logged. A + sign-in is never recorded on unauthenticated evidence. +- **Configuration is validated at startup**, so an unusable key fails the process rather than silently + suppressing every sign-in notification. When attestation is configured, `NotifyUrl` must also be an absolute + HTTPS URL (HTTP is accepted only for loopback development). + +--- + ## Security -The notification JSON is not signed and carries no bearer credential. Point `NotifyUrl` at an internal -application address that is **network-isolated** from public traffic, or authenticate AuthProxy separately at -the application endpoint. Treat the identity tuple as authenticated provider metadata, then apply the -application's own authorization policy before changing any access or membership. +**Unsigned by default.** With no [`Attestation`](#the-signed-envelope) section the notification JSON is not +signed and carries no credential. Point `NotifyUrl` at an internal application address that is +**network-isolated** from public traffic, or authenticate AuthProxy separately at the application endpoint. + +**Signed when configured.** The envelope establishes that AuthProxy produced this exact notification, for this +application, over this exact body, recently, and only once. Network isolation and an authenticated envelope +are complementary — enabling one is not a reason to relax the other. + +Either way, treat the identity tuple as authenticated provider metadata, then apply the application's own +authorization policy before changing any access or membership. A verified envelope proves the notification's +origin and integrity; it grants no membership, role, or scope. diff --git a/Source/AuthProxy.Specs/SignIns/RecordingHttpMessageHandler.cs b/Source/AuthProxy.Specs/SignIns/RecordingHttpMessageHandler.cs index 99d11aa..8594d18 100644 --- a/Source/AuthProxy.Specs/SignIns/RecordingHttpMessageHandler.cs +++ b/Source/AuthProxy.Specs/SignIns/RecordingHttpMessageHandler.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Net; +using System.Net.Http.Headers; namespace Cratis.AuthProxy.SignIns; @@ -11,9 +12,21 @@ public class RecordingHttpMessageHandler(HttpStatusCode statusCode = HttpStatusC public string? LastRequestBody { get; private set; } + /// + /// The exact bytes the transport received — the only body a digest claim can honestly be checked against. + /// + public ReadOnlyMemory LastRequestBytes { get; private set; } + + /// + /// Captured while the request is still alive, since the notifier disposes it as soon as the call returns. + /// + public AuthenticationHeaderValue? LastRequestAuthorization { get; private set; } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; + LastRequestAuthorization = request.Headers.Authorization; + LastRequestBytes = request.Content is null ? default : await request.Content.ReadAsByteArrayAsync(cancellationToken); LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); return new HttpResponseMessage(statusCode); } diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/given/a_sign_in_attestation_configuration.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/given/a_sign_in_attestation_configuration.cs new file mode 100644 index 0000000..722ea1d --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/given/a_sign_in_attestation_configuration.cs @@ -0,0 +1,44 @@ +// 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; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +public class a_sign_in_attestation_configuration : Specification +{ + protected const string NotifyUrl = "https://studio.example.com/api/internal/sign-ins"; + + protected static C.SignInAttestationSigningKey PrivateKey(string keyId, int keySize = 2048) + { + using var rsa = RSA.Create(keySize); + return new C.SignInAttestationSigningKey + { + KeyId = keyId, + PrivateKeyPem = rsa.ExportPkcs8PrivateKeyPem(), + }; + } + + protected static C.AuthProxy Configuration( + string notifyUrl = NotifyUrl, + string? activeKeyId = null, + TimeSpan? lifetime = null, + params C.SignInAttestationSigningKey[] signingKeys) => new() + { + SignIn = new C.SignIn + { + NotifyUrl = notifyUrl, + Attestation = new C.SignInAttestation + { + Issuer = "https://auth.example.com", + Audience = "ada", + ActiveKeyId = activeKeyId ?? signingKeys[0].KeyId, + SigningKeys = signingKeys, + Lifetime = lifetime ?? TimeSpan.FromSeconds(60), + } + } + }; + + protected static ValidateOptionsResult Validate(C.AuthProxy configuration) => + new SignInAttestationConfigurationValidator().Validate(null, configuration); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_an_undersized_signing_key_is_configured.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_an_undersized_signing_key_is_configured.cs new file mode 100644 index 0000000..dd00c48 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_an_undersized_signing_key_is_configured.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// An RS256 key below 2048 bits would still sign, so nothing at run time would ever complain — the strength +/// of the whole binding has to be checked where it is configured. +/// +public class and_an_undersized_signing_key_is_configured : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _result; + + void Because() => _result = Validate(Configuration(signingKeys: PrivateKey("current", 1024))); + + [Fact] void should_reject_the_configuration() => _result.Succeeded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_it_is_complete.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_it_is_complete.cs new file mode 100644 index 0000000..40b5420 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_it_is_complete.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// The baseline every rejection is measured against — without it, a validator that rejected everything would +/// make each negative spec below pass for the wrong reason. +/// +public class and_it_is_complete : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _result; + + void Because() => _result = Validate(Configuration(signingKeys: PrivateKey("current"))); + + [Fact] void should_accept_the_configuration() => _result.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_signing_is_not_configured.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_signing_is_not_configured.cs new file mode 100644 index 0000000..bc4137a --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_signing_is_not_configured.cs @@ -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 Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// Every deployment that has not opted in must start unaffected — including the ones whose notify URL would +/// never satisfy the rules that only apply to a signing deployment. +/// +public class and_signing_is_not_configured : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _withoutTheSection; + ValidateOptionsResult _withoutSignIn; + + void Because() + { + _withoutTheSection = Validate(new C.AuthProxy { SignIn = new C.SignIn { NotifyUrl = "not-a-url" } }); + _withoutSignIn = Validate(new C.AuthProxy()); + } + + [Fact] void should_accept_an_unsigned_notification_configuration() => _withoutTheSection.Succeeded.ShouldBeTrue(); + [Fact] void should_accept_a_configuration_without_sign_ins_at_all() => _withoutSignIn.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_active_key_identifier_matches_no_key.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_active_key_identifier_matches_no_key.cs new file mode 100644 index 0000000..4278425 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_active_key_identifier_matches_no_key.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// A rotation that leaves the active identifier pointing at nothing signs nothing, which at run time means +/// every sign-in stops being recorded. That has to be a startup failure, not a silent one. +/// +public class and_the_active_key_identifier_matches_no_key : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _result; + + void Because() => _result = Validate(Configuration(activeKeyId: "retired", signingKeys: PrivateKey("current"))); + + [Fact] void should_reject_the_configuration() => _result.Succeeded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_lifetime_is_out_of_bounds.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_lifetime_is_out_of_bounds.cs new file mode 100644 index 0000000..1227c84 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_lifetime_is_out_of_bounds.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// The time binding is what keeps a captured envelope from being useful later, so its window is bounded at +/// both ends rather than left to a deployer to widen into meaninglessness. +/// +public class and_the_lifetime_is_out_of_bounds : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _tooLong; + ValidateOptionsResult _tooShort; + ValidateOptionsResult _atTheUpperBound; + + void Because() + { + _tooLong = Validate(Configuration(lifetime: TimeSpan.FromSeconds(61), signingKeys: PrivateKey("current"))); + _tooShort = Validate(Configuration(lifetime: TimeSpan.FromSeconds(9), signingKeys: PrivateKey("current"))); + _atTheUpperBound = Validate(Configuration(lifetime: TimeSpan.FromSeconds(60), signingKeys: PrivateKey("current"))); + } + + [Fact] void should_reject_a_longer_lifetime() => _tooLong.Succeeded.ShouldBeFalse(); + [Fact] void should_reject_an_unusably_short_lifetime() => _tooShort.Succeeded.ShouldBeFalse(); + [Fact] void should_accept_the_upper_bound() => _atTheUpperBound.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_is_not_an_absolute_secure_endpoint.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_is_not_an_absolute_secure_endpoint.cs new file mode 100644 index 0000000..4447949 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_is_not_an_absolute_secure_endpoint.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// The route binding is only as meaningful as the target it names, so a signing deployment has to point at an +/// absolute, secure endpoint — with loopback still allowed for development, as everywhere else in AuthProxy. +/// +public class and_the_notify_url_is_not_an_absolute_secure_endpoint : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _relative; + ValidateOptionsResult _plainHttp; + ValidateOptionsResult _loopback; + + void Because() + { + _relative = Validate(Configuration("/api/internal/sign-ins", signingKeys: PrivateKey("current"))); + _plainHttp = Validate(Configuration("http://studio.example.com/api/internal/sign-ins", signingKeys: PrivateKey("current"))); + _loopback = Validate(Configuration("http://localhost:5000/api/internal/sign-ins", signingKeys: PrivateKey("current"))); + } + + [Fact] void should_reject_a_relative_url() => _relative.Succeeded.ShouldBeFalse(); + [Fact] void should_reject_plain_http_to_a_remote_host() => _plainHttp.Succeeded.ShouldBeFalse(); + [Fact] void should_still_allow_loopback_for_development() => _loopback.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/given/a_sign_in_notification_signer.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/given/a_sign_in_notification_signer.cs new file mode 100644 index 0000000..c7fe481 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/given/a_sign_in_notification_signer.cs @@ -0,0 +1,104 @@ +// 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.Text; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; + +public class a_sign_in_notification_signer : Specification +{ + protected const string Issuer = "https://auth.example.com"; + protected const string Audience = "ada"; + protected const string KeyId = "sign-in-2026-08"; + + /// A target carrying a query and a fragment, so the RFC 9449 htu spelling is observable. + protected static readonly Uri Target = new("https://studio.example.com/api/internal/sign-ins?trace=1#frag"); + + protected static readonly byte[] Body = Encoding.UTF8.GetBytes("{\"subject\":\"subject-123\"}"); + + protected SignInNotificationSigner _signer; + protected C.AuthProxy _configuration; + protected RsaSecurityKey _validationKey; + protected RsaSecurityKey _unrelatedKey; + protected DateTimeOffset _now; + + protected virtual TimeSpan Lifetime => TimeSpan.FromSeconds(60); + + protected virtual DateTimeOffset IssuedAt => _now; + + protected virtual string ActiveKeyId => KeyId; + + protected virtual string PrivateKeyPem(string valid) => valid; + + protected virtual bool SigningIsConfigured => true; + + void Establish() + { + using var rsa = RSA.Create(2048); + using var unrelated = RSA.Create(2048); + _validationKey = new RsaSecurityKey(rsa.ExportParameters(false)) { KeyId = KeyId }; + _unrelatedKey = new RsaSecurityKey(unrelated.ExportParameters(false)) { KeyId = KeyId }; + _now = DateTimeOffset.FromUnixTimeSeconds(TimeProvider.System.GetUtcNow().ToUnixTimeSeconds()); + + _configuration = new C.AuthProxy + { + SignIn = new C.SignIn + { + NotifyUrl = Target.ToString(), + Attestation = SigningIsConfigured + ? new C.SignInAttestation + { + Issuer = Issuer, + Audience = Audience, + ActiveKeyId = ActiveKeyId, + Lifetime = Lifetime, + SigningKeys = + [ + new C.SignInAttestationSigningKey + { + KeyId = KeyId, + PrivateKeyPem = PrivateKeyPem(rsa.ExportPkcs8PrivateKeyPem()), + } + ], + } + : null, + } + }; + + var options = Substitute.For>(); + options.CurrentValue.Returns(_configuration); + _signer = new(options, new FixedTimeProvider(IssuedAt)); + } + + protected async Task Validate( + string envelope, + SecurityKey? validationKey = null, + string issuer = Issuer, + string audience = Audience, + TimeSpan? clockSkew = null) => + await new JsonWebTokenHandler().ValidateTokenAsync(envelope, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = validationKey ?? _validationKey, + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + ClockSkew = clockSkew ?? TimeSpan.Zero, + }); + + protected static JsonWebToken Read(string envelope) => new JsonWebTokenHandler().ReadJsonWebToken(envelope); + + protected static string Claim(JsonWebToken envelope, string type) => envelope.Claims.Single(_ => _.Type == type).Value; + + protected static string Digest(byte[] body) => Base64UrlEncoder.Encode(SHA256.HashData(body)); + + sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_it_has_outlived_its_lifetime.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_it_has_outlived_its_lifetime.cs new file mode 100644 index 0000000..b6309f5 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_it_has_outlived_its_lifetime.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// The time binding, mutated on its own: an envelope captured and presented after its configured lifetime is +/// refused, while the very same envelope verifies for a receiver whose clock still sits inside that window. +/// +public class and_it_has_outlived_its_lifetime : a_sign_in_notification_signer +{ + protected override DateTimeOffset IssuedAt => _now.AddMinutes(-5); + + TokenValidationResult _afterExpiry; + TokenValidationResult _withinTheWindow; + + async Task Because() + { + _signer.TryIssue(HttpMethod.Post, Target, Body, out var envelope); + _afterExpiry = await Validate(envelope); + _withinTheWindow = await Validate(envelope, clockSkew: TimeSpan.FromMinutes(30)); + } + + [Fact] void should_reject_the_expired_envelope() => _afterExpiry.IsValid.ShouldBeFalse(); + [Fact] void should_have_been_a_valid_envelope_inside_its_window() => _withinTheWindow.IsValid.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_signing_is_not_configured.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_signing_is_not_configured.cs new file mode 100644 index 0000000..b603e90 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_signing_is_not_configured.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// With no attestation section there is nothing to sign with and nothing to sign for — the signer says so, +/// and the notifier reads that as "post exactly what has always been posted". +/// +public class and_signing_is_not_configured : a_sign_in_notification_signer +{ + protected override bool SigningIsConfigured => false; + + bool _issued; + string _envelope; + + void Because() => _issued = _signer.TryIssue(HttpMethod.Post, Target, Body, out _envelope); + + [Fact] void should_report_signing_as_disabled() => _signer.IsEnabled.ShouldBeFalse(); + [Fact] void should_not_issue_an_envelope() => _issued.ShouldBeFalse(); + [Fact] void should_not_hand_back_anything_that_could_be_sent() => _envelope.ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_active_key_is_not_configured.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_active_key_is_not_configured.cs new file mode 100644 index 0000000..a7403ae --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_active_key_is_not_configured.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// A rotation that names a key nobody holds must fail loudly rather than mint an envelope under whatever key +/// happens to be first in the list. +/// +public class and_the_active_key_is_not_configured : a_sign_in_notification_signer +{ + protected override string ActiveKeyId => "a-key-that-was-retired"; + + bool _issued; + string _envelope; + + void Because() => _issued = _signer.TryIssue(HttpMethod.Post, Target, Body, out _envelope); + + [Fact] void should_not_issue_an_envelope() => _issued.ShouldBeFalse(); + [Fact] void should_not_hand_back_anything_that_could_be_sent() => _envelope.ShouldBeEmpty(); + [Fact] void should_still_report_signing_as_enabled() => _signer.IsEnabled.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_body_differs.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_body_differs.cs new file mode 100644 index 0000000..4cd1593 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_body_differs.cs @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// The body binding, mutated on its own — the route is held identical so only the bytes move. A forged body +/// differing by a single character produces a different digest, which is what makes the subject of a +/// notification unforgeable rather than merely transported. +/// +public class and_the_body_differs : a_sign_in_notification_signer +{ + static readonly byte[] _forgedBody = Encoding.UTF8.GetBytes("{\"subject\":\"subject-124\"}"); + + JsonWebToken _honest; + JsonWebToken _forged; + + void Because() + { + _signer.TryIssue(HttpMethod.Post, Target, Body, out var honest); + _signer.TryIssue(HttpMethod.Post, Target, _forgedBody, out var forged); + _honest = Read(honest); + _forged = Read(forged); + } + + [Fact] void should_digest_the_honest_bytes() => Claim(_honest, SignInAttestationClaims.BodyHash).ShouldEqual(Digest(Body)); + [Fact] void should_digest_the_forged_bytes() => Claim(_forged, SignInAttestationClaims.BodyHash).ShouldEqual(Digest(_forgedBody)); + [Fact] void should_not_reuse_one_digest_for_two_bodies() => (Claim(_honest, SignInAttestationClaims.BodyHash) == Claim(_forged, SignInAttestationClaims.BodyHash)).ShouldBeFalse(); + [Fact] void should_not_let_the_route_binding_move_with_it() => Claim(_honest, SignInAttestationClaims.HttpUri).ShouldEqual(Claim(_forged, SignInAttestationClaims.HttpUri)); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_route_differs.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_route_differs.cs new file mode 100644 index 0000000..3d5d029 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_route_differs.cs @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; +using Microsoft.IdentityModel.JsonWebTokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// The route binding, mutated on its own. Signing two different routes with everything else held constant is +/// the only thing that separates a real route binding from two constants that happen to read correctly for +/// the single route the notifier actually uses. +/// +public class and_the_route_differs : a_sign_in_notification_signer +{ + static readonly Uri _otherTarget = new("https://elsewhere.example.com/hook"); + + JsonWebToken _first; + JsonWebToken _second; + + void Because() + { + _signer.TryIssue(HttpMethod.Post, Target, Body, out var first); + _signer.TryIssue(HttpMethod.Put, _otherTarget, Body, out var second); + _first = Read(first); + _second = Read(second); + } + + [Fact] void should_bind_the_first_method() => Claim(_first, SignInAttestationClaims.HttpMethod).ShouldEqual("POST"); + [Fact] void should_bind_the_second_method() => Claim(_second, SignInAttestationClaims.HttpMethod).ShouldEqual("PUT"); + [Fact] void should_bind_the_first_target() => Claim(_first, SignInAttestationClaims.HttpUri).ShouldEqual("https://studio.example.com/api/internal/sign-ins"); + [Fact] void should_bind_the_second_target() => Claim(_second, SignInAttestationClaims.HttpUri).ShouldEqual("https://elsewhere.example.com/hook"); + [Fact] void should_not_leave_the_body_binding_free_to_move() => Claim(_first, SignInAttestationClaims.BodyHash).ShouldEqual(Claim(_second, SignInAttestationClaims.BodyHash)); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_same_request_is_signed_twice.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_same_request_is_signed_twice.cs new file mode 100644 index 0000000..28978de --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_same_request_is_signed_twice.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// The replay binding, mutated on its own: two envelopes over an identical request at an identical instant +/// still differ, so a receiver can remember the identifiers it has already accepted. +/// +public class and_the_same_request_is_signed_twice : a_sign_in_notification_signer +{ + string _first; + string _second; + + void Because() + { + _signer.TryIssue(HttpMethod.Post, Target, Body, out var first); + _signer.TryIssue(HttpMethod.Post, Target, Body, out var second); + _first = Read(first).Id; + _second = Read(second).Id; + } + + [Fact] void should_give_the_first_envelope_an_identifier() => _first.ShouldNotBeEmpty(); + [Fact] void should_never_repeat_it() => (_second == _first).ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_signing_key_is_malformed.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_signing_key_is_malformed.cs new file mode 100644 index 0000000..be2f779 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_signing_key_is_malformed.cs @@ -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 Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// Unusable key material is reported, never thrown — a sign-in must not break because a secret was mounted +/// wrong, and it must not be recorded on unauthenticated evidence either. +/// +public class and_the_signing_key_is_malformed : a_sign_in_notification_signer +{ + protected override string PrivateKeyPem(string valid) => "-----BEGIN PRIVATE KEY-----\nnot-a-key\n-----END PRIVATE KEY-----"; + + Exception _exception; + bool _issued; + string _envelope; + + void Because() => _exception = Catch.Exception(() => _issued = _signer.TryIssue(HttpMethod.Post, Target, Body, out _envelope)); + + [Fact] void should_not_throw() => _exception.ShouldBeNull(); + [Fact] void should_not_issue_an_envelope() => _issued.ShouldBeFalse(); + [Fact] void should_not_hand_back_anything_that_could_be_sent() => _envelope.ShouldBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_audience.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_audience.cs new file mode 100644 index 0000000..b519085 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_audience.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// The audience binding, mutated on its own: an envelope minted for one application cannot be presented to a +/// second one, which is what stops a shared AuthProxy deployment from cross-feeding sign-ins. +/// +public class and_the_verifier_pins_another_audience : a_sign_in_notification_signer +{ + TokenValidationResult _underAnotherAudience; + TokenValidationResult _underTheConfiguredAudience; + + async Task Because() + { + _signer.TryIssue(HttpMethod.Post, Target, Body, out var envelope); + _underAnotherAudience = await Validate(envelope, audience: "another-application"); + _underTheConfiguredAudience = await Validate(envelope); + } + + [Fact] void should_reject_the_envelope() => _underAnotherAudience.IsValid.ShouldBeFalse(); + [Fact] void should_still_verify_for_the_configured_audience() => _underTheConfiguredAudience.IsValid.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_issuer.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_issuer.cs new file mode 100644 index 0000000..ab0ed8c --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_issuer.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// The provenance binding as the verifier reads it: an envelope claiming a different origin than the one the +/// application expects is refused, while the configured origin still passes. +/// +public class and_the_verifier_pins_another_issuer : a_sign_in_notification_signer +{ + TokenValidationResult _underAnotherIssuer; + TokenValidationResult _underTheConfiguredIssuer; + + async Task Because() + { + _signer.TryIssue(HttpMethod.Post, Target, Body, out var envelope); + _underAnotherIssuer = await Validate(envelope, issuer: "https://impostor.example.com"); + _underTheConfiguredIssuer = await Validate(envelope); + } + + [Fact] void should_reject_the_envelope() => _underAnotherIssuer.IsValid.ShouldBeFalse(); + [Fact] void should_still_verify_under_the_configured_issuer() => _underTheConfiguredIssuer.IsValid.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_key.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_key.cs new file mode 100644 index 0000000..7923f26 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_pins_another_key.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// The provenance binding, mutated on its own: an application that pins a key AuthProxy does not hold must +/// reject the envelope, and the same envelope must still verify under the real key so the rejection cannot be +/// green merely because nothing was signed. +/// +public class and_the_verifier_pins_another_key : a_sign_in_notification_signer +{ + TokenValidationResult _underTheUnrelatedKey; + TokenValidationResult _underTheRealKey; + + async Task Because() + { + _signer.TryIssue(HttpMethod.Post, Target, Body, out var envelope); + _underTheUnrelatedKey = await Validate(envelope, _unrelatedKey); + _underTheRealKey = await Validate(envelope); + } + + [Fact] void should_reject_the_envelope() => _underTheUnrelatedKey.IsValid.ShouldBeFalse(); + [Fact] void should_still_verify_under_the_real_key() => _underTheRealKey.IsValid.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_uses_the_published_contract.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_uses_the_published_contract.cs new file mode 100644 index 0000000..52235fd --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_uses_the_published_contract.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// Pins every one of the six facts the envelope binds. Each assertion names one fact, so a binding that stops +/// being written fails on its own rather than hiding behind "the token still verifies". +/// +public class and_the_verifier_uses_the_published_contract : a_sign_in_notification_signer +{ + bool _issued; + string _envelope; + JsonWebToken _token; + TokenValidationResult _validation; + + async Task Because() + { + _issued = _signer.TryIssue(HttpMethod.Post, Target, Body, out _envelope); + _token = Read(_envelope); + _validation = await Validate(_envelope); + } + + [Fact] void should_issue_the_envelope() => _issued.ShouldBeTrue(); + [Fact] void should_report_signing_as_enabled() => _signer.IsEnabled.ShouldBeTrue(); + [Fact] void should_verify_against_the_pinned_public_key() => _validation.IsValid.ShouldBeTrue(); + [Fact] void should_name_the_signing_key() => _token.Kid.ShouldEqual(KeyId); + [Fact] void should_bind_provenance_to_the_configured_issuer() => _token.Issuer.ShouldEqual(Issuer); + [Fact] void should_bind_the_audience_to_the_configured_application() => _token.Audiences.ShouldContain(Audience); + [Fact] void should_bind_the_request_method() => Claim(_token, SignInAttestationClaims.HttpMethod).ShouldEqual("POST"); + [Fact] void should_bind_the_request_target_without_query_or_fragment() => Claim(_token, SignInAttestationClaims.HttpUri).ShouldEqual("https://studio.example.com/api/internal/sign-ins"); + [Fact] void should_bind_the_digest_of_the_exact_body_bytes() => Claim(_token, SignInAttestationClaims.BodyHash).ShouldEqual(Digest(Body)); + [Fact] void should_bind_the_time_it_was_issued() => _token.ValidFrom.ShouldEqual(_now.UtcDateTime); + [Fact] void should_bind_the_time_it_expires() => _token.ValidTo.ShouldEqual(_now.AddSeconds(60).UtcDateTime); + [Fact] void should_bind_a_replay_identifier() => _token.Id.ShouldNotBeEmpty(); + [Fact] void should_separate_itself_from_every_other_assertion_signed_with_the_same_key() => Claim(_token, SignInAttestationClaims.Purpose).ShouldEqual(SignInAttestationClaims.NotificationPurpose); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/given/a_signed_sign_in_notifier.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/given/a_signed_sign_in_notifier.cs new file mode 100644 index 0000000..226ae67 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/given/a_signed_sign_in_notifier.cs @@ -0,0 +1,138 @@ +// 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 Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotifier.given; + +/// +/// A notifier wired exactly as the host wires it once SignIn:Attestation is configured — the same +/// released notifier, with the envelope signer available to it. +/// +public class a_signed_sign_in_notifier : a_sign_in_notifier +{ + protected const string Issuer = "https://auth.example.com"; + protected const string Audience = "ada"; + protected const string KeyId = "sign-in-2026-08"; + + protected static readonly string SigningKeyPem = CreatePrivateKeyPem(); + protected static readonly RsaSecurityKey ValidationKey = CreateValidationKey(SigningKeyPem); + + protected DateTimeOffset _now; + + protected virtual string ConfiguredNotifyUrl => NotifyUrl; + + protected virtual string ConfiguredSigningKeyPem => SigningKeyPem; + + protected virtual bool SignerIsAvailable => true; + + protected virtual C.SignInAttestation? CreateAttestation() => new() + { + Issuer = Issuer, + Audience = Audience, + ActiveKeyId = KeyId, + Lifetime = TimeSpan.FromSeconds(60), + SigningKeys = + [ + new C.SignInAttestationSigningKey + { + KeyId = KeyId, + PrivateKeyPem = ConfiguredSigningKeyPem, + } + ], + }; + + protected override C.AuthProxy CreateConfig() => new() + { + SignIn = new C.SignIn + { + NotifyUrl = ConfiguredNotifyUrl, + Attestation = CreateAttestation(), + } + }; + + protected override SignInNotifier CreateNotifier( + C.AuthProxy configuration, + IOptionsMonitor optionsMonitor, + IHttpClientFactory httpClientFactory) + { + _now = DateTimeOffset.FromUnixTimeSeconds(TimeProvider.System.GetUtcNow().ToUnixTimeSeconds()); + return SignerIsAvailable + ? new( + optionsMonitor, + new ClientLocationResolver(), + httpClientFactory, + Substitute.For>(), + null, + new SignInNotificationSigner(optionsMonitor, new FixedTimeProvider(_now))) + : new( + optionsMonitor, + new ClientLocationResolver(), + httpClientFactory, + Substitute.For>()); + } + + /// + /// Posts the very same sign-in through the released four-argument notifier, which has no signer and no + /// attestation configuration at all. Its recorded request is what "today's behavior" means, byte for byte. + /// + /// The handler that recorded the released notifier's request. + protected async Task NotifyThroughTheReleasedNotifier() + { + var handler = new RecordingHttpMessageHandler(); + var options = Substitute.For>(); + options.CurrentValue.Returns(new C.AuthProxy { SignIn = new C.SignIn { NotifyUrl = NotifyUrl } }); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(handler)); + + var released = new SignInNotifier( + options, + new ClientLocationResolver(), + httpClientFactory, + Substitute.For>()); + await released.Notify(_httpContext, _principal); + return handler; + } + + protected async Task Validate( + string envelope, + string issuer = Issuer, + string audience = Audience) => + await new JsonWebTokenHandler().ValidateTokenAsync(envelope, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = ValidationKey, + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + }); + + protected static JsonWebToken Read(string envelope) => new JsonWebTokenHandler().ReadJsonWebToken(envelope); + + protected static string Claim(JsonWebToken envelope, string type) => envelope.Claims.Single(_ => _.Type == type).Value; + + protected static string Digest(byte[] body) => Base64UrlEncoder.Encode(SHA256.HashData(body)); + + static string CreatePrivateKeyPem() + { + using var rsa = RSA.Create(2048); + return rsa.ExportPkcs8PrivateKeyPem(); + } + + static RsaSecurityKey CreateValidationKey(string privateKeyPem) + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(privateKeyPem); + return new RsaSecurityKey(rsa.ExportParameters(false)) { KeyId = KeyId }; + } + + sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_inspecting_public_constructors.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_inspecting_public_constructors.cs index 2a5e46d..e0fb0d5 100644 --- a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_inspecting_public_constructors.cs +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_inspecting_public_constructors.cs @@ -12,16 +12,19 @@ public class when_inspecting_public_constructors : Specification { bool _hasReleasedConstructor; bool _hasResolverAwareConstructor; + bool _hasSignerAwareConstructor; void Because() { var constructors = typeof(SignInNotifier).GetConstructors(); _hasReleasedConstructor = constructors.Any(_ => HasParameters(_, ReleasedParameterTypes)); _hasResolverAwareConstructor = constructors.Any(_ => HasParameters(_, ResolverAwareParameterTypes)); + _hasSignerAwareConstructor = constructors.Any(_ => HasParameters(_, SignerAwareParameterTypes)); } [Fact] void should_keep_the_released_four_argument_constructor() => _hasReleasedConstructor.ShouldBeTrue(); [Fact] void should_expose_the_resolver_aware_constructor() => _hasResolverAwareConstructor.ShouldBeTrue(); + [Fact] void should_expose_the_signer_aware_constructor() => _hasSignerAwareConstructor.ShouldBeTrue(); static Type[] ReleasedParameterTypes => [ @@ -37,6 +40,12 @@ void Because() typeof(ICanonicalIdentityResolver) ]; + static Type[] SignerAwareParameterTypes => + [ + .. ResolverAwareParameterTypes, + typeof(ISignInNotificationSigner) + ]; + static bool HasParameters(System.Reflection.ConstructorInfo constructor, Type[] expected) => constructor.GetParameters().Select(_ => _.ParameterType).SequenceEqual(expected); } diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_no_signer_was_supplied.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_no_signer_was_supplied.cs new file mode 100644 index 0000000..0d1efc2 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_no_signer_was_supplied.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotifier.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotifier.when_signing_a_sign_in_notification; + +/// +/// The released constructors are still available for direct construction, and they carry no signer. A caller +/// that builds the notifier that way while the deployment asks for signed notifications gets no notification +/// rather than an unauthenticated one. +/// +public class and_no_signer_was_supplied : a_signed_sign_in_notifier +{ + protected override bool SignerIsAvailable => false; + + SignInNotificationResult _result; + + async Task Because() => _result = await _notifier.Notify(_httpContext, _principal); + + [Fact] void should_report_the_notification_as_failed() => _result.ShouldEqual(SignInNotificationResult.Failed); + [Fact] void should_not_post_anything_at_all() => _handler.LastRequest.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_body_is_altered_in_flight.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_body_is_altered_in_flight.cs new file mode 100644 index 0000000..7ebf6eb --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_body_is_altered_in_flight.cs @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using Cratis.AuthProxy.SignIns.for_SignInNotifier.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotifier.when_signing_a_sign_in_notification; + +/// +/// The attack the body binding exists to stop: something between AuthProxy and the application keeps the +/// envelope and swaps the subject in the body. The digest is taken from the bytes the transport received, so +/// the altered body no longer matches while the untouched one still does. +/// +public class and_the_body_is_altered_in_flight : a_signed_sign_in_notifier +{ + string _boundDigest; + string _digestOfWhatWasSent; + string _digestOfTheAlteredBody; + + async Task Because() + { + await _notifier.Notify(_httpContext, _principal); + _boundDigest = Claim(Read(_handler.LastRequestAuthorization!.Parameter!), SignInAttestationClaims.BodyHash); + + var sent = _handler.LastRequestBytes.ToArray(); + _digestOfWhatWasSent = Digest(sent); + _digestOfTheAlteredBody = Digest(Encoding.UTF8.GetBytes( + Encoding.UTF8.GetString(sent).Replace("subject-123", "subject-124", StringComparison.Ordinal))); + } + + [Fact] void should_match_the_body_that_was_actually_sent() => _boundDigest.ShouldEqual(_digestOfWhatWasSent); + [Fact] void should_not_match_the_altered_body() => (_boundDigest == _digestOfTheAlteredBody).ShouldBeFalse(); + [Fact] void should_have_altered_something_that_was_really_there() => (_digestOfTheAlteredBody == _digestOfWhatWasSent).ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_body_is_compared_to_the_released_notification.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_body_is_compared_to_the_released_notification.cs new file mode 100644 index 0000000..0986847 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_body_is_compared_to_the_released_notification.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using Cratis.AuthProxy.SignIns.for_SignInNotifier.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotifier.when_signing_a_sign_in_notification; + +/// +/// Signing is additive: the envelope rides in a header and the JSON the application parses is unchanged. +/// The comparison is against the released four-argument notifier posting the same sign-in, so an application +/// that upgrades and enables signing never has to change how it reads the body. +/// +public class and_the_body_is_compared_to_the_released_notification : a_signed_sign_in_notifier +{ + byte[] _signedBytes; + byte[] _releasedBytes; + + async Task Because() + { + await _notifier.Notify(_httpContext, _principal); + _signedBytes = _handler.LastRequestBytes.ToArray(); + _releasedBytes = (await NotifyThroughTheReleasedNotifier()).LastRequestBytes.ToArray(); + } + + [Fact] void should_send_the_same_json() => Encoding.UTF8.GetString(_signedBytes).ShouldEqual(Encoding.UTF8.GetString(_releasedBytes)); + [Fact] void should_send_the_same_number_of_bytes() => _signedBytes.Length.ShouldEqual(_releasedBytes.Length); + [Fact] void should_not_be_comparing_two_empty_bodies() => _releasedBytes.ShouldNotBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_envelope_cannot_be_signed.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_envelope_cannot_be_signed.cs new file mode 100644 index 0000000..2f4b871 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_envelope_cannot_be_signed.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotifier.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotifier.when_signing_a_sign_in_notification; + +/// +/// Once a deployment has asked for signed notifications, an unsigned one is never an acceptable fallback. +/// Unusable key material must cost the notification, not its authentication — otherwise a deployer who +/// mounted a secret wrong would silently be back on the unauthenticated back-channel this exists to close. +/// +public class and_the_envelope_cannot_be_signed : a_signed_sign_in_notifier +{ + protected override string ConfiguredSigningKeyPem => "-----BEGIN PRIVATE KEY-----\nnot-a-key\n-----END PRIVATE KEY-----"; + + SignInNotificationResult _result; + + async Task Because() => _result = await _notifier.Notify(_httpContext, _principal); + + [Fact] void should_report_the_notification_as_failed() => _result.ShouldEqual(SignInNotificationResult.Failed); + [Fact] void should_not_post_anything_at_all() => _handler.LastRequest.ShouldBeNull(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_envelope_is_verified.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_envelope_is_verified.cs new file mode 100644 index 0000000..7130dac --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_envelope_is_verified.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotifier.given; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotifier.when_signing_a_sign_in_notification; + +/// +/// The end-to-end pin: every binding is checked against what the transport actually received, not against +/// what the notifier intended to send. The digest in particular is compared to the bytes the handler read off +/// the request, so an envelope signed over anything other than the posted body fails here. +/// +public class and_the_envelope_is_verified : a_signed_sign_in_notifier +{ + SignInNotificationResult _result; + JsonWebToken _envelope; + TokenValidationResult _validation; + + async Task Because() + { + _result = await _notifier.Notify(_httpContext, _principal); + _envelope = Read(_handler.LastRequestAuthorization!.Parameter!); + _validation = await Validate(_handler.LastRequestAuthorization!.Parameter!); + } + + [Fact] void should_notify() => _result.ShouldEqual(SignInNotificationResult.Notified); + [Fact] void should_present_the_envelope_as_a_bearer_credential() => _handler.LastRequestAuthorization!.Scheme.ShouldEqual("Bearer"); + [Fact] void should_verify_against_the_pinned_public_key() => _validation.IsValid.ShouldBeTrue(); + [Fact] void should_name_the_signing_key() => _envelope.Kid.ShouldEqual(KeyId); + [Fact] void should_bind_provenance_to_the_configured_issuer() => _envelope.Issuer.ShouldEqual(Issuer); + [Fact] void should_bind_the_audience_to_the_configured_application() => _envelope.Audiences.ShouldContain(Audience); + [Fact] void should_bind_the_method_the_request_was_sent_with() => Claim(_envelope, SignInAttestationClaims.HttpMethod).ShouldEqual(_handler.LastRequest!.Method.Method); + [Fact] void should_bind_the_target_the_request_was_sent_to() => Claim(_envelope, SignInAttestationClaims.HttpUri).ShouldEqual(NotifyUrl); + [Fact] void should_bind_the_digest_of_the_bytes_that_crossed_the_wire() => Claim(_envelope, SignInAttestationClaims.BodyHash).ShouldEqual(Digest(_handler.LastRequestBytes.ToArray())); + [Fact] void should_bind_the_time_it_was_issued() => _envelope.ValidFrom.ShouldEqual(_now.UtcDateTime); + [Fact] void should_bind_the_time_it_expires() => _envelope.ValidTo.ShouldEqual(_now.AddSeconds(60).UtcDateTime); + [Fact] void should_bind_a_replay_identifier() => _envelope.Id.ShouldNotBeEmpty(); + [Fact] void should_have_posted_a_body_to_bind() => _handler.LastRequestBytes.Length.ShouldBeGreaterThan(0); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_target_is_configured_elsewhere.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_target_is_configured_elsewhere.cs new file mode 100644 index 0000000..45af8ed --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_a_sign_in_notification/and_the_target_is_configured_elsewhere.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotifier.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotifier.when_signing_a_sign_in_notification; + +/// +/// The route binding follows the request rather than a constant: pointing the notification at a different +/// application moves the bound target with it, so an envelope minted for one receiver cannot be replayed at +/// another. +/// +public class and_the_target_is_configured_elsewhere : a_signed_sign_in_notifier +{ + const string OtherNotifyUrl = "https://lobby.example.com/internal/sign-ins"; + + protected override string ConfiguredNotifyUrl => OtherNotifyUrl; + + string _boundTarget; + + async Task Because() + { + await _notifier.Notify(_httpContext, _principal); + _boundTarget = Claim(Read(_handler.LastRequestAuthorization!.Parameter!), SignInAttestationClaims.HttpUri); + } + + [Fact] void should_post_to_the_reconfigured_target() => _handler.LastRequest!.RequestUri!.ToString().ShouldEqual(OtherNotifyUrl); + [Fact] void should_bind_the_reconfigured_target() => _boundTarget.ShouldEqual(OtherNotifyUrl); + [Fact] void should_no_longer_bind_the_original_target() => (_boundTarget == NotifyUrl).ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_is_not_configured.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_is_not_configured.cs new file mode 100644 index 0000000..b318dbf --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotifier/when_signing_is_not_configured.cs @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; +using Cratis.AuthProxy.SignIns.for_SignInNotifier.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotifier; + +/// +/// The compatibility contract. The signer is wired exactly as the host wires it, but no +/// SignIn:Attestation section exists — so the request that leaves must be indistinguishable from the +/// one the released four-argument notifier sends: the same body bytes, and no Authorization header. +/// +public class when_signing_is_not_configured : a_signed_sign_in_notifier +{ + protected override C.SignInAttestation? CreateAttestation() => null; + + SignInNotificationResult _result; + byte[] _bytes; + byte[] _releasedBytes; + + async Task Because() + { + _result = await _notifier.Notify(_httpContext, _principal); + _bytes = _handler.LastRequestBytes.ToArray(); + _releasedBytes = (await NotifyThroughTheReleasedNotifier()).LastRequestBytes.ToArray(); + } + + [Fact] void should_notify() => _result.ShouldEqual(SignInNotificationResult.Notified); + [Fact] void should_not_authenticate_the_request() => _handler.LastRequestAuthorization.ShouldBeNull(); + [Fact] void should_send_the_released_json() => Encoding.UTF8.GetString(_bytes).ShouldEqual(Encoding.UTF8.GetString(_releasedBytes)); + [Fact] void should_send_the_released_number_of_bytes() => _bytes.Length.ShouldEqual(_releasedBytes.Length); + [Fact] void should_not_be_comparing_two_empty_bodies() => _releasedBytes.ShouldNotBeEmpty(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInsServiceCollectionExtensions/when_adding_sign_ins.cs b/Source/AuthProxy.Specs/SignIns/for_SignInsServiceCollectionExtensions/when_adding_sign_ins.cs index eaf5b8a..9026317 100644 --- a/Source/AuthProxy.Specs/SignIns/for_SignInsServiceCollectionExtensions/when_adding_sign_ins.cs +++ b/Source/AuthProxy.Specs/SignIns/for_SignInsServiceCollectionExtensions/when_adding_sign_ins.cs @@ -26,4 +26,7 @@ [Fact] void should_register_the_sign_in_notifier() => [Fact] void should_register_the_client_location_resolver() => _serviceProvider.GetRequiredService().ShouldBeOfExactType(); + + [Fact] void should_register_the_notification_signer() => + _serviceProvider.GetRequiredService().ShouldBeOfExactType(); } diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInsServiceCollectionExtensions/when_notifying_through_the_registered_services.cs b/Source/AuthProxy.Specs/SignIns/for_SignInsServiceCollectionExtensions/when_notifying_through_the_registered_services.cs new file mode 100644 index 0000000..3945d3d --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInsServiceCollectionExtensions/when_notifying_through_the_registered_services.cs @@ -0,0 +1,77 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using System.Security.Cryptography; +using Cratis.AuthProxy.Authentication; +using Cratis.AuthProxy.Ingress; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.SignIns.for_SignInsServiceCollectionExtensions; + +/// +/// Registration is only correct if the container hands the notifier every collaborator it needs. The notifier +/// carries overloaded constructors for its optional collaborators, so convention-based selection silently +/// drops all of them the moment one is unregistered — and a notifier without its signer refuses every +/// notification a signing deployment asks for. Resolving it through the real registrations and posting a real +/// notification is what proves the wiring; a spec on the constructor list alone would not. +/// +public class when_notifying_through_the_registered_services : Specification +{ + const string NotifyUrl = "https://studio.example.com/api/internal/sign-ins"; + const string KeyId = "sign-in-2026-08"; + + RecordingHttpMessageHandler _handler; + ICanonicalIdentityResolver _canonicalIdentityResolver; + SignInNotificationResult _result; + + async Task Establish() + { + _handler = new RecordingHttpMessageHandler(HttpStatusCode.OK); + using var rsa = RSA.Create(2048); + var privateKeyPem = rsa.ExportPkcs8PrivateKeyPem(); + + var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "subject-123")], "github")); + _canonicalIdentityResolver = Substitute.For(); + _canonicalIdentityResolver + .Resolve(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(CanonicalIdentityResolution.SanitizedLegacy(principal)); + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddHttpClient(string.Empty).ConfigurePrimaryHttpMessageHandler(() => _handler); + builder.Services.AddSingleton(_canonicalIdentityResolver); + builder.Services.Configure(options => + { + options.SignIn = new C.SignIn + { + NotifyUrl = NotifyUrl, + Attestation = new C.SignInAttestation + { + Issuer = "https://auth.example.com", + Audience = "ada", + ActiveKeyId = KeyId, + Lifetime = TimeSpan.FromSeconds(60), + SigningKeys = [new C.SignInAttestationSigningKey { KeyId = KeyId, PrivateKeyPem = privateKeyPem }], + }, + }; + }); + builder.AddSignIns(); + + await using var serviceProvider = builder.Services.BuildServiceProvider(); + var notifier = serviceProvider.GetRequiredService(); + + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse("198.51.100.5"); + context.MarkTrustedProxyPeer(true); + + _result = await notifier.Notify(context, principal); + } + + [Fact] void should_notify() => _result.ShouldEqual(SignInNotificationResult.Notified); + [Fact] void should_authenticate_the_notification() => _handler.LastRequestAuthorization!.Scheme.ShouldEqual("Bearer"); + [Fact] void should_carry_an_envelope() => _handler.LastRequestAuthorization!.Parameter!.ShouldNotBeEmpty(); + + [Fact] void should_consult_the_registered_canonical_identity_resolver() => + _canonicalIdentityResolver.Received(1).Resolve(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); +} diff --git a/Source/AuthProxy/Configuration/SignIn.cs b/Source/AuthProxy/Configuration/SignIn.cs index 47b14fb..6a9a061 100644 --- a/Source/AuthProxy/Configuration/SignIn.cs +++ b/Source/AuthProxy/Configuration/SignIn.cs @@ -24,4 +24,12 @@ public class SignIn /// to it. Leave empty to disable sign-in notifications. /// public string NotifyUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the signed-envelope configuration for sign-in notifications. + /// Set this section to have AuthProxy authenticate every notification with a short-lived RS256 JWS bound + /// to the exact request it accompanies. + /// Leave it unset — the default — and notifications are posted unsigned, exactly as they always have been. + /// + public SignInAttestation? Attestation { get; set; } } diff --git a/Source/AuthProxy/Configuration/SignInAttestation.cs b/Source/AuthProxy/Configuration/SignInAttestation.cs new file mode 100644 index 0000000..c255cdd --- /dev/null +++ b/Source/AuthProxy/Configuration/SignInAttestation.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Configures the signed envelope AuthProxy sends over every sign-in notification. +/// +/// +/// +/// Leave this section unset — the default — and sign-in notifications are posted exactly as they always have +/// been: an unsigned JSON body with no Authorization header. Nothing about an existing deployment +/// changes on upgrade. +/// +/// +/// Set it and AuthProxy signs a short-lived RS256 JWS over each notification and sends it as +/// Authorization: Bearer. The envelope binds six facts: provenance (iss plus the kid +/// header), audience (aud), route (htm and htu, per RFC 9449), body (body_hash, +/// over the exact bytes posted), time (iat, nbf, exp) and replay (a random jti). +/// Once configured, AuthProxy never falls back to an unsigned notification: if the envelope cannot be signed, +/// nothing is posted. +/// +/// +/// AuthProxy publishes no JWKS document, so the receiving application pins the matching public key by its own +/// configuration and selects it by the required kid header — the same way the invitation authority +/// consumes . +/// +/// +public class SignInAttestation +{ + /// + /// Gets or sets the issuer written to every sign-in notification envelope. + /// + public string Issuer { get; set; } = string.Empty; + + /// + /// Gets or sets the audience expected by the application receiving the notification. + /// + public string Audience { get; set; } = string.Empty; + + /// + /// Gets or sets the identifier of the signing key used for new envelopes. + /// + public string ActiveKeyId { get; set; } = string.Empty; + + /// + /// Gets or sets the signing keys available to AuthProxy. + /// + /// + /// Keep the previous key during a rotation until every envelope it signed has expired. The receiving + /// application pins the corresponding public keys and selects one by the required JWS kid header. + /// + public IList SigningKeys { get; set; } = []; + + /// + /// Gets or sets the lifetime of a signed envelope. The default and maximum are 60 seconds. + /// + public TimeSpan Lifetime { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/Source/AuthProxy/Configuration/SignInAttestationSigningKey.cs b/Source/AuthProxy/Configuration/SignInAttestationSigningKey.cs new file mode 100644 index 0000000..5cbf025 --- /dev/null +++ b/Source/AuthProxy/Configuration/SignInAttestationSigningKey.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Represents one RSA key available for signing sign-in notifications. +/// +public class SignInAttestationSigningKey +{ + /// + /// Gets or sets the key identifier written to the JWS kid header. + /// + public string KeyId { get; set; } = string.Empty; + + /// + /// Gets or sets the PEM-encoded RSA private key. + /// + /// + /// Supply this value through a secret provider. AuthProxy never returns or logs it. Publish only the matching + /// public key to the application that receives sign-in notifications. + /// + public string PrivateKeyPem { get; set; } = string.Empty; +} diff --git a/Source/AuthProxy/SignIns/ISignInNotificationSigner.cs b/Source/AuthProxy/SignIns/ISignInNotificationSigner.cs new file mode 100644 index 0000000..da1ff70 --- /dev/null +++ b/Source/AuthProxy/SignIns/ISignInNotificationSigner.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.SignIns; + +/// +/// Defines the system that signs the envelope authenticating a sign-in notification to the application. +/// +public interface ISignInNotificationSigner +{ + /// + /// Gets a value indicating whether sign-in notifications are configured to be signed. + /// + /// + /// When this is a notification is posted exactly as it always has been — unsigned, + /// with no Authorization header. When it is an unsigned notification is never + /// an acceptable outcome: a caller that cannot obtain an envelope must refuse to post at all. + /// + bool IsEnabled { get; } + + /// + /// Tries to issue the signed envelope binding one notification request. + /// + /// The HTTP method of the request the envelope accompanies. + /// The absolute target URI of the request the envelope accompanies. + /// The exact request body bytes the envelope accompanies. + /// The compact signed JWS when successful; otherwise an empty string. + /// when the envelope was issued; otherwise . + bool TryIssue(HttpMethod method, Uri target, byte[] body, out string attestation); +} diff --git a/Source/AuthProxy/SignIns/SignInAttestationClaims.cs b/Source/AuthProxy/SignIns/SignInAttestationClaims.cs new file mode 100644 index 0000000..d42a5b7 --- /dev/null +++ b/Source/AuthProxy/SignIns/SignInAttestationClaims.cs @@ -0,0 +1,42 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.SignIns; + +/// +/// Defines the claims AuthProxy writes to the signed envelope over a sign-in notification. +/// +/// +/// The envelope is a profile of RFC 9449 (DPoP) rather than a scheme of its own: and +/// are the RFC 9449 route claims with the RFC 9449 semantics, and the standard +/// iss, aud, iat, nbf, exp and jti claims carry provenance, audience, +/// time and replay resistance. is the one AuthProxy extension — RFC 9449 has no body +/// digest — and uses the identical construction to its ath claim. +/// +public static class SignInAttestationClaims +{ + /// + /// The HTTP method of the request the envelope accompanies, per RFC 9449. + /// + public const string HttpMethod = "htm"; + + /// + /// The HTTP target URI of the request the envelope accompanies, per RFC 9449 — without query and fragment. + /// + public const string HttpUri = "htu"; + + /// + /// The base64url-encoded SHA-256 digest of the exact request body bytes the envelope accompanies. + /// + public const string BodyHash = "body_hash"; + + /// + /// The claim that separates this envelope from every other assertion AuthProxy signs with the same keys. + /// + public const string Purpose = "purpose"; + + /// + /// The purpose value for a sign-in notification envelope. + /// + public const string NotificationPurpose = "sign-in-notification"; +} diff --git a/Source/AuthProxy/SignIns/SignInAttestationConfigurationValidator.cs b/Source/AuthProxy/SignIns/SignInAttestationConfigurationValidator.cs new file mode 100644 index 0000000..9242a2d --- /dev/null +++ b/Source/AuthProxy/SignIns/SignInAttestationConfigurationValidator.cs @@ -0,0 +1,73 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Attestations; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.SignIns; + +/// +/// Validates the cryptographic and endpoint configuration for signed sign-in notifications. +/// +/// +/// A signing deployment fails closed at run time — an unusable key means no notification is posted at all — so +/// the failure a deployer must never discover from missing sign-in records is caught here, at startup. +/// +sealed class SignInAttestationConfigurationValidator : IValidateOptions +{ + /// + /// Validates one AuthProxy configuration instance. + /// + /// The options instance name. + /// The configuration to validate. + /// All configuration failures, or a successful validation result. + public ValidateOptionsResult Validate(string? name, C.AuthProxy options) + { + var signIn = options.SignIn; + var attestation = signIn?.Attestation; + if (attestation is null) + { + return ValidateOptionsResult.Success; + } + + var failures = new List(); + AttestationConfigurationValidation.ValidateAbsoluteEndpoint(signIn!.NotifyUrl, "SignIn.NotifyUrl", failures); + AttestationConfigurationValidation.ValidateBoundedValue(attestation.Issuer, "SignIn.Attestation.Issuer", failures); + AttestationConfigurationValidation.ValidateBoundedValue(attestation.Audience, "SignIn.Attestation.Audience", failures); + AttestationConfigurationValidation.ValidateBoundedValue(attestation.ActiveKeyId, "SignIn.Attestation.ActiveKeyId", failures); + + if (attestation.Lifetime < TimeSpan.FromSeconds(10) || attestation.Lifetime > TimeSpan.FromSeconds(60)) + { + failures.Add("SignIn.Attestation.Lifetime must be between 10 and 60 seconds."); + } + + var duplicateKeyIds = attestation.SigningKeys + .GroupBy(_ => _.KeyId, StringComparer.Ordinal) + .Where(_ => _.Count() > 1) + .Select(_ => _.Key) + .ToArray(); + if (duplicateKeyIds.Length > 0) + { + failures.Add("SignIn.Attestation.SigningKeys must use unique, case-sensitive key identifiers."); + } + + foreach (var key in attestation.SigningKeys) + { + AttestationConfigurationValidation.ValidateSigningKey( + key.KeyId, + key.PrivateKeyPem, + "SignIn.Attestation.SigningKeys", + failures); + } + + if (attestation.SigningKeys.Count(_ => string.Equals(_.KeyId, attestation.ActiveKeyId, StringComparison.Ordinal)) != 1) + { + failures.Add("SignIn.Attestation.ActiveKeyId must identify exactly one configured signing key."); + } + + return failures.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(failures); + } +} diff --git a/Source/AuthProxy/SignIns/SignInNotificationSigner.cs b/Source/AuthProxy/SignIns/SignInNotificationSigner.cs new file mode 100644 index 0000000..279b6b2 --- /dev/null +++ b/Source/AuthProxy/SignIns/SignInNotificationSigner.cs @@ -0,0 +1,68 @@ +// 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 Cratis.AuthProxy.Attestations; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.SignIns; + +/// +/// Issues the short-lived RS256 envelope that authenticates one sign-in notification to the application. +/// +/// The current AuthProxy configuration. +/// The source of the current time. +/// +/// The envelope binds six facts about the notification it accompanies, so that reaching the application's +/// private endpoint is no longer enough to choose which user it records as having signed in: provenance +/// (iss plus the kid header selecting the key), audience (aud), route +/// ( and ), body +/// ( over the exact bytes posted), time (iat, nbf, +/// exp) and replay (a random jti). Provenance, audience, time and replay come from +/// — the one signing implementation, shared with invitation attestation — and +/// route and body are added here. +/// +public sealed class SignInNotificationSigner(IOptionsMonitor configuration, TimeProvider timeProvider) : ISignInNotificationSigner +{ + /// + public bool IsEnabled => configuration.CurrentValue.SignIn?.Attestation is not null; + + /// + public bool TryIssue(HttpMethod method, Uri target, byte[] body, out string attestation) + { + attestation = string.Empty; + var settings = configuration.CurrentValue.SignIn?.Attestation; + if (settings is null) + { + return false; + } + + var key = settings.SigningKeys.SingleOrDefault(_ => + string.Equals(_.KeyId, settings.ActiveKeyId, StringComparison.Ordinal)); + if (key is null) + { + return false; + } + + var claims = new Dictionary(StringComparer.Ordinal) + { + [SignInAttestationClaims.Purpose] = SignInAttestationClaims.NotificationPurpose, + [SignInAttestationClaims.HttpMethod] = method.Method, + [SignInAttestationClaims.HttpUri] = target.GetLeftPart(UriPartial.Path), + [SignInAttestationClaims.BodyHash] = Base64UrlEncoder.Encode(SHA256.HashData(body)), + }; + + return AttestationSigner.TryIssue( + new AttestationSigningContract( + settings.Issuer, + settings.Audience, + key.KeyId, + key.PrivateKeyPem, + settings.Lifetime), + timeProvider.GetUtcNow(), + claims, + out attestation); + } +} diff --git a/Source/AuthProxy/SignIns/SignInNotifier.cs b/Source/AuthProxy/SignIns/SignInNotifier.cs index 701429f..24befe8 100644 --- a/Source/AuthProxy/SignIns/SignInNotifier.cs +++ b/Source/AuthProxy/SignIns/SignInNotifier.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Net.Http.Headers; using System.Security.Claims; using Cratis.AuthProxy.Authentication; using Microsoft.Extensions.Options; @@ -21,12 +22,14 @@ namespace Cratis.AuthProxy.SignIns; /// The HTTP client factory used for the notification call. /// The logger. /// The shared canonical identity resolver, or for legacy-compatible direct construction. +/// The sign-in notification envelope signer, or for legacy-compatible direct construction. public class SignInNotifier( IOptionsMonitor config, IClientLocationResolver locationResolver, IHttpClientFactory httpClientFactory, ILogger logger, - ICanonicalIdentityResolver? canonicalIdentityResolver) : ISignInNotifier + ICanonicalIdentityResolver? canonicalIdentityResolver, + ISignInNotificationSigner? signer) : ISignInNotifier { /// /// Initializes a legacy-compatible notifier that sanitizes reserved canonical claims without resolving canonical providers. @@ -40,7 +43,25 @@ public SignInNotifier( IClientLocationResolver locationResolver, IHttpClientFactory httpClientFactory, ILogger logger) - : this(config, locationResolver, httpClientFactory, logger, null) + : this(config, locationResolver, httpClientFactory, logger, null, null) + { + } + + /// + /// Initializes a resolver-aware notifier that posts notifications unsigned. + /// + /// The auth proxy configuration monitor. + /// The resolver for the request's approximate location. + /// The HTTP client factory used for the notification call. + /// The logger. + /// The shared canonical identity resolver. + public SignInNotifier( + IOptionsMonitor config, + IClientLocationResolver locationResolver, + IHttpClientFactory httpClientFactory, + ILogger logger, + ICanonicalIdentityResolver? canonicalIdentityResolver) + : this(config, locationResolver, httpClientFactory, logger, canonicalIdentityResolver, null) { } @@ -116,6 +137,23 @@ public async Task Notify(HttpContext context, ClaimsPr Content = content, }; + // Signing is opt-in, and the gate is the presence of the configuration section alone. Without it the + // request leaves exactly as it always has: the same body, and no header of any kind added here. With + // it, the envelope is bound to the serialized bytes this request will actually carry — which is why + // the digest is taken from the content, not from the object it was built out of — and an envelope + // that cannot be signed means nothing is posted, never a silent downgrade to an unsigned call. + if (config.CurrentValue.SignIn?.Attestation is not null) + { + var body = await content.ReadAsByteArrayAsync(); + if (signer is null || !signer.TryIssue(request.Method, request.RequestUri!, body, out var envelope)) + { + logger.SignInNotificationCouldNotBeSigned(); + return SignInNotificationResult.Failed; + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", envelope); + } + HttpResponseMessage response; try { diff --git a/Source/AuthProxy/SignIns/SignInNotifierLogging.cs b/Source/AuthProxy/SignIns/SignInNotifierLogging.cs index 5102dc9..cdaab08 100644 --- a/Source/AuthProxy/SignIns/SignInNotifierLogging.cs +++ b/Source/AuthProxy/SignIns/SignInNotifierLogging.cs @@ -11,6 +11,9 @@ internal static partial class SignInNotifierLogging [LoggerMessage(LogLevel.Error, "Failed to call sign-in notification endpoint at {Url}")] internal static partial void FailedToCallSignInNotifyEndpoint(this ILogger logger, Exception exception, string url); + [LoggerMessage(LogLevel.Error, "Sign-in notification could not be signed; nothing was posted")] + internal static partial void SignInNotificationCouldNotBeSigned(this ILogger logger); + [LoggerMessage(LogLevel.Warning, "Sign-in notification endpoint returned {StatusCode}")] internal static partial void SignInNotifyEndpointFailed(this ILogger logger, int statusCode); diff --git a/Source/AuthProxy/SignIns/SignInsServiceCollectionExtensions.cs b/Source/AuthProxy/SignIns/SignInsServiceCollectionExtensions.cs index fdd48ab..1127f06 100644 --- a/Source/AuthProxy/SignIns/SignInsServiceCollectionExtensions.cs +++ b/Source/AuthProxy/SignIns/SignInsServiceCollectionExtensions.cs @@ -1,6 +1,10 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.AuthProxy.Authentication; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + namespace Cratis.AuthProxy.SignIns; /// @@ -10,14 +14,30 @@ public static class SignInsServiceCollectionExtensions { /// /// Registers the and its used to - /// notify the application of completed sign-ins. + /// notify the application of completed sign-ins, together with the + /// that authenticates each notification when signing is configured. /// /// The to configure. /// The same for chaining. public static WebApplicationBuilder AddSignIns(this WebApplicationBuilder builder) { builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.TryAddSingleton(TimeProvider.System); + builder.Services.AddSingleton(); + builder.Services.AddSingleton, SignInAttestationConfigurationValidator>(); + + // Constructed explicitly rather than by convention. The notifier's optional collaborators are + // constructor overloads, so convention-based selection quietly falls back to the released + // four-argument constructor whenever any one of them is unregistered — and a notifier without its + // signer refuses every notification a signing deployment asks for. Naming them makes the signer a + // hard requirement and the canonical resolver a genuinely optional one. + builder.Services.AddSingleton(sp => new SignInNotifier( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetService(), + sp.GetRequiredService())); return builder; } From a0dbe20d7c9d787e85e72ac322a57161bfe17529 Mon Sep 17 00:00:00 2001 From: woksin Date: Tue, 11 Aug 2026 19:19:44 +0200 Subject: [PATCH 3/3] Pin the sign-in envelope's wire contract and refuse a weak key Every constant in the published envelope -- the claim names and the value that separates a sign-in notification from an invitation attestation -- appeared in exactly one file, and every assertion read that constant and compared it against itself. Renaming the separating value to the invitation's left the whole suite green while every deployed verifier broke and the separation the documentation promises collapsed. The contract is now pinned as literals, and asserted to differ from both invitation purposes. The duplicate-key guard was live but unpinned for the case that matters: the only spec duplicated the active key, so it died to the active-key rule rather than to uniqueness, and the guard could have been deleted unnoticed. Rotation to a duplicated identifier would then have thrown out of the signer and broken the sign-in it was only supposed to record -- so the lookup now takes the first match rather than demanding a single one, and fails closed if configuration ever lets one through. An undersized RSA key signed happily, because the identity library does not refuse one and the signer is reachable without the configuration validator. A signing endpoint carrying a query was accepted while the route binding deliberately excludes the query, leaving a replay window to a different query string. The signing contract's generated ToString printed the private key, and a key identifier of null crashed the configuration check instead of reporting it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UWugkmN6NmoemKeTvSKNDg --- Documentation/configuration/sign-in.md | 6 ++- .../when_signing_with_an_undersized_key.cs | 39 +++++++++++++++++++ .../when_rendering_it_as_text.cs | 29 ++++++++++++++ .../and_the_duplicate_is_the_active_key.cs} | 4 +- ...nd_two_signing_keys_share_an_identifier.cs | 26 +++++++++++++ ...the_active_key_identifier_is_duplicated.cs | 30 ++++++++++++++ .../when_publishing_the_wire_contract.cs | 30 ++++++++++++++ .../and_a_signing_key_has_no_identifier.cs | 28 +++++++++++++ .../and_no_signing_key_is_configured.cs | 26 +++++++++++++ .../and_the_notify_url_carries_a_query.cs | 27 +++++++++++++ ...nd_two_signing_keys_share_an_identifier.cs | 26 +++++++++++++ ...the_active_key_identifier_is_duplicated.cs | 31 +++++++++++++++ ...he_verifier_uses_the_published_contract.cs | 1 + .../AttestationConfigurationValidation.cs | 7 ++-- .../Attestations/AttestationSigner.cs | 15 +++++++ .../AttestationSigningContract.cs | 14 ++++++- Source/AuthProxy/Configuration/SignIn.cs | 5 +++ .../Invites/InvitationAttestationIssuer.cs | 4 +- ...SignInAttestationConfigurationValidator.cs | 5 +++ .../SignIns/SignInNotificationSigner.cs | 4 +- 20 files changed, 347 insertions(+), 10 deletions(-) create mode 100644 Source/AuthProxy.Specs/Attestations/for_AttestationSigner/when_signing_with_an_undersized_key.cs create mode 100644 Source/AuthProxy.Specs/Attestations/for_AttestationSigningContract/when_rendering_it_as_text.cs rename Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/{when_signing_key_identifiers_are_duplicated.cs => when_signing_key_identifiers_are_duplicated/and_the_duplicate_is_the_active_key.cs} (78%) create mode 100644 Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated/and_two_signing_keys_share_an_identifier.cs create mode 100644 Source/AuthProxy.Specs/Invites/for_InvitationAttestationIssuer/when_the_active_key_identifier_is_duplicated.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationClaims/when_publishing_the_wire_contract.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_a_signing_key_has_no_identifier.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_no_signing_key_is_configured.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_carries_a_query.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_two_signing_keys_share_an_identifier.cs create mode 100644 Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_active_key_identifier_is_duplicated.cs diff --git a/Documentation/configuration/sign-in.md b/Documentation/configuration/sign-in.md index 2acf8ac..fe9bc41 100644 --- a/Documentation/configuration/sign-in.md +++ b/Documentation/configuration/sign-in.md @@ -159,7 +159,9 @@ so an invitation attestation can never be presented in its place. Two details a verifier must implement exactly: - **`htu` follows RFC 9449** — the target URI *without* query and fragment. Compare it against the - query-stripped request URI, not the raw target. + query-stripped request URI, not the raw target. Because the query is deliberately outside the binding, a + `NotifyUrl` that carries one would be signed without it — so AuthProxy refuses to start with a query on + `NotifyUrl` once `Attestation` is configured. Put anything the application needs in the body instead. - **`body_hash` is an AuthProxy extension** — RFC 9449 defines no body digest. It uses the identical construction to that specification's `ath` claim: unpadded base64url of the SHA-256 of the raw request body. @@ -208,7 +210,7 @@ Two details a verifier must implement exactly: | `Cratis:AuthProxy:SignIn:Attestation:Issuer` | written to `iss`; required, and required to match at the verifier | | `Cratis:AuthProxy:SignIn:Attestation:Audience` | written to `aud`; names the one application entitled to the notification | | `Cratis:AuthProxy:SignIn:Attestation:ActiveKeyId` | the key new envelopes are signed with; must name exactly one configured key | -| `Cratis:AuthProxy:SignIn:Attestation:SigningKeys` | the available keys, each a `KeyId` and a PEM-encoded RSA `PrivateKeyPem` of at least 2048 bits | +| `Cratis:AuthProxy:SignIn:Attestation:SigningKeys` | the available keys, each a `KeyId` and a PEM-encoded RSA `PrivateKeyPem` of at least 2048 bits; every `KeyId` must be unique | | `Cratis:AuthProxy:SignIn:Attestation:Lifetime` | the envelope lifetime; between 10 and 60 seconds, defaulting to 60 | Supply `PrivateKeyPem` through a secret provider. AuthProxy never returns or logs it — publish only the diff --git a/Source/AuthProxy.Specs/Attestations/for_AttestationSigner/when_signing_with_an_undersized_key.cs b/Source/AuthProxy.Specs/Attestations/for_AttestationSigner/when_signing_with_an_undersized_key.cs new file mode 100644 index 0000000..3a8f591 --- /dev/null +++ b/Source/AuthProxy.Specs/Attestations/for_AttestationSigner/when_signing_with_an_undersized_key.cs @@ -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 System.Security.Cryptography; + +namespace Cratis.AuthProxy.Attestations.for_AttestationSigner; + +/// +/// Microsoft.IdentityModel signs with a 1024-bit RSA key without complaint, so nothing about the resulting +/// assertion says it is weak — it verifies, and every binding it carries reads exactly as it should. The floor +/// therefore has to be enforced by the signer itself: this method is public, so the configuration validator is +/// not the only way into it. +/// +public class when_signing_with_an_undersized_key : Specification +{ + bool _undersized; + bool _conformant; + string _fromUndersizedKey; + string _fromConformantKey; + + void Because() + { + _undersized = AttestationSigner.TryIssue(Contract(1024), DateTimeOffset.UtcNow, Claims(), out _fromUndersizedKey); + _conformant = AttestationSigner.TryIssue(Contract(2048), DateTimeOffset.UtcNow, Claims(), out _fromConformantKey); + } + + [Fact] void should_refuse_to_sign_with_an_undersized_key() => _undersized.ShouldBeFalse(); + [Fact] void should_hand_back_nothing_it_refused_to_sign() => _fromUndersizedKey.ShouldBeEmpty(); + [Fact] void should_sign_with_a_conformant_key() => _conformant.ShouldBeTrue(); + [Fact] void should_hand_back_the_assertion_it_signed() => _fromConformantKey.ShouldNotBeEmpty(); + + static AttestationSigningContract Contract(int keySize) + { + using var rsa = RSA.Create(keySize); + return new("https://auth.example.com", "ada", "current", rsa.ExportPkcs8PrivateKeyPem(), TimeSpan.FromSeconds(60)); + } + + static Dictionary Claims() => new(StringComparer.Ordinal) { ["purpose"] = "specification" }; +} diff --git a/Source/AuthProxy.Specs/Attestations/for_AttestationSigningContract/when_rendering_it_as_text.cs b/Source/AuthProxy.Specs/Attestations/for_AttestationSigningContract/when_rendering_it_as_text.cs new file mode 100644 index 0000000..556dd4c --- /dev/null +++ b/Source/AuthProxy.Specs/Attestations/for_AttestationSigningContract/when_rendering_it_as_text.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Attestations.for_AttestationSigningContract; + +/// +/// The contract is a record, and a record's generated rendering prints every one of its properties — including +/// the signing key. One LogDebug("{Contract}", contract) added by anyone, at any point, would write the +/// private key to the log without a single line of code looking wrong. The rendering has to be safe by +/// construction rather than by everybody remembering. +/// +public class when_rendering_it_as_text : Specification +{ + const string Issuer = "https://auth.example.com"; + const string Audience = "ada"; + const string KeyId = "sign-in-2026-08"; + const string PrivateKeyPem = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQ\n-----END PRIVATE KEY-----"; + + readonly AttestationSigningContract _contract = new(Issuer, Audience, KeyId, PrivateKeyPem, TimeSpan.FromSeconds(60)); + string _text; + + void Because() => _text = _contract.ToString(); + + [Fact] void should_not_disclose_the_private_key() => _text.Contains(PrivateKeyPem, StringComparison.Ordinal).ShouldBeFalse(); + [Fact] void should_not_disclose_a_fragment_of_the_private_key() => _text.Contains("MIIEvQIBADANBgkqhkiG9w0BAQ", StringComparison.Ordinal).ShouldBeFalse(); + [Fact] void should_still_name_the_signing_key() => _text.Contains(KeyId, StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_still_name_the_issuer() => _text.Contains(Issuer, StringComparison.Ordinal).ShouldBeTrue(); + [Fact] void should_still_name_the_audience() => _text.Contains(Audience, StringComparison.Ordinal).ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated.cs b/Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated/and_the_duplicate_is_the_active_key.cs similarity index 78% rename from Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated.cs rename to Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated/and_the_duplicate_is_the_active_key.cs index a773acd..ba9eaf6 100644 --- a/Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated.cs +++ b/Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated/and_the_duplicate_is_the_active_key.cs @@ -3,9 +3,9 @@ using Cratis.AuthProxy.Invites.for_InvitationAttestationConfigurationValidator.given; -namespace Cratis.AuthProxy.Invites.for_InvitationAttestationConfigurationValidator; +namespace Cratis.AuthProxy.Invites.for_InvitationAttestationConfigurationValidator.when_signing_key_identifiers_are_duplicated; -public class when_signing_key_identifiers_are_duplicated : an_attestation_configuration +public class and_the_duplicate_is_the_active_key : an_attestation_configuration { ValidateOptionsResult _result; diff --git a/Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated/and_two_signing_keys_share_an_identifier.cs b/Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated/and_two_signing_keys_share_an_identifier.cs new file mode 100644 index 0000000..a08a405 --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InvitationAttestationConfigurationValidator/when_signing_key_identifiers_are_duplicated/and_two_signing_keys_share_an_identifier.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Invites.for_InvitationAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.Invites.for_InvitationAttestationConfigurationValidator.when_signing_key_identifiers_are_duplicated; + +/// +/// A duplicate on the active key is caught by the active-key rule, so it proves nothing about the uniqueness +/// rule. A duplicate on any other key is the case only the uniqueness rule can see — and the one that lies in +/// wait until a rotation makes that identifier active. +/// +public class and_two_signing_keys_share_an_identifier : an_attestation_configuration +{ + ValidateOptionsResult _shared; + ValidateOptionsResult _distinct; + + void Because() + { + _shared = Validate(Configuration(PrivateKey("current"), PrivateKey("previous"), PrivateKey("previous"))); + _distinct = Validate(Configuration(PrivateKey("current"), PrivateKey("previous"), PrivateKey("retired"))); + } + + [Fact] void should_reject_a_duplicate_on_a_key_that_is_not_active() => _shared.Succeeded.ShouldBeFalse(); + [Fact] void should_accept_the_same_rotation_with_distinct_identifiers() => _distinct.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/Invites/for_InvitationAttestationIssuer/when_the_active_key_identifier_is_duplicated.cs b/Source/AuthProxy.Specs/Invites/for_InvitationAttestationIssuer/when_the_active_key_identifier_is_duplicated.cs new file mode 100644 index 0000000..e16539a --- /dev/null +++ b/Source/AuthProxy.Specs/Invites/for_InvitationAttestationIssuer/when_the_active_key_identifier_is_duplicated.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Invites.for_InvitationAttestationIssuer.given; + +namespace Cratis.AuthProxy.Invites.for_InvitationAttestationIssuer; + +/// +/// Startup validation should never let this configuration through, but resolving the active key sits on the +/// request path — so a duplicate identifier that did get through has to degrade to a refusal to issue, not to +/// an exception thrown at whoever is holding the invitation link. +/// +public class when_the_active_key_identifier_is_duplicated : an_attestation_issuer +{ + bool _issued; + string _attestation; + Exception _error; + + void Establish() => + _configuration.Invite!.Attestation!.SigningKeys.Add(new C.InvitationAttestationSigningKey + { + KeyId = KeyId, + PrivateKeyPem = _configuration.Invite.Attestation.SigningKeys[0].PrivateKeyPem, + }); + + void Because() => _error = Catch.Exception(() => _issued = _issuer.TryIssueStage(_state, out _attestation)); + + [Fact] void should_not_throw_out_of_the_request() => _error.ShouldBeNull(); + [Fact] void should_still_issue_the_attestation() => _issued.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationClaims/when_publishing_the_wire_contract.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationClaims/when_publishing_the_wire_contract.cs new file mode 100644 index 0000000..0cbe3e7 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationClaims/when_publishing_the_wire_contract.cs @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Invites; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationClaims; + +/// +/// Pins the published wire contract to literals rather than to the constants that produce it. +/// +/// +/// Every other assertion in the suite reads a constant and compares it to the same constant, so renaming one +/// would leave all of them green while every deployed verifier broke — the claim names and the purpose value +/// are a contract with software AuthProxy does not build. These literals are what makes such a rename fail +/// here instead of in production. The separation assertions do the same for the collision the shape of +/// otherwise makes invisible: both protocols sign a purpose +/// claim with the same key material, so two purposes that ever converged would let one protocol's assertion be +/// replayed as the other's. +/// +public class when_publishing_the_wire_contract : Specification +{ + [Fact] void should_publish_the_method_claim_as_the_rfc_9449_htm() => SignInAttestationClaims.HttpMethod.ShouldEqual("htm"); + [Fact] void should_publish_the_target_claim_as_the_rfc_9449_htu() => SignInAttestationClaims.HttpUri.ShouldEqual("htu"); + [Fact] void should_publish_the_body_digest_claim_as_body_hash() => SignInAttestationClaims.BodyHash.ShouldEqual("body_hash"); + [Fact] void should_publish_the_separating_claim_as_purpose() => SignInAttestationClaims.Purpose.ShouldEqual("purpose"); + [Fact] void should_publish_the_notification_purpose_value() => SignInAttestationClaims.NotificationPurpose.ShouldEqual("sign-in-notification"); + [Fact] void should_separate_the_notification_from_invitation_staging() => SignInAttestationClaims.NotificationPurpose.ShouldNotEqual(InvitationAttestationClaims.StagePurpose); + [Fact] void should_separate_the_notification_from_invitation_completion() => SignInAttestationClaims.NotificationPurpose.ShouldNotEqual(InvitationAttestationClaims.CompletePurpose); + [Fact] void should_share_the_separating_claim_name_with_every_other_signed_protocol() => SignInAttestationClaims.Purpose.ShouldEqual(InvitationAttestationClaims.Purpose); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_a_signing_key_has_no_identifier.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_a_signing_key_has_no_identifier.cs new file mode 100644 index 0000000..295b028 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_a_signing_key_has_no_identifier.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// Configuration binding produces a null identifier for a key section that omits it, and startup validation is +/// the one place that must survive every shape a deployer can hand it. Reporting the failure is the whole +/// point of the validator; throwing out of it turns a fixable misconfiguration into a process that will not +/// start with no statement of what is wrong. +/// +public class and_a_signing_key_has_no_identifier : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _result; + Exception _error; + + void Because() + { + var key = PrivateKey("current"); + key.KeyId = null!; + _error = Catch.Exception(() => _result = Validate(Configuration(activeKeyId: "current", signingKeys: [key]))); + } + + [Fact] void should_report_the_failure_rather_than_throw() => _error.ShouldBeNull(); + [Fact] void should_reject_the_configuration() => _result.Succeeded.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_no_signing_key_is_configured.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_no_signing_key_is_configured.cs new file mode 100644 index 0000000..b527a64 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_no_signing_key_is_configured.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// Opting in to signing and then configuring no key at all is the one misconfiguration that looks completely +/// harmless: nothing is malformed, nothing is out of bounds, and at run time every sign-in simply stops being +/// recorded. It has to fail at startup, like every other unusable signing configuration. +/// +public class and_no_signing_key_is_configured : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _empty; + ValidateOptionsResult _oneKey; + + void Because() + { + _empty = Validate(Configuration(activeKeyId: "current", signingKeys: [])); + _oneKey = Validate(Configuration(activeKeyId: "current", signingKeys: PrivateKey("current"))); + } + + [Fact] void should_reject_a_signing_configuration_with_no_keys() => _empty.Succeeded.ShouldBeFalse(); + [Fact] void should_accept_the_same_configuration_once_the_key_is_supplied() => _oneKey.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_carries_a_query.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_carries_a_query.cs new file mode 100644 index 0000000..d3c303b --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_the_notify_url_carries_a_query.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// The route binding is the RFC 9449 htu, which is the target's path — the query is deliberately not +/// part of it. A notify URL that carries one therefore signs a route it does not fully name, and a captured +/// notification could be replayed against a different query while a conformant verifier still accepted it. +/// The only place that can be ruled out is where the endpoint is configured. +/// +public class and_the_notify_url_carries_a_query : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _withQuery; + ValidateOptionsResult _withoutQuery; + + void Because() + { + _withQuery = Validate(Configuration($"{NotifyUrl}?tenant=acme", signingKeys: PrivateKey("current"))); + _withoutQuery = Validate(Configuration(NotifyUrl, signingKeys: PrivateKey("current"))); + } + + [Fact] void should_reject_an_endpoint_carrying_a_query() => _withQuery.Succeeded.ShouldBeFalse(); + [Fact] void should_accept_the_same_endpoint_without_one() => _withoutQuery.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_two_signing_keys_share_an_identifier.cs b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_two_signing_keys_share_an_identifier.cs new file mode 100644 index 0000000..69d32ff --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInAttestationConfigurationValidator/when_validating_a_configuration/and_two_signing_keys_share_an_identifier.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInAttestationConfigurationValidator.when_validating_a_configuration; + +/// +/// A duplicate identifier on a key that is not the active one passes every other check today, and stays +/// harmless right up until the next rotation names it active — at which point the notification path resolves +/// two keys for one identifier. The active-key rule cannot see this, so the uniqueness rule has to. +/// +public class and_two_signing_keys_share_an_identifier : a_sign_in_attestation_configuration +{ + ValidateOptionsResult _shared; + ValidateOptionsResult _distinct; + + void Because() + { + _shared = Validate(Configuration(signingKeys: [PrivateKey("current"), PrivateKey("previous"), PrivateKey("previous")])); + _distinct = Validate(Configuration(signingKeys: [PrivateKey("current"), PrivateKey("previous"), PrivateKey("retired")])); + } + + [Fact] void should_reject_a_duplicate_on_a_key_that_is_not_active() => _shared.Succeeded.ShouldBeFalse(); + [Fact] void should_accept_the_same_rotation_with_distinct_identifiers() => _distinct.Succeeded.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_active_key_identifier_is_duplicated.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_active_key_identifier_is_duplicated.cs new file mode 100644 index 0000000..5f4e972 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_active_key_identifier_is_duplicated.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.given; + +namespace Cratis.AuthProxy.SignIns.for_SignInNotificationSigner.when_issuing_an_envelope; + +/// +/// Startup validation should never let this configuration through — but the signer is the last thing standing +/// between a bad configuration and the sign-in itself. An exception here escapes issuing, escapes notifying, +/// and breaks the sign-in AuthProxy was only supposed to record, so resolving the active key must not be able +/// to throw whatever the key list looks like. +/// +public class and_the_active_key_identifier_is_duplicated : a_sign_in_notification_signer +{ + bool _issued; + string _envelope; + Exception _error; + + void Establish() => + _configuration.SignIn!.Attestation!.SigningKeys.Add(new C.SignInAttestationSigningKey + { + KeyId = KeyId, + PrivateKeyPem = _configuration.SignIn.Attestation.SigningKeys[0].PrivateKeyPem, + }); + + void Because() => _error = Catch.Exception(() => _issued = _signer.TryIssue(HttpMethod.Post, Target, Body, out _envelope)); + + [Fact] void should_not_throw_out_of_the_sign_in() => _error.ShouldBeNull(); + [Fact] void should_still_issue_the_envelope() => _issued.ShouldBeTrue(); +} diff --git a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_uses_the_published_contract.cs b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_uses_the_published_contract.cs index 52235fd..39c61d5 100644 --- a/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_uses_the_published_contract.cs +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_uses_the_published_contract.cs @@ -29,6 +29,7 @@ async Task Because() [Fact] void should_report_signing_as_enabled() => _signer.IsEnabled.ShouldBeTrue(); [Fact] void should_verify_against_the_pinned_public_key() => _validation.IsValid.ShouldBeTrue(); [Fact] void should_name_the_signing_key() => _token.Kid.ShouldEqual(KeyId); + [Fact] void should_name_the_published_signing_algorithm() => _token.Alg.ShouldEqual("RS256"); [Fact] void should_bind_provenance_to_the_configured_issuer() => _token.Issuer.ShouldEqual(Issuer); [Fact] void should_bind_the_audience_to_the_configured_application() => _token.Audiences.ShouldContain(Audience); [Fact] void should_bind_the_request_method() => Claim(_token, SignInAttestationClaims.HttpMethod).ShouldEqual("POST"); diff --git a/Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs b/Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs index 5fccf21..4d8cea2 100644 --- a/Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs +++ b/Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs @@ -66,7 +66,8 @@ internal static void ValidateBoundedValue(string value, string path, List failures) { ValidateBoundedValue(keyId, $"{path}.KeyId", failures); - if (keyId.Length > 128 + if (string.IsNullOrEmpty(keyId) + || keyId.Length > 128 || keyId.Any(_ => !(char.IsAsciiLetterOrDigit(_) || _ is '.' or '_' or '-'))) { failures.Add($"{path}.KeyId must be a bounded ASCII identifier using letters, digits, periods, underscores, or hyphens."); @@ -82,9 +83,9 @@ internal static void ValidateSigningKey(string keyId, string privateKeyPem, stri using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); _ = rsa.ExportParameters(true); - if (rsa.KeySize < 2048) + if (rsa.KeySize < AttestationSigner.MinimumKeySize) { - failures.Add($"{path}.PrivateKeyPem must contain an RSA key of at least 2048 bits."); + failures.Add($"{path}.PrivateKeyPem must contain an RSA key of at least {AttestationSigner.MinimumKeySize} bits."); } } catch (Exception exception) when (exception is CryptographicException or ArgumentException) diff --git a/Source/AuthProxy/Attestations/AttestationSigner.cs b/Source/AuthProxy/Attestations/AttestationSigner.cs index 5c41f7f..0413fa4 100644 --- a/Source/AuthProxy/Attestations/AttestationSigner.cs +++ b/Source/AuthProxy/Attestations/AttestationSigner.cs @@ -19,6 +19,16 @@ namespace Cratis.AuthProxy.Attestations; /// public static class AttestationSigner { + /// + /// The smallest RSA key AuthProxy signs with. + /// + /// + /// Microsoft.IdentityModel signs happily with a 1024-bit key, and this method is public, so the floor is + /// enforced here as well as where key material is configured — the configuration validator is not the only + /// door into signing. + /// + public const int MinimumKeySize = 2048; + /// /// Creates a cryptographically random 256-bit opaque value. /// @@ -78,6 +88,11 @@ static bool TryCreateSigningCredentials(AttestationSigningContract contract, out { using var rsa = RSA.Create(); rsa.ImportFromPem(contract.PrivateKeyPem); + if (rsa.KeySize < MinimumKeySize) + { + return false; + } + var securityKey = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = contract.KeyId }; credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256); return true; diff --git a/Source/AuthProxy/Attestations/AttestationSigningContract.cs b/Source/AuthProxy/Attestations/AttestationSigningContract.cs index 33c19bb..4a403e7 100644 --- a/Source/AuthProxy/Attestations/AttestationSigningContract.cs +++ b/Source/AuthProxy/Attestations/AttestationSigningContract.cs @@ -21,4 +21,16 @@ public sealed record AttestationSigningContract( string Audience, string KeyId, string PrivateKeyPem, - TimeSpan Lifetime); + TimeSpan Lifetime) +{ + /// + /// Renders the contract without its key material. + /// + /// The contract's nonsecret values. + /// + /// A record's generated prints every property, so one + /// LogDebug("{Contract}", contract) would write the signing key to the log. This override exists so + /// that no logging statement anyone adds later can disclose it. + /// + public override string ToString() => $"{nameof(AttestationSigningContract)} {{ {nameof(Issuer)} = {Issuer}, {nameof(Audience)} = {Audience}, {nameof(KeyId)} = {KeyId}, {nameof(Lifetime)} = {Lifetime} }}"; +} diff --git a/Source/AuthProxy/Configuration/SignIn.cs b/Source/AuthProxy/Configuration/SignIn.cs index 6a9a061..4b741b3 100644 --- a/Source/AuthProxy/Configuration/SignIn.cs +++ b/Source/AuthProxy/Configuration/SignIn.cs @@ -23,6 +23,11 @@ public class SignIn /// AuthProxy posts { subject, identityProvider, ipAddress, location, browser, operatingSystem, userAgent } /// to it. Leave empty to disable sign-in notifications. /// + /// + /// With configured this URL must carry no query. The signed route binding is the + /// RFC 9449 htu, which covers the path only, so a query would travel unsigned — a captured + /// notification could then be replayed against a different query and still verify. + /// public string NotifyUrl { get; set; } = string.Empty; /// diff --git a/Source/AuthProxy/Invites/InvitationAttestationIssuer.cs b/Source/AuthProxy/Invites/InvitationAttestationIssuer.cs index ff9f15b..9d8b04e 100644 --- a/Source/AuthProxy/Invites/InvitationAttestationIssuer.cs +++ b/Source/AuthProxy/Invites/InvitationAttestationIssuer.cs @@ -60,7 +60,9 @@ bool TryIssue(InvitationEntryState state, Dictionary claims, out return false; } - var key = settings.SigningKeys.SingleOrDefault(_ => + // FirstOrDefault, not SingleOrDefault: a configuration that slipped a duplicate key identifier past + // startup validation must degrade to a refusal to issue, never to an exception thrown out of a request. + var key = settings.SigningKeys.FirstOrDefault(_ => string.Equals(_.KeyId, settings.ActiveKeyId, StringComparison.Ordinal)); if (key is null) { diff --git a/Source/AuthProxy/SignIns/SignInAttestationConfigurationValidator.cs b/Source/AuthProxy/SignIns/SignInAttestationConfigurationValidator.cs index 9242a2d..1d1b7c2 100644 --- a/Source/AuthProxy/SignIns/SignInAttestationConfigurationValidator.cs +++ b/Source/AuthProxy/SignIns/SignInAttestationConfigurationValidator.cs @@ -33,6 +33,11 @@ public ValidateOptionsResult Validate(string? name, C.AuthProxy options) var failures = new List(); AttestationConfigurationValidation.ValidateAbsoluteEndpoint(signIn!.NotifyUrl, "SignIn.NotifyUrl", failures); + if (Uri.TryCreate(signIn.NotifyUrl, UriKind.Absolute, out var notifyUrl) && !string.IsNullOrEmpty(notifyUrl.Query)) + { + failures.Add("SignIn.NotifyUrl must carry no query, because the signed route binding covers the path only."); + } + AttestationConfigurationValidation.ValidateBoundedValue(attestation.Issuer, "SignIn.Attestation.Issuer", failures); AttestationConfigurationValidation.ValidateBoundedValue(attestation.Audience, "SignIn.Attestation.Audience", failures); AttestationConfigurationValidation.ValidateBoundedValue(attestation.ActiveKeyId, "SignIn.Attestation.ActiveKeyId", failures); diff --git a/Source/AuthProxy/SignIns/SignInNotificationSigner.cs b/Source/AuthProxy/SignIns/SignInNotificationSigner.cs index 279b6b2..5f60df0 100644 --- a/Source/AuthProxy/SignIns/SignInNotificationSigner.cs +++ b/Source/AuthProxy/SignIns/SignInNotificationSigner.cs @@ -39,7 +39,9 @@ public bool TryIssue(HttpMethod method, Uri target, byte[] body, out string atte return false; } - var key = settings.SigningKeys.SingleOrDefault(_ => + // FirstOrDefault, not SingleOrDefault: a configuration that slipped a duplicate key identifier past + // startup validation must degrade to a refusal to sign, never to an exception thrown out of a sign-in. + var key = settings.SigningKeys.FirstOrDefault(_ => string.Equals(_.KeyId, settings.ActiveKeyId, StringComparison.Ordinal)); if (key is null) {