Skip to main content

Authentication

REST and GraphQL share one authentication model, defined in ProtoTest.Http. An authenticator gets the outgoing HttpRequestMessage just before it's sent and adds whatever the API needs.

public interface IProtoHttpAuthenticator
{
ValueTask AuthenticateAsync(
ProtoHttpAuthenticationContext context,
CancellationToken cancellationToken = default);
}

public sealed record ProtoHttpAuthenticationContext(
HttpRequestMessage Request,
ProtoExecutionContext Test,
string ClientName);

Because the context carries the running test, an authenticator can read typed state that an attribute set up earlier — which is how a test gets a fresh user without any header code.

Built-in authenticators

AuthenticatorConstructorAdds
BearerTokenAuthenticator(string token)Authorization: Bearer <token>
BasicAuthAuthenticator(string username, string password)Authorization: Basic <base64> (UTF-8)
ApiKeyAuthenticator(string keyName, string keyValue, ApiKeyLocation location = ApiKeyLocation.Header)a header via TryAddWithoutValidation, or a query parameter with ApiKeyLocation.Query

ApiKeyLocation is Header or Query; the query form needs a request URI and throws InvalidOperationException if there is none.

Applying it

Per request

await Proto.Context.Rest()
.Auth<BearerTokenAuthenticator>("orders-token")
.GetAsync("/api/orders");

await Proto.Context.Rest()
.Auth(new ApiKeyAuthenticator("X-Api-Key", apiKey))
.GetAsync("/api/orders");

Per test or class, with an attribute

[Application("Api")]
[Auth<BearerTokenAuthenticator>("orders-token")]
public class OrderTests
{
[ProtoTest]
public async Task Lists_orders()
{
// Authorization is already applied.
var response = await Proto.Context.Rest().GetAsync("/api/orders");
response.Should.HaveHttpStatus(HttpStatusCode.OK);
}
}

AuthAttribute<TAuthenticator> applies to a class or method, allows several attributes at the same level, and inherits. Two properties shape it:

  • Order — several attributes at the same level run in this order.
  • Protocols — empty (the default) applies to every HTTP-based protocol the application exposes; otherwise it matches protocol names case-insensitively, for example ["GraphQL"].

[Application("Api")] selects the application the test targets; Rest() uses that application's default REST client. Bind a different client with [Application("Api", "Rest:Billing")].

Opting out

await Proto.Context.Rest()
.WithoutAuth()
.GetAsync("/api/health");

This is how you test that an endpoint rejects anonymous callers, or send a deliberately wrong token:

using var response = await Proto.Context.Rest()
.WithoutAuth()
.Header("Authorization", $"Bearer {user.AccessToken}")
.Header("X-Tenant", "competitor-tenant")
.GetAsync("/api/control-plane");

response
.Should.HaveHttpStatus(HttpStatusCode.Forbidden)
.ShouldMatchShape(new { error = "tenant-access-denied" });

Precedence

When several of these apply, this is what wins:

  1. [Application] on the method beats the one on the class (and its client bindings replace the class's).
  2. [Auth<T>] on the method replaces the class-level ones entirely — they don't merge.
  3. Several [Auth<T>] attributes at the same level are ordered by their Order property and composed: ProtoCompositeHttpAuthenticator runs each in turn on the same request, recording every handler under an auth.handler.apply operation with its auth.type and client.name. The composite's trace source is ProtoTest.{protocol}.
  4. A per-request .Auth(...) overrides whatever the attributes resolved, and .WithoutAuth() clears it.

The REST and GraphQL lifecycle hooks (Order 100) resolve the attributes before each test and record a Auth entity state under the protocol name with auth.source (method, class or none), auth.count and auth.types. Per request, the applied authenticator is recorded on the request operation: auth.outcome is applied or skipped, with auth.type when applied. Header values are never traced; the trace records the header count and each header's name with http.header.value_recorded=false.

Writing your own

Most real suites need one. Here's the one from the sample app, which authenticates as whichever user the test's [SampleUser] attribute created:

using System.Net.Http.Headers;
using ProtoTest.Http;

public sealed class SampleUserAuthenticator : IProtoHttpAuthenticator
{
public ValueTask AuthenticateAsync(
ProtoHttpAuthenticationContext context,
CancellationToken cancellationToken = default)
{
var environment = context.Test.Resolve<SampleEnvironmentContext>();
var user = context.Test.Resolve<SampleUserContext>();

context.Request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", user.AccessToken);
context.Request.Headers.Add("X-Tenant", environment.Tenant);
return ValueTask.CompletedTask;
}
}

Constructor arguments and services

[Auth<T>(args)] and .Auth<T>(args) construct T with ActivatorUtilities, so the constructor can mix positional arguments from the attribute with services from the test's DI scope — including ProtoExecutionContext itself:

public sealed class TenantTokenAuthenticator(
string tenant, // from [Auth<TenantTokenAuthenticator>("tenant-a")]
ITokenService tokens) // resolved from DI
: IProtoHttpAuthenticator
{
public async ValueTask AuthenticateAsync(
ProtoHttpAuthenticationContext context,
CancellationToken cancellationToken = default)
{
var token = await tokens.GetTokenAsync(tenant, cancellationToken);
context.Request.Headers.Authorization = new("Bearer", token);
}
}

This keeps secrets out of attribute metadata: the attribute carries only a name, and the authenticator looks up the real value.

The same authenticator serves GraphQL too: [Auth<T>] applies to every HTTP-based protocol the application exposes, and its Protocols property narrows it when an application exposes both and you only want one. Extending ProtoTest covers the factory behind it.