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;
}