diff --git a/Documentation/aspire/index.md b/Documentation/aspire/index.md
index db1baf7..f6b8a2e 100644
--- a/Documentation/aspire/index.md
+++ b/Documentation/aspire/index.md
@@ -239,7 +239,10 @@ authproxy.WithSignedInvitationAttestations(
```
Load `invitationSigningKey` from a secret provider and configure the invitation authority with only the matching
-public key. See [Invitation to Organization](../configuration/lobby/invitation-to-organization.md) for the claims,
+public key. Signed attestations also require recipient binding — call
+[`WithInviteEmailBinding`](#binding-an-invitation-to-the-invited-email) with a non-empty claim and pass a
+`tenantClaim` to `WithInvite`, or AuthProxy fails options validation at startup. See
+[Invitation to Organization](../configuration/lobby/invitation-to-organization.md) for the claims,
two calls, verification rules, and rotation sequence.
---
@@ -344,6 +347,24 @@ authproxy.WithInvite(
| `tenantClaim` | – | Claim that carries the tenant ID for tenant-issued invite detection. |
| `subjectAlreadyExistsUrl` | – | Redirect URL when the exchange endpoint returns HTTP 409. Omit to serve the built-in page. |
+### Binding an invitation to the invited email
+
+By default an invite is a bearer token: any subject who signs in holding it can redeem it. To bind it to the
+address it was issued to, name the claim in the invite token that carries that address:
+
+```csharp
+authproxy.WithInviteEmailBinding("invited_email");
+```
+
+Compose this after either `WithInvite` overload. AuthProxy then compares that claim against the email evidence
+the identity provider supplied for the signed-in session, before the second-stage exchange runs. When the
+provider offers no usable address, the invite is rejected with `invitation-email-unavailable.html`; when the
+address differs from the invited one — or the provider explicitly reports `email_verified=false` — with
+`invitation-email-mismatch.html`.
+
+Omitting the call, or passing an empty claim, writes nothing and retains the released default of no recipient
+binding.
+
### Claim forwarding
To propagate invite-token claims into the principal sent to `/.cratis/me` endpoints, call
diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_binding_invitations_to_the_invited_email/and_a_claim_is_named.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_binding_invitations_to_the_invited_email/and_a_claim_is_named.cs
new file mode 100644
index 0000000..636be5a
--- /dev/null
+++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_binding_invitations_to_the_invited_email/and_a_claim_is_named.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.
+
+namespace Cratis.AuthProxy.Aspire.for_AuthProxyExtensions.when_binding_invitations_to_the_invited_email;
+
+///
+/// Naming the claim is what turns an invite from a bearer token anyone signed in can redeem into one bound to
+/// the address it was sent to, so the key and its value are the whole of the binding.
+///
+/// The Aspire package cannot reference the proxy it configures, so this string is the only thing joining the
+/// two. A rename on either side binds nothing and falls back to the empty default — which reads as
+/// enforcement deliberately left off rather than as a broken deployment, and leaves the invite redeemable by
+/// any authenticated subject holding it.
+///
+///
+public class and_a_claim_is_named : given.an_auth_proxy_resource
+{
+ Dictionary _environment;
+
+ void Establish() => _resource.WithInviteEmailBinding("invited_email");
+
+ async Task Because() => _environment = await EnvironmentVariables();
+
+ [Fact] void should_bind_the_invitation_to_the_named_claim() => _environment["Cratis__AuthProxy__Invite__EmailClaim"].ShouldEqual("invited_email");
+}
diff --git a/Source/Aspire.Specs/for_AuthProxyExtensions/when_binding_invitations_to_the_invited_email/and_no_claim_is_named.cs b/Source/Aspire.Specs/for_AuthProxyExtensions/when_binding_invitations_to_the_invited_email/and_no_claim_is_named.cs
new file mode 100644
index 0000000..e80567b
--- /dev/null
+++ b/Source/Aspire.Specs/for_AuthProxyExtensions/when_binding_invitations_to_the_invited_email/and_no_claim_is_named.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.Aspire.for_AuthProxyExtensions.when_binding_invitations_to_the_invited_email;
+
+///
+/// An empty claim has to write nothing at all, rather than write the key empty.
+///
+/// The two are not the same to a deployment: an absent key lets any lower-precedence configuration source —
+/// an appsettings file, a Helm value, a container default — still name a claim, while an emitted empty value
+/// silently overrides all of them and switches recipient binding off. Retaining the released default means
+/// staying out of the section entirely.
+///
+///
+public class and_no_claim_is_named : given.an_auth_proxy_resource
+{
+ Dictionary _environment;
+
+ void Establish() => _resource.WithInviteEmailBinding(string.Empty);
+
+ async Task Because() => _environment = await EnvironmentVariables();
+
+ [Fact] void should_not_write_the_binding_at_all() => _environment.ContainsKey("Cratis__AuthProxy__Invite__EmailClaim").ShouldBeFalse();
+}
diff --git a/Source/Aspire/AuthProxyExtensions.cs b/Source/Aspire/AuthProxyExtensions.cs
index ceee6c1..5135268 100644
--- a/Source/Aspire/AuthProxyExtensions.cs
+++ b/Source/Aspire/AuthProxyExtensions.cs
@@ -986,6 +986,11 @@ public static IResourceBuilder WithInvite(
///
/// Configure the invitation authority with the matching public key before activating a new key identifier.
/// AuthProxy writes the private value to an environment variable and never logs it.
+ ///
+ /// Signed attestations require recipient binding: also call
+ /// with a non-empty claim, and pass a
+ /// tenantClaim to WithInvite. Without both, AuthProxy fails options validation at startup.
+ ///
///
public static IResourceBuilder WithSignedInvitationAttestations(
this IResourceBuilder builder,
@@ -1087,6 +1092,41 @@ public static IResourceBuilder WithInvite(
return builder;
}
+ ///
+ /// Binds an invitation to the address it was issued to, so only the invited recipient can redeem it.
+ /// AuthProxy reads from the validated invite token and compares it against the
+ /// email evidence the identity provider supplied for the signed-in session, before the second-stage exchange runs.
+ ///
+ /// The resource type (must support environment variables).
+ /// The resource builder.
+ ///
+ /// Claim in the invite token that carries the invited email address.
+ /// Pass an empty string to leave recipient binding off.
+ ///
+ /// The same for chaining.
+ ///
+ /// Compose this after either WithInvite overload. When the provider offers no usable address, AuthProxy
+ /// rejects the invite with invitation-email-unavailable.html; when the address differs from the invited
+ /// one — or the provider explicitly reports email_verified=false — it rejects with
+ /// invitation-email-mismatch.html.
+ ///
+ /// Not calling this method, or passing an empty claim, writes nothing and retains the released default of no
+ /// recipient binding: any authenticated subject holding the invite token can redeem it.
+ ///
+ ///
+ public static IResourceBuilder WithInviteEmailBinding(
+ this IResourceBuilder builder,
+ string emailClaim)
+ where T : IResourceWithEnvironment
+ {
+ if (!string.IsNullOrEmpty(emailClaim))
+ {
+ builder.WithEnvironment($"{ConfigPrefix}__Invite__EmailClaim", emailClaim);
+ }
+
+ return builder;
+ }
+
///
/// Adds a claim-forwarding entry to the AuthProxy invite system.
/// When a pending invite cookie exists, AuthProxy reads the specified claim from the invite token