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
| Authenticator | Constructor | Adds |
|---|---|---|
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:
[Application]on the method beats the one on the class (and its client bindings replace the class's).[Auth<T>]on the method replaces the class-level ones entirely — they don't merge.- Several
[Auth<T>]attributes at the same level are ordered by theirOrderproperty and composed:ProtoCompositeHttpAuthenticatorruns each in turn on the same request, recording every handler under anauth.handler.applyoperation with itsauth.typeandclient.name. The composite's trace source isProtoTest.{protocol}. - 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.