Auth
NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See API Security.
Trax.Samples/samples/Auth/Trax.Samples.Auth is one host that secures a Trax GraphQL server end
to end. Copy its Program.cs as the starting point for your own: everything a secured host needs
is in it, and nothing else.
| What it shows | Where |
|---|---|
| API keys and JWT bearer side by side, either one authenticating any request | AddTraxApiKeyAuth, AddTraxJwtAuth |
| Demo credentials that exist only in Development; real ones from configuration elsewhere | if (builder.Environment.IsDevelopment()) |
| A public train and a public query model | [TraxAllowAnonymous] |
| A train any signed-in caller may run | bare [TraxAuthorize] |
| A policy and a role that must both pass | [TraxAuthorize("VerifiedEmail")] + [TraxAuthorize(Roles = "Editor")] |
| Either of two roles is enough | [TraxAuthorize(Roles = "Editor,Auditor")] |
| A gated entity reached through a public one | Article.editorNote |
The operations namespace for operators only, the rest of the endpoint open | GateOperations(roles: "Operator") |
| Principal ids qualified by scheme | TraxApiKey:alice, TraxJwt:alice |
| Who did what, refused calls included, readable by auditors | AddAudit<DatabaseAuditSink>(), the AuditRecord query model |
Every row is proven against the real host by tests/Trax.Samples.Auth.E2E, over both schemes and
over WebSockets.
Run it
The sample uses PostgreSQL. From the Trax.Samples root:
docker compose up -d database
dotnet run --project samples/Auth/Trax.Samples.Authdotnet run uses Properties/launchSettings.json, which sets ASPNETCORE_ENVIRONMENT=Development
and serves on http://localhost:5220. Development is the only environment in which the demo
credentials exist. The connection string is ConnectionStrings:TraxDatabase in appsettings.json
(Host=localhost;Port=5432;Database=trax_auth;Username=trax;Password=trax123); override it with the
ConnectionStrings__TraxDatabase environment variable.
The demo users
Each user can sign in either way: with an API key in X-Api-Key, or with a JWT in
Authorization: Bearer. GET /dev/token/{user} mints a one-hour token, in Development only.
| User | API key | Roles | Email verified |
|---|---|---|---|
alice | alice-key-do-not-use-in-production | Editor | yes |
erin | erin-key-do-not-use-in-production | Editor | no |
bob | bob-key-do-not-use-in-production | Reader | yes |
oscar | oscar-key-do-not-use-in-production | Operator, Auditor | yes |
Try it
Every command below was run against the sample. Responses are trimmed to the interesting part.
G=localhost:5220/trax/graphql
J='Content-Type: application/json'
# Public: no credential needed
curl -s $G -H "$J" -d '{"query":"{ discover { echo(input: { message: \"hi\" }) { echoed } } }"}'
# {"data":{"discover":{"echo":{"echoed":"hi"}}}}
# A gated train with no credential: the generic refusal, nothing else
curl -s $G -H "$J" -d '{"query":"{ discover { whoAmI { id } } }"}'
# {"errors":[{"message":"Not authorized.","path":["discover","whoAmI"],"extensions":{"code":"TRAX_AUTHORIZATION"}}],...}
# Alice by key, then by token: the id says which scheme authenticated her
curl -s $G -H "$J" -H 'X-Api-Key: alice-key-do-not-use-in-production' \
-d '{"query":"{ discover { whoAmI { id roles principalType } } }"}'
# {"data":{"discover":{"whoAmI":{"id":"TraxApiKey:alice","roles":["Editor"],"principalType":"apikey"}}}}
TOKEN=$(curl -s localhost:5220/dev/token/alice | sed 's/.*"token":"\([^"]*\)".*/\1/')
curl -s $G -H "$J" -H "Authorization: Bearer $TOKEN" \
-d '{"query":"{ discover { whoAmI { id roles principalType } } }"}'
# {"data":{"discover":{"whoAmI":{"id":"TraxJwt:alice","roles":["Editor"],"principalType":"jwt"}}}}
# Erin holds Editor but fails the VerifiedEmail policy: refused
curl -s $G -H "$J" -H 'X-Api-Key: erin-key-do-not-use-in-production' \
-d '{"query":"mutation { dispatch { news { publishArticle(input: { title: \"t\", body: \"b\" }) { output { articleId } } } } }"}'
# {"errors":[{"message":"Not authorized.",...,"extensions":{"code":"TRAX_AUTHORIZATION"}}],...}
# Alice passes both
curl -s $G -H "$J" -H "Authorization: Bearer $TOKEN" \
-d '{"query":"mutation { dispatch { news { publishArticle(input: { title: \"t\", body: \"b\" }) { output { articleId authorId } } } } }"}'
# {"data":{"dispatch":{"news":{"publishArticle":{"output":{"articleId":3,"authorId":"TraxJwt:alice"}}}}}}
# Articles are public; their editor notes are not
curl -s $G -H "$J" -d '{"query":"{ discover { news { articles { nodes { title editorNote { text } } } } } }"}'
# "editorNote": null on every node, with one TRAX_AUTHORIZATION error per node
# The operations namespace: refused to Alice, served to Oscar
curl -s $G -H "$J" -H 'X-Api-Key: alice-key-do-not-use-in-production' \
-d '{"query":"{ operations { health { status } } }"}'
# {"errors":[{"message":"Not authorized.","path":["operations"],...}],"data":{"operations":null}}
curl -s $G -H "$J" -H 'X-Api-Key: oscar-key-do-not-use-in-production' \
-d '{"query":"{ operations { health { status } } }"}'
# {"data":{"operations":{"health":{"status":"Healthy"}}}}
# Who did what. Entries reach the table within a quarter of a second of the request.
curl -s $G -H "$J" -H 'X-Api-Key: oscar-key-do-not-use-in-production' \
-d '{"query":"{ discover { audit { auditRecords(first: 5, order: { id: DESC }) { nodes { principalId success errorText document } } } } }"}'
# {"principalId":"TraxApiKey:alice","success":false,"errorText":"TRAX_AUTHORIZATION at operations","document":"{\n operations {..."}How it is built
Authentication: two schemes, Development and everything else
using Trax.Api.Auth;
using Trax.Api.Auth.ApiKey;
using Trax.Api.Auth.Jwt;
if (builder.Environment.IsDevelopment())
{
builder.Services.AddTraxApiKeyAuth(keys =>
keys.Add(DemoCredentials.AliceKey, DemoCredentials.Alice.ToApiKeyPrincipal)
.Add(DemoCredentials.ErinKey, DemoCredentials.Erin.ToApiKeyPrincipal)
.Add(DemoCredentials.BobKey, DemoCredentials.Bob.ToApiKeyPrincipal)
.Add(DemoCredentials.OscarKey, DemoCredentials.Oscar.ToApiKeyPrincipal)
);
builder.Services.AddTraxJwtAuth(jwt =>
jwt.UseSymmetricKey(
DemoCredentials.JwtIssuer,
DemoCredentials.JwtAudience,
DemoCredentials.JwtSigningKey // at least 32 bytes
)
);
}
else
{
var apiKeys = builder.Configuration.GetSection("Auth:ApiKeys").Get<ConfiguredApiKey[]>() ?? [];
if (apiKeys.Length > 0)
builder.Services.AddTraxApiKeyAuth(keys =>
{
foreach (var key in apiKeys)
keys.AddHashed(
Convert.FromBase64String(key.Salt),
Convert.FromBase64String(key.Hash), // SHA-256(salt || UTF-8 key)
key.Id,
key.Roles
);
});
var jwt = builder.Configuration.GetSection("Auth:Jwt");
if (jwt["Authority"] is { Length: > 0 } authority)
builder.Services.AddTraxJwtAuth(authority, jwt["Audience"]!);
}
builder.Services.AddAuthentication();
builder.Services.AddTraxPrincipalAccessor();
// A production API key as configuration holds it: never the key itself.
internal sealed record ConfiguredApiKey(string Id, string Salt, string Hash, string[] Roles);- Neither scheme is the default. For a GraphQL request, Trax tries every registered scheme in registration order and keeps the first that authenticates. A request carrying both an API key and a token is authenticated by the API key here, because it is registered first.
- The demo credentials exist only in Development. Each demo API key contains
do-not-use-in-production, and Trax.Api refuses to start a host outside Development with such a key registered:AddTraxApiKeyAuth() registered a key containing 'do-not-use-in-production', which marks a published demo key, and the environment is 'Production'. Such keys start only in Development.Every demo credential, the JWT signing key included, is registered inside thatif. - Production reads real credentials from configuration.
AddHashedtakes the salt andSHA-256(salt || UTF-8 key), so the key itself never enters the process. See Pre-hashed keys for how to produce them. The JWT scheme validates tokens against the identity provider's published keys. - The last two lines matter because the registrations above are conditional. Every
AddTrax*Authcall registers the authentication services and the injectableTraxPrincipal. With none of them called (Production with nothing configured),app.UseAuthentication()throwsUnable to resolve service for type 'IAuthenticationSchemeProvider', and the mediator refuses a host whose junctions injectTraxPrincipal:step 1 (DescribeCallerJunction) needs 'Trax.Api.Auth.TraxPrincipal' as a constructor argument; ... the container does not register it.With both lines, that host starts and refuses every gated call.
The factory overload of Add builds the whole principal. This one carries a custom claim, which
the VerifiedEmail policy reads, and sets PrincipalType to apikey, which the plain
Add(key, id, roles) overload sets for you:
public TraxPrincipal ToApiKeyPrincipal() =>
new(Id, DisplayName, Roles,
Claims: new Dictionary<string, string> { ["email_verified"] = EmailVerified ? "true" : "false" },
PrincipalType: "apikey");A JWT carries the same facts as claims: sub, name, role (one per role) and
email_verified. Trax's default JWT resolver maps sub to the id, name to the display name and
role to roles, and passes email_verified through to the principal's claims.
Authorization: policies and roles
builder.Services.AddAuthorization(options =>
options.AddPolicy("VerifiedEmail", policy => policy.RequireClaim("email_verified", "true"))
);Roles need no registration. A policy does: register every policy before a [TraxAuthorize]
names it.
| Surface | Declaration | Who gets in |
|---|---|---|
EchoTrain | [TraxQuery] [TraxAllowAnonymous] | anyone |
WhoAmITrain | [TraxQuery] [TraxAuthorize] | any signed-in caller |
PublishArticleTrain | [TraxMutation(GraphQLOperation.Run, Namespace = "news")] [TraxAuthorize("VerifiedEmail")] [TraxAuthorize(Roles = "Editor")] | an editor who passes the policy (Alice, not Erin) |
Article | [TraxQueryModel(Namespace = "news")] [TraxAllowAnonymous] | anyone |
Article.wordCount (type extension) | [TraxAllowAnonymous] on the resolver | anyone |
EditorNote (reached through Article.editorNote) | [TraxAuthorize(Roles = "Editor,Auditor")] | an editor or an auditor |
AuditRecord | [TraxQueryModel(Namespace = "audit")] [TraxAuthorize(Roles = "Auditor")] | an auditor |
operations | GateOperations(roles: "Operator") | an operator |
Separate attributes combine as ASP.NET Core's [Authorize] does: each is a requirement of its own,
and the caller must meet every one, so policies AND and two role attributes require both roles.
Within one attribute, a comma-separated role list is any of. Every exposed train and query model declares exactly one of
[TraxAuthorize] and [TraxAllowAnonymous]; with neither, the host refuses to start, naming the
type. So do EditorNote, which is not a query model but is reachable from one, and the
wordCount field grafted onto the public Article.
A refusal is always the same response: code TRAX_AUTHORIZATION, message Not authorized.. The
train never runs, so a refused publishArticle writes no article.
PublishArticleTrain exposes Run only. Its junction reads the caller by injecting
TraxPrincipal, which needs the HTTP request; a queued run, executed later by the scheduler,
has none.
public class SaveArticleJunction(TraxPrincipal caller, INewsroomDbContext db)
: Junction<PublishArticleInput, PublishArticleOutput>
{
public override async Task<PublishArticleOutput> Run(PublishArticleInput input)
{
var article = new Article { Title = input.Title, Body = input.Body, AuthorId = caller.Id };
db.Articles.Add(article);
await db.SaveChangesAsync();
return new PublishArticleOutput(article.Id, article.AuthorId);
}
}caller.Id is the scheme-qualified id, TraxJwt:alice or TraxApiKey:alice. Store it as it is.
GraphQL: the operations gate and the audit trail
using Trax.Api.GraphQL.Audit;
using Trax.Api.GraphQL.Extensions;
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(connectionString).AddJson())
.AddMediator(typeof(Program).Assembly)
.AddScheduler(scheduler => scheduler)
);
builder.Services.AddTraxGraphQL(graphql =>
graphql
.AddDbContext<NewsroomDbContext>()
.AddTypeExtension<ArticleExtensions>()
.ExposeOperationQueries()
.ExposeOperationMutations()
.GateOperations(roles: "Operator")
.AddAudit<DatabaseAuditSink>(audit =>
{
audit.BatchSize = 20;
audit.FlushInterval = TimeSpan.FromMilliseconds(250);
})
);
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseTraxGraphQL();GateOperations(roles: "Operator")puts the gate on theoperationsfield of the query and the mutation root. The rest of the endpoint stays open, whichRequireAuthorization()cannot do: it gates every operation, and also refuses to start beside a[TraxAllowAnonymous]surface. Exposing the namespace with no gate at all refuses startup, and so does callingGateOperations()with neither a policy nor roles.queueTrainalso applies the queued train's own[TraxAuthorize]. Oscar is an operator but not an editor, sooperations { workQueue { queueTrain(input: { trainName: "Trax.Samples.Auth.Trains.IPublishArticleTrain", ... }) } }is refused and writes nothing.- No
SaveTrainParameters(). A saved input is readable by anyone who passes the operations gate, throughoperations { executionDetail { input } }, andpublishArticle's input carries the editor note. Saving it would hand every operator a note that only editors and auditors may read. AddSchedulerregisters what the operations namespace runs on (IOperationsService,ITraxScheduler, a job submitter). Exposing the namespace without them refuses startup.- Subscriptions. A WebSocket carries its credential as
authTokenin theconnection_initpayload: a JWT, or an API key. With no credential, an unknown key or a token that fails validation, the connection is refused. Because this host exposes the operations surface, an operator subscribed toonTrainCompletedreceives every train, and Bob, who may see none of them (no train here is[TraxBroadcast]), is refused when he subscribes.
The sink stores each entry in a table of the sample's own newsroom schema:
public sealed class DatabaseAuditSink(IDbContextFactory<NewsroomDbContext> contexts) : ITraxAuditSink
{
public async Task WriteAsync(IReadOnlyList<TraxAuditEntry> batch, CancellationToken ct)
{
await using var db = await contexts.CreateDbContextAsync(ct);
db.AuditRecords.AddRange(batch.Select(e => new AuditRecord
{
PrincipalId = e.PrincipalId, PrincipalType = e.PrincipalType,
OperationName = e.OperationName, Document = e.Document,
Success = e.Success, ErrorText = e.ErrorText,
DurationMs = e.DurationMs, Timestamp = e.Timestamp.UtcDateTime,
}));
await db.SaveChangesAsync(ct);
}
}| Request | PrincipalId | PrincipalType | Success | ErrorText |
|---|---|---|---|---|
echo, no credential | <anonymous> | null | true | null |
echo, Alice's key | TraxApiKey:alice | apikey | true | null |
echo, Alice's token | TraxJwt:alice | jwt | true | null |
publishArticle, Erin's token | TraxJwt:erin | jwt | false | Not authorized. |
operations { health }, Bob's key | TraxApiKey:bob | apikey | false | Not authorized. |
The document is stored with every string and number literal blanked (title: ""), and variables
are not recorded. OperationName is the request's operationName field: a client that names its
operation only inside the document (query Feed { ... }) and sends no operationName gets null.
What the tests prove
tests/Trax.Samples.Auth.E2E runs the real host through WebApplicationFactory, against the
auth_e2e_tests database. Locally, start Postgres and run
dotnet test tests/Trax.Samples.Auth.E2E; set TRAX_TEST_PG_PORT when your Postgres is not on 5432.
| Test class | Proves |
|---|---|
AuthenticationTests | ids TraxApiKey:alice and TraxJwt:alice; an unknown key, a forged, expired or wrong-audience token is anonymous; the first registered scheme wins when both are sent |
TrainAuthorizationTests | publishArticle refused to anonymous, readers, operators and the unverified editor, over both schemes, without running; served to Alice over both |
QueryModelAuthorizationTests | public articles; the gated note through the public article, selected or filtered on; either stacked role reads it; the audit model is refused and cannot be counted without Auditor |
OperationsGateTests | operations reads and mutations refused without Operator; served to Oscar over both schemes; queueTrain still applies the train's own gate; an operator reads no editor note from an execution's saved input |
AuditTrailTests | every call recorded with its qualified principal, refused ones as unsuccessful, without literal values; an auditor reads it back over GraphQL |
SubscriptionAuthTests | socket credentials accepted or refused; the operator sees every train, the reader is refused |
DemoCredentialsEnvironmentTests | in Production no demo scheme exists and the demo key and token authenticate nobody, /dev/token is gone, a copied demo key refuses startup, and configured hashed keys and an identity provider's tokens work |
The Production test signs in with an identity provider's token using TestJwksServer from
Trax.Api.Auth.Jwt.Testing; see JWT Testing.
SDK Reference
AddTraxApiKeyAuth | AddTraxJwtAuth | Injecting TraxPrincipal | TraxPrincipal | TraxAuthorize | TraxAllowAnonymous | AddTraxGraphQL | AddAudit | ITraxAuditSink | TraxAuditEntry | DomainDataContext
See also Authorization, API Security and Qualified Principal Ids.