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
23 changes: 22 additions & 1 deletion Documentation/aspire/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
public class and_a_claim_is_named : given.an_auth_proxy_resource
{
Dictionary<string, string> _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");
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// An empty claim has to write nothing at all, rather than write the key empty.
/// <para>
/// 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.
/// </para>
/// </summary>
public class and_no_claim_is_named : given.an_auth_proxy_resource
{
Dictionary<string, string> _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();
}
40 changes: 40 additions & 0 deletions Source/Aspire/AuthProxyExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,11 @@ public static IResourceBuilder<T> WithInvite<T>(
/// <remarks>
/// 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.
/// <para>
/// Signed attestations require recipient binding: also call
/// <see cref="WithInviteEmailBinding{T}(IResourceBuilder{T}, string)"/> with a non-empty claim, and pass a
/// <c>tenantClaim</c> to <c>WithInvite</c>. Without both, AuthProxy fails options validation at startup.
/// </para>
/// </remarks>
public static IResourceBuilder<T> WithSignedInvitationAttestations<T>(
this IResourceBuilder<T> builder,
Expand Down Expand Up @@ -1087,6 +1092,41 @@ public static IResourceBuilder<T> WithInvite<T>(
return builder;
}

/// <summary>
/// Binds an invitation to the address it was issued to, so only the invited recipient can redeem it.
/// AuthProxy reads <paramref name="emailClaim"/> 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.
/// </summary>
/// <typeparam name="T">The resource type (must support environment variables).</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="emailClaim">
/// Claim in the invite token that carries the invited email address.
/// Pass an empty string to leave recipient binding off.
/// </param>
/// <returns>The same <see cref="IResourceBuilder{T}"/> for chaining.</returns>
/// <remarks>
/// Compose this after either <c>WithInvite</c> overload. When the provider offers no usable address, AuthProxy
/// rejects the invite with <c>invitation-email-unavailable.html</c>; when the address differs from the invited
/// one — or the provider explicitly reports <c>email_verified=false</c> — it rejects with
/// <c>invitation-email-mismatch.html</c>.
/// <para>
/// 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.
/// </para>
/// </remarks>
public static IResourceBuilder<T> WithInviteEmailBinding<T>(
this IResourceBuilder<T> builder,
string emailClaim)
where T : IResourceWithEnvironment
{
if (!string.IsNullOrEmpty(emailClaim))
{
builder.WithEnvironment($"{ConfigPrefix}__Invite__EmailClaim", emailClaim);
}

return builder;
}

/// <summary>
/// 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
Expand Down
Loading