Cognito Issuer

> NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See API Security.

Trax.Api.Auth.Jwt.Cognito.Issuer is the symmetric counterpart to Trax.Api.Auth.Jwt.Cognito. The cognito package validates Cognito-shaped tokens; this package mints them. Use it in local-development stand-ins for Cognito (a Docker service that speaks the user-pool dialect to NextAuth, Amplify, or any OIDC client) and in integration tests that need real Cognito claim shapes rather than the generic JWTs TestTokenIssuer emits.

The package is consumed in addition to, not instead of, the validator. Server code that only validates tokens keeps referencing Trax.Api.Auth.Jwt.Cognito and never pulls in the issuer.

What it provides

TypePurpose
CognitoTokenIssuerMints RS256 access and ID tokens with Cognito claim conventions.
CognitoAccessTokenRequest, CognitoIdTokenRequestInputs for mint calls.
FederatedIdentityOne entry in the Cognito identities claim.
IRefreshTokenStorePersistence contract for opaque refresh tokens with rotation chains.
InMemoryRefreshTokenStoreProcess-memory implementation for tests and local dev.
TestJwksServer.CreateCognitoIssuer()Extension that pairs a TestJwksServer with a CognitoTokenIssuer.

CognitoTokenIssuer

using var rsa = RSA.Create(2048);
var key = new RsaSecurityKey(rsa) { KeyId = "local-pool-key-1" };
var issuer = new CognitoTokenIssuer(
    issuer: "http://localhost:8080/local_us-east-1_xxx",
    signingKey: key);
 
var accessToken = issuer.MintAccessToken(new CognitoAccessTokenRequest
{
    Sub = userId,
    ClientId = "mobile-app-client-id",
    Lifetime = TimeSpan.FromHours(1),
    Username = "alice",
    Scopes = new[] { "openid", "profile" },
    Groups = new[] { "admin" },
});
 
var idToken = issuer.MintIdToken(new CognitoIdTokenRequest
{
    Sub = userId,
    ClientId = "mobile-app-client-id",
    Lifetime = TimeSpan.FromHours(1),
    Email = "alice@example.com",
    EmailVerified = true,
    GivenName = "Alice",
    FamilyName = "Smith",
});

The signing key must have KeyId set; the same value lands in the JWT header kid and must be discoverable in whatever JWKS your validator fetches. Inject a TimeProvider to control iat/exp in tests.

Access vs ID token claim shape

ClaimAccess tokenID token
token_use"access""id"
Audienceclient_id claim (no aud)aud claim
usernamealways(uses cognito:username)
cognito:usernameomittedalways
email, email_verifiedomittedrequired
given_name, family_nameomittedoptional
identitiesomittedoptional, JSON-array string
scopespace-delimited from Scopesomitted
cognito:groupsrepeated per entryrepeated per entry
auth_timerequest AuthTime or iatrequest AuthTime or iat
jtifresh GUID per callfresh GUID per call

AuthTime lets refresh-issued tokens carry the original sign-in time even though iat reflects the rotation. Leave it null for a fresh login.

CognitoAccessTokenRequest

PropertyRequiredNotes
SubyesCognito uses a GUID per user.
ClientIdyesApp client id. Written as client_id, not aud.
LifetimeyesMust be positive.
UsernamenoDefaults to Sub.ToString().
ScopesnoJoined into a space-delimited scope claim; omitted when empty.
GroupsnoEach entry becomes a repeated cognito:groups claim.
AuthTimenoOriginal authentication time; defaults to the issuer clock.
AdditionalClaimsnoString-valued claims appended verbatim (use for custom:* attributes).

CognitoIdTokenRequest

PropertyRequiredNotes
SubyesSame as access token.
ClientIdyesWritten as aud.
LifetimeyesMust be positive.
EmailyesOIDC profile claim.
EmailVerifiedyesOIDC profile claim.
GivenName, FamilyNamenoOmitted when null or empty.
UsernamenoDefaults to Sub.ToString(). Lands in cognito:username.
GroupsnoRepeated cognito:groups.
IdentitiesnoFederated identities; see below.
AuthTimenoOriginal authentication time.
AdditionalClaimsnoString-valued extras.

FederatedIdentity

One entry per linked external provider. Cognito serializes the list as a JSON-array string under the identities claim, which CognitoJwtPrincipalResolver parses to surface identity_provider.

FieldNotes
UserIdProvider-issued subject (Google numeric id, Apple opaque sub, SAML NameID).
ProviderNameCognito-side provider name (Google, SignInWithApple, etc.).
ProviderTypeDiscriminator. Matches ProviderName for OIDC federations; SAML for SAML.
PrimaryCognito's resolver prefers the primary entry when surfacing identity_provider.
DateCreatedWhen the identity was linked; serialized as epoch milliseconds.

Refresh tokens

Real Cognito refresh tokens are opaque blobs. The IRefreshTokenStore contract captures the four operations a Cognito-compatible auth server has to support.

MethodBehavior
IssueAsync(sub, clientId, lifetime, ct)Start a new rotation chain.
ValidateAsync(token, ct)Returns claims if active; null if expired, revoked, consumed, or unknown.
RotateAsync(oldToken, ct)Atomically consume one token and issue a successor in the same chain. Returns null when the supplied token is invalid. Rotation does not extend session length, the new token inherits the original expiry.
RevokeAsync(token, ct)Revokes the entire rotation chain the token belongs to.
RevokeAllAsync(sub, clientId, ct)Global sign-out: revoke every chain for the user.

InMemoryRefreshTokenStore is provided for tests and local-dev auth servers. It generates 256-bit random tokens, holds state in process memory, and is not safe across restarts. Production hosts implement IRefreshTokenStore against their own persistence (a relational table keyed by token hash, a Redis namespace with TTLs, etc.).

var store = new InMemoryRefreshTokenStore();
 
var refresh = await store.IssueAsync(userId, clientId, TimeSpan.FromDays(30), ct);
 
// Later, the client refreshes:
var next = await store.RotateAsync(refresh.Token, ct);
if (next is null)
{
    // The token was already rotated, revoked, or expired. Force re-auth.
}

Concurrent rotations of the same token are race-safe: exactly one caller wins, every other concurrent attempt gets null.

TestJwksServer.CreateCognitoIssuer()

Pairs a test JWKS server with a CognitoTokenIssuer that signs against its current key and uses its issuer URL. Tokens validate against the same server's JWKS without further setup.

await using var server = await TestJwksServer.StartAsync();
var issuer = server.CreateCognitoIssuer();
 
var token = issuer.MintAccessToken(new CognitoAccessTokenRequest
{
    Sub = userId,
    ClientId = "test-client",
    Lifetime = TimeSpan.FromHours(1),
});

The extension lives in this package, not in Trax.Api.Auth.Jwt.Testing, so consumers of the testing package don't pull in Cognito issuance transitively.

End-to-end round trip

A token minted here validates through UseCognito into a TraxPrincipal with the same shape a real Cognito-issued token produces.

await using var server = await TestJwksServer.StartAsync();
var issuer = server.CreateCognitoIssuer();
 
services.AddTraxJwtAuth<CognitoJwtPrincipalResolver>(jwt =>
{
    jwt.UseCognito("us-east-1", "us-east-1_TestPool", "test-client").AllowHttpMetadata();
    jwt.CustomizeBearerOptions(opts =>
    {
        opts.Authority = server.Issuer;
        opts.RequireHttpsMetadata = false;
    });
});
 
var idToken = issuer.MintIdToken(new CognitoIdTokenRequest
{
    Sub = userId,
    ClientId = "test-client",
    Lifetime = TimeSpan.FromHours(1),
    Email = "alice@example.com",
    EmailVerified = true,
    Identities = new[]
    {
        new FederatedIdentity("108222222222", "Google", "Google", Primary: true, DateCreated: DateTimeOffset.UtcNow),
    },
});

After validation the resolved principal has PrincipalType = "cognito", Roles populated from cognito:groups, and a Claims["identity_provider"] = "Google" entry from the identities array.

Caveats

  • The issuer matches the real-Cognito wire format, but Trax does not ship the rest of the Cognito API surface (SignUp, InitiateAuth, hosted UI, MFA, Lambda triggers). Hosts that need a full local-Cognito service build it on top of these primitives. See the package's README for context.
  • AdditionalClaims is IReadOnlyDictionary<string, string>. Cognito custom attributes that need non-string types (arrays, booleans, nested objects) are not expressible through this API.
  • The signing-key KeyId is required. Missing it would produce tokens whose kid header is empty, breaking JWKS lookup at the validator.

SDK Reference

> UseCognito | JWT Testing | AddTraxJwtAuth