diff --git a/Documentation/configuration/sign-in.md b/Documentation/configuration/sign-in.md index 23f7458..fe9bc41 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,119 @@ 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. 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. + +### 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; 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 +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/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/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_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/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_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_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_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_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_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_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_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/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_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_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..39c61d5 --- /dev/null +++ b/Source/AuthProxy.Specs/SignIns/for_SignInNotificationSigner/when_issuing_an_envelope/and_the_verifier_uses_the_published_contract.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. + +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_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"); + [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/Attestations/AttestationConfigurationValidation.cs b/Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs new file mode 100644 index 0000000..4d8cea2 --- /dev/null +++ b/Source/AuthProxy/Attestations/AttestationConfigurationValidation.cs @@ -0,0 +1,96 @@ +// 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 (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."); + } + 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 < AttestationSigner.MinimumKeySize) + { + failures.Add($"{path}.PrivateKeyPem must contain an RSA key of at least {AttestationSigner.MinimumKeySize} 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..0413fa4 --- /dev/null +++ b/Source/AuthProxy/Attestations/AttestationSigner.cs @@ -0,0 +1,105 @@ +// 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 +{ + /// + /// 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. + /// + /// 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); + if (rsa.KeySize < MinimumKeySize) + { + return false; + } + + 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..4a403e7 --- /dev/null +++ b/Source/AuthProxy/Attestations/AttestationSigningContract.cs @@ -0,0 +1,36 @@ +// 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) +{ + /// + /// 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 47b14fb..4b741b3 100644 --- a/Source/AuthProxy/Configuration/SignIn.cs +++ b/Source/AuthProxy/Configuration/SignIn.cs @@ -23,5 +23,18 @@ 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; + + /// + /// 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/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..9d8b04e 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) { @@ -84,33 +60,30 @@ 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 || !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); } } 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..1d1b7c2 --- /dev/null +++ b/Source/AuthProxy/SignIns/SignInAttestationConfigurationValidator.cs @@ -0,0 +1,78 @@ +// 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); + 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); + + 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..5f60df0 --- /dev/null +++ b/Source/AuthProxy/SignIns/SignInNotificationSigner.cs @@ -0,0 +1,70 @@ +// 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; + } + + // 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) + { + 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; }