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