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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 116 additions & 6 deletions Documentation/configuration/sign-in.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Security.Cryptography;

namespace Cratis.AuthProxy.Attestations.for_AttestationSigner;

/// <summary>
/// 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.
/// </summary>
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<string, object> Claims() => new(StringComparer.Ordinal) { ["purpose"] = "specification" };
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The contract is a record, and a record's generated rendering prints every one of its properties — including
/// the signing key. One <c>LogDebug("{Contract}", contract)</c> 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.
/// </summary>
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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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();
}
13 changes: 13 additions & 0 deletions Source/AuthProxy.Specs/SignIns/RecordingHttpMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -11,9 +12,21 @@ public class RecordingHttpMessageHandler(HttpStatusCode statusCode = HttpStatusC

public string? LastRequestBody { get; private set; }

/// <summary>
/// The exact bytes the transport received — the only body a digest claim can honestly be checked against.
/// </summary>
public ReadOnlyMemory<byte> LastRequestBytes { get; private set; }

/// <summary>
/// Captured while the request is still alive, since the notifier disposes it as soon as the call returns.
/// </summary>
public AuthenticationHeaderValue? LastRequestAuthorization { get; private set; }

protected override async Task<HttpResponseMessage> 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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Pins the published wire contract to literals rather than to the constants that produce it.
/// </summary>
/// <remarks>
/// 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
/// <see cref="InvitationAttestationClaims"/> otherwise makes invisible: both protocols sign a <c>purpose</c>
/// claim with the same key material, so two purposes that ever converged would let one protocol's assertion be
/// replayed as the other's.
/// </remarks>
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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading