Skip to content

Changelog - 2026-03-04 ​

Customizable JWT Payload Field Codecs ​

Adds pluggable field codecs to AbstractBearerTokenService, allowing consumers to control how individual JWT payload fields are serialized (encrypt) and deserialized (decrypt). Previously, roles was hardcoded as pipe-delimited and all other fields used `${value}` - silently corrupting arrays and objects.

Overview ​

  • IPayloadFieldCodec<T>: New interface for custom field serialization/deserialization
  • AuthenticationFieldCodecs: New class with built-in ROLES_CODEC and a build() factory method
  • fieldCodecs option: All three token service option interfaces (IJWSTokenServiceOptions, IJWKSIssuerOptions, IJWKSVerifierOptions) accept an optional fieldCodecs array
  • Symmetric JSON serialization: Default fallback uses JSON.stringify/JSON.parse for all types - no more String() corruption

Breaking Changes ​

WARNING

This section contains changes that require migration or manual updates to existing code.

1. Default serialization changed from String() to JSON.stringify ​

Fields without a codec are now serialized via JSON.stringify instead of String(). This means:

  • Strings are now JSON-quoted: "hello" → '"hello"' (encrypted form differs)
  • Numbers and booleans roundtrip correctly: 42 → "42" → 42 (previously stayed as string "42")
  • Existing encrypted tokens using String() serialization will fail to deserialize under the new logic

Before:

typescript
// String "hello" serialized as: hello
// Number 42 serialized as: 42
// Array [1,2] serialized as: 1,2 (corrupted!)

After:

typescript
// String "hello" serialized as: "hello" (JSON)
// Number 42 serialized as: 42 (JSON)
// Array [1,2] serialized as: [1,2] (JSON, correct!)

2. ROLES_CODEC no longer auto-registered ​

The built-in roles codec is no longer automatically applied. If you use AES payload encryption with the roles field, you must explicitly pass ROLES_CODEC in the fieldCodecs array.

Before:

typescript
// ROLES_CODEC was automatically applied - no configuration needed
this.bind<TJWTTokenServiceOptions>({ key: AuthenticateBindingKeys.JWT_OPTIONS }).toValue({
  standard: JOSEStandards.JWS,
  options: {
    jwtSecret: '...',
    applicationSecret: '...',
    getTokenExpiresFn: () => 86400,
  },
});

After:

typescript
import { AuthenticationFieldCodecs } from '@venizia/ignis';

this.bind<TJWTTokenServiceOptions>({ key: AuthenticateBindingKeys.JWT_OPTIONS }).toValue({
  standard: JOSEStandards.JWS,
  options: {
    jwtSecret: '...',
    applicationSecret: '...',
    getTokenExpiresFn: () => 86400,
    fieldCodecs: [AuthenticationFieldCodecs.ROLES_CODEC],
  },
});

New Features ​

IPayloadFieldCodec Interface ​

File: packages/core-server/src/components/auth/authenticate/common/types.ts

Problem: No way to control serialization of custom JWT payload fields. Arrays and objects were silently corrupted by String().

Solution: A generic codec interface that pairs a field key with serialize/deserialize functions.

typescript
interface IPayloadFieldCodec<T = unknown> {
  key: string;
  serialize(opts: { value: T }): string;
  deserialize(opts: { raw: string }): T;
}

AuthenticationFieldCodecs Class ​

File: packages/core-server/src/components/auth/authenticate/common/codecs.ts

Problem: No reusable way to define or share field codecs.

Solution: A class with a built-in ROLES_CODEC and a build() factory method.

typescript
import { AuthenticationFieldCodecs } from '@venizia/ignis';

// Use the built-in roles codec
const rolesCodec = AuthenticationFieldCodecs.ROLES_CODEC;

// Build a custom codec
const permissionsCodec = AuthenticationFieldCodecs.build<string[]>({
  key: 'permissions',
  serialize(opts) { return opts.value.join(','); },
  deserialize(opts) { return opts.raw.split(','); },
});

Benefits:

  • Type-safe: generic <T> flows through serialize/deserialize
  • Reusable: codecs are plain objects, shareable across services
  • build() factory validates structure at the type level

fieldCodecs Option ​

Problem: All token service options lacked codec configuration.

Solution: Added fieldCodecs?: IPayloadFieldCodec[] to IJWSTokenServiceOptions, IJWKSIssuerOptions, and IJWKSVerifierOptions.

typescript
this.bind<TJWTTokenServiceOptions>({ key: AuthenticateBindingKeys.JWT_OPTIONS }).toValue({
  standard: JOSEStandards.JWS,
  options: {
    jwtSecret: process.env.APP_ENV_JWT_SECRET,
    applicationSecret: process.env.APP_ENV_APPLICATION_SECRET,
    getTokenExpiresFn: () => 86400,
    fieldCodecs: [
      AuthenticationFieldCodecs.ROLES_CODEC,
      AuthenticationFieldCodecs.build<string[]>({
        key: 'permissions',
        serialize(opts) { return opts.value.join(','); },
        deserialize(opts) { return opts.raw.split(','); },
      }),
    ],
  },
});

Symmetric JSON Serialization ​

File: packages/core-server/src/components/auth/authenticate/services/bearer/abstract.service.ts

Problem: serializeField used String() for primitives but deserializeField needed try/catch to guess the format - asymmetric and error-prone.

Solution: Both sides now use JSON.stringify/JSON.parse uniformly. No try/catch, no type guessing.

typescript
// serializeField: codec → JSON.stringify
protected serializeField(opts: { key: string; value: any }): string {
  const codec = this.fieldCodecs.get(key);
  if (codec) return codec.serialize({ value });
  return JSON.stringify(value);
}

// deserializeField: codec → JSON.parse
protected deserializeField(opts: { key: string; value: string }) {
  const codec = this.fieldCodecs.get(key);
  if (codec) return codec.deserialize({ raw: value });
  return JSON.parse(value);
}

Files Changed ​

Core Package (packages/core-server) ​

FileChanges
src/components/auth/authenticate/common/types.tsAdded IPayloadFieldCodec<T> interface; added fieldCodecs?: IPayloadFieldCodec[] to IJWSTokenServiceOptions, IJWKSIssuerOptions, IJWKSVerifierOptions
src/components/auth/authenticate/common/codecs.tsNew file - AuthenticationFieldCodecs class with ROLES_CODEC and build() factory
src/components/auth/authenticate/common/constants.tsRemoved ROLES_CODEC (moved to codecs.ts)
src/components/auth/authenticate/common/index.tsAdded export * from './codecs'
src/components/auth/authenticate/services/bearer/abstract.service.tsAdded fieldCodecs Map; updated configurePayloadEncryption to accept codecs; extracted serializeField/deserializeField methods; symmetric JSON serialization
src/components/auth/authenticate/services/bearer/jws.service.tsForwards fieldCodecs to configurePayloadEncryption
src/components/auth/authenticate/services/bearer/jwks/issuer.service.tsForwards fieldCodecs to configurePayloadEncryption
src/components/auth/authenticate/services/bearer/jwks/verifier.service.tsForwards fieldCodecs to configurePayloadEncryption

Migration Guide ​

NOTE

Follow these steps if you're upgrading from a previous version.

Step 1: Add ROLES_CODEC if using AES payload encryption with roles ​

If your JWT payloads include roles and you use applicationSecret for AES encryption, add AuthenticationFieldCodecs.ROLES_CODEC to your options:

typescript
import { AuthenticationFieldCodecs } from '@venizia/ignis';

// In your JWT options
options: {
  // ...existing options
  fieldCodecs: [AuthenticationFieldCodecs.ROLES_CODEC],
},

Step 2: Rotate tokens (if using AES encryption) ​

The default serialization changed from String() to JSON.stringify. Existing encrypted tokens will not deserialize correctly under the new format. Plan a token rotation (e.g., force re-login) after upgrading.

Step 3: Replace custom serialization workarounds ​

If you had custom logic working around the old String() corruption (e.g., post-processing array fields after decryption), replace it with a proper field codec:

typescript
const myArrayCodec = AuthenticationFieldCodecs.build<string[]>({
  key: 'tags',
  serialize(opts) { return JSON.stringify(opts.value); },
  deserialize(opts) { return JSON.parse(opts.raw); },
});