Skip to content

Changelog - 2026-08-19

Service-to-service authentication, phase 1

New Feature Security Enhancement Breaking Change

In one line. A route can name any authentication strategy you register, and a JWT can now say who issued it and who it is for.

ts
// The strategy name is yours. No cast.
@get({ path: '/orders', authenticate: { strategies: ['service'] } })

// The issuer stamps who it is, and who the token is for.
jwtOptions: {
  standard: 'JWKS',
  options: { mode: 'issuer', sign: { issuer: 'identity', audience: 'inventory' }, /* ... */ },
}

// The verifier refuses anything not addressed to it.
jwtOptions: {
  standard: 'JWKS',
  options: { mode: 'verifier', verify: { audience: 'inventory', maxTokenAge: '60 seconds' }, /* ... */ },
}

What changed

  • TAuthStrategy accepts any registered name. 'jwt' and 'basic' still autocomplete; anything you register with AuthenticationStrategyRegistry now typechecks without a cast.
  • A misspelled strategy is reported at boot, at error level, naming both the typo and what was registered. Previously it compiled only for the two built-ins and, once the type opened, would have 401'd every request with nothing above debug level to explain it.
  • verify checks claims. audience, issuer, subject, algorithms, clockTolerance, maxTokenAge, typ and requiredClaims, passed straight to jose.
  • sign stamps claims, and generate({ payload, claims }) sets per-token aud / sub / jti.
  • securitySchemes on the API reference, so a route naming your strategy publishes a valid OpenAPI document.
  • AuthenticationStrategyRegistry.has() / .getNames() / .findUnregistered() / .assertRegistered() for building your own startup checks.

Who is affected

  • Anyone registering a custom strategy. The cast at every route goes away. Declare a securitySchemes entry for your strategy name, or your generated OpenAPI document names a scheme it never declares.
  • Anyone running more than one service off one issuer. Set verify.audience. Without it, a token minted for one service is accepted verbatim by every other - there is no cryptographic notion of "this token was meant for me".
  • Anyone importing a deprecated alias. QueueHelper, TNodePostgresConnector and TNodePostgresTransactionConnector are removed. Pure renames - see Breaking changes.
  • Anyone importing any Swagger* symbol. SwaggerComponent, ISwaggerOptions and SwaggerBindingKeys are all removed in this release. It is a rename - see Breaking changes.
  • Everyone else. No action needed. Every new option is optional and unset means today's behaviour, byte for byte.

Breaking changes

The deprecated Swagger* aliases are removed

All three go together, so nothing is left half-renamed:

RemovedUse instead
SwaggerComponentApiReferenceComponent
ISwaggerOptionsIApiReferenceOptions
SwaggerBindingKeys.SWAGGER_OPTIONSApiReferenceBindingKeys.API_REFERENCE_OPTIONS
ts
// Before
import { ISwaggerOptions, SwaggerBindingKeys, SwaggerComponent } from '@venizia/ignis';

this.bind<ISwaggerOptions>({ key: SwaggerBindingKeys.SWAGGER_OPTIONS }).toValue({ /* ... */ });
this.component(SwaggerComponent);

// After
import { ApiReferenceBindingKeys, ApiReferenceComponent, IApiReferenceOptions } from '@venizia/ignis';

this.bind<IApiReferenceOptions>({ key: ApiReferenceBindingKeys.API_REFERENCE_OPTIONS }).toValue({ /* ... */ });
this.component(ApiReferenceComponent);

It is a rename only. SwaggerBindingKeys.SWAGGER_OPTIONS always held the same '@app/api-reference/options' string, so no binding moves and no runtime behaviour changes.

Every deprecated alias is removed

The compatibility aliases are gone rather than marked. Each is a pure rename with no behaviour attached:

RemovedUse insteadPackage
QueueHelperSequentialQueueHelperhelpers
TNodePostgresConnectorTRelationalConnectorconnectors
TNodePostgresTransactionConnectorTRelationalConnectorconnectors

TRelationalConnector covers both Postgres cases: NodePgDatabase and PostgresJsDatabase both extend PgDatabase, so the driver-specific aliases described a distinction that stopped existing when the relational tier went engine-neutral.

A configured iss / aud now wins over the payload

WARNING

Only for applications that already smuggle iss or aud through the token payload.

A configured sign.issuer / sign.audience is authoritative. It overrides a claim of the same name on the payload, and logs a warning when it does.

Before - the payload was the only way to set these, and it won:

ts
await tokenService.generate({ payload: { userId, aud: 'inventory' } });

After - configure the default, and pass per-token claims explicitly:

ts
// once, in the issuer options
sign: { issuer: 'identity', audience: 'inventory' }

// per token, when it genuinely varies
await tokenService.generate({ payload: { userId }, claims: { audience: 'inventory' } });

The reason is not tidiness. iss and aud are standard JWT fields, so they pass through the AES payload envelope in the clear - an issuer identity that any payload could overwrite is not an identity. With sign unset, nothing fires and tokens are unchanged.

Details

The registry is the source of truth for a strategy name

AuthenticateStrategy.isValid knows the two built-ins only, and returns false for a strategy you registered. It stays as a public export, but never build a check on it - ask the registry:

ts
AuthenticationStrategyRegistry.getInstance().has({ name: 'service' });
AuthenticationStrategyRegistry.getInstance().getNames();

The framework reports unregistered names while building route configs, which happens after registration. It reports rather than throws, deliberately:

  • defineAuthController hard-codes 'jwt' on four of its routes. An application that registers its JWT strategy under a custom name - the configuration this release adds support for - would stop booting.
  • A route listing several strategies in mode: 'any' tolerates one that does not resolve. That is a working setup today.

An empty strategies array is never reported: that is how authenticate: { skip: true } is encoded.

If you want the hard failure, ask for it in your own startup:

ts
AuthenticationStrategyRegistry.getInstance().assertRegistered({ names: ['service', 'jwt'] });

Audience matching is overlap, not equality

jose accepts a token whose aud contains the configured value. A token carrying ['commerce','inventory'] satisfies verify.audience: 'inventory'. If you want strict single-audience tokens, enforce that when you issue them.

maxTokenAge is the replay window

exp is chosen by whoever minted the token. maxTokenAge caps elapsed time since iat regardless, which is what a short-lived per-request assertion actually needs.

Diagnosing a rejection

Every verification failure still surfaces as one TOKEN_INVALID, but the jose error survives on cause - JWTClaimValidationFailed: unexpected "aud" claim value rather than a generic "invalid or expired token". That distinction is the difference between an afternoon and a week during a fleet-wide rollout.

FilePackage
src/base/auth/authenticate/common/constants.tskernel
src/base/auth/authenticate/common/types.tskernel
src/base/auth/authenticate/strategies/strategy-registry.tskernel
src/base/auth/base/abstract-auth-registry.tskernel
src/components/auth/authenticate/services/bearer/core
src/components/api-reference/core