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 showsWhere
API keys and JWT bearer side by side, either one authenticating any requestAddTraxApiKeyAuth, AddTraxJwtAuth
Demo credentials that exist only in Development; real ones from configuration elsewhereif (builder.Environment.IsDevelopment())
A public train and a public query model[TraxAllowAnonymous]
A train any signed-in caller may runbare [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 oneArticle.editorNote
The operations namespace for operators only, the rest of the endpoint openGateOperations(roles: "Operator")
Principal ids qualified by schemeTraxApiKey:alice, TraxJwt:alice
Who did what, refused calls included, readable by auditorsAddAudit<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.Auth

dotnet 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.

UserAPI keyRolesEmail verified
alicealice-key-do-not-use-in-productionEditoryes
erinerin-key-do-not-use-in-productionEditorno
bobbob-key-do-not-use-in-productionReaderyes
oscaroscar-key-do-not-use-in-productionOperator, Auditoryes

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 that if.
  • Production reads real credentials from configuration. AddHashed takes the salt and SHA-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*Auth call registers the authentication services and the injectable TraxPrincipal. With none of them called (Production with nothing configured), app.UseAuthentication() throws Unable to resolve service for type 'IAuthenticationSchemeProvider', and the mediator refuses a host whose junctions inject TraxPrincipal: 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.

SurfaceDeclarationWho 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 resolveranyone
EditorNote (reached through Article.editorNote)[TraxAuthorize(Roles = "Editor,Auditor")]an editor or an auditor
AuditRecord[TraxQueryModel(Namespace = "audit")] [TraxAuthorize(Roles = "Auditor")]an auditor
operationsGateOperations(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 the operations field of the query and the mutation root. The rest of the endpoint stays open, which RequireAuthorization() 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 calling GateOperations() with neither a policy nor roles.
  • queueTrain also applies the queued train's own [TraxAuthorize]. Oscar is an operator but not an editor, so operations { 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, through operations { executionDetail { input } }, and publishArticle's input carries the editor note. Saving it would hand every operator a note that only editors and auditors may read.
  • AddScheduler registers 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 authToken in the connection_init payload: 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 to onTrainCompleted receives 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);
    }
}
RequestPrincipalIdPrincipalTypeSuccessErrorText
echo, no credential<anonymous>nulltruenull
echo, Alice's keyTraxApiKey:aliceapikeytruenull
echo, Alice's tokenTraxJwt:alicejwttruenull
publishArticle, Erin's tokenTraxJwt:erinjwtfalseNot authorized.
operations { health }, Bob's keyTraxApiKey:bobapikeyfalseNot 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 classProves
AuthenticationTestsids 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
TrainAuthorizationTestspublishArticle refused to anonymous, readers, operators and the unverified editor, over both schemes, without running; served to Alice over both
QueryModelAuthorizationTestspublic 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
OperationsGateTestsoperations 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
AuditTrailTestsevery call recorded with its qualified principal, refused ones as unsuccessful, without literal values; an auditor reads it back over GraphQL
SubscriptionAuthTestssocket credentials accepted or refused; the operator sees every train, the reader is refused
DemoCredentialsEnvironmentTestsin 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.