Authentication Patterns

Add OAuth2 bearer token (JWT) validation or HTTP Basic authentication to a Benzene service that has no security-terminating gateway in front of it, plus scope-, role-, policy-, and resource-based authorization.

Problem Statement

Most Benzene services run behind something that already handles authentication — API Gateway with a Cognito/Lambda authorizer, an Azure Front Door with auth rules, a load balancer with OIDC. In that setup Benzene shouldn't duplicate that work, and it doesn't: there's nothing to configure.

But not every deployment has that in front of it — a self-hosted Express worker, a Lambda exposed directly, or a deployment where the gateway's auth story isn't wired up yet. For those, you need authentication at the Benzene level itself, as opt-in middleware you add only where you need it.

This cookbook covers:

Benzene provides the authorization enforcement primitives and pluggable policy/resource seams, but no built-in adapter for a specific external policy engine — see What This Doesn't Cover.

Prerequisites

Installation

npm install @benzene/auth-oauth2
# or, for Basic auth
npm install @benzene/auth-basic

Both depend on @benzene/auth-core (the shared ClaimsPrincipal/authorization primitives), which npm pulls in transitively. @benzene/auth-oauth2 adapts jose — the popular Node JWT/JWKS library — as its one runtime dependency (the analog of the .NET package's Microsoft.IdentityModel stack), so the same middleware works identically behind Express, Lambda, or Azure Functions. @benzene/auth-basic has no third-party dependency.

How the middleware composes

Every use*/require* function here is ordinary Benzene pipeline middleware: a free function that takes the HTTP builder as its first argument (the port-wide convention for downstream fluent extensions — see Middleware) and registers itself in front of your handlers. Authentication runs first and sets the caller; authorization runs next and reads it. Wire them onto the same builder you pass to useMessageHandlers, authentication before authorization before the handlers:

useApiGateway(app, (api) => {
  useOAuth2Bearer(api, oauth2Options); // 1. authenticate: sets the principal (or 401s)
  requireScope(api, 'orders:write');   // 2. authorize: reads the principal (or 401/403s)
  useMessageHandlers(api, CreateOrderHandler); // 3. the protected route(s)
});

The authenticated caller is handed from the authentication middleware to later steps through AuthenticationHolder — a small scoped (per-message) DI holder carrying a ClaimsPrincipal, not a property on the transport context. This is the port's "context purity" pattern: a TContext describes the shape of a transport message and stays free of optional cross-cutting state, so a pipeline that never adds an authentication middleware never even allocates a holder. ClaimsPrincipal, ClaimsIdentity, Claim, and ClaimTypes are a minimal port of System.Security.Claims living in @benzene/auth-core — the .NET auth stack carries the caller as a BCL ClaimsPrincipal every JWT library already produces, and JavaScript has no such shared type, so the small slice the middleware actually reads is ported rather than inventing a Benzene-specific principal.

OAuth2 Bearer Token Validation

useOAuth2Bearer takes an OAuth2BearerOptions instance. Every field is deliberately required with no permissive silent default — see Why validAlgorithms has no default.

import { OAuth2BearerOptions, useOAuth2Bearer } from '@benzene/auth-oauth2';

const oauth2Options = new OAuth2BearerOptions();

// Full OIDC discovery - most identity providers expose this. Use jwksUri instead for one that only
// publishes a bare JWKS document (set exactly one of the two, never both).
oauth2Options.authority = 'https://your-tenant.auth0.com/.well-known/openid-configuration';

// Required, no default - an empty list would accept tokens from any issuer/audience.
oauth2Options.validIssuers = ['https://your-tenant.auth0.com/'];
oauth2Options.validAudiences = ['your-api-identifier'];

// Required, no default - see "Why validAlgorithms has no default" below.
oauth2Options.validAlgorithms = ['RS256'];

useApiGateway(app, (api) => {
  useOAuth2Bearer(api, oauth2Options);
  useMessageHandlers(api, CreateOrderHandler);
});

Requests without a valid bearer token — missing header, malformed, expired, bad signature, wrong issuer/audience/algorithm — are short-circuited with the unauthorized status (mapped to HTTP 401 by every Benzene HTTP transport — see the status table). A successfully validated token's claims become available to later middleware and handlers via AuthenticationHolder.principal. Each JWT payload entry becomes a Claim (a JSON-array claim value becomes one claim per element, so scp/roles arrays read element by element downstream).

OAuth2BearerOptions also carries two optional fields:

Field Default Meaning
clockToleranceSeconds 120 Clock-skew tolerance applied to exp/nbf (the port of C#'s ClockSkew TimeSpan, in seconds to match jose).
requireHttpsMetadata true Whether the authority/jwksUri must be fetched over HTTPS. Set false only for local testing against a plain-HTTP fake JWKS endpoint — never in production.

Provider-specific examples

Auth0:

const options = new OAuth2BearerOptions();
options.authority = 'https://your-tenant.auth0.com/.well-known/openid-configuration';
options.validIssuers = ['https://your-tenant.auth0.com/'];
options.validAudiences = ['https://your-api-identifier'];
options.validAlgorithms = ['RS256'];

AWS Cognito:

const options = new OAuth2BearerOptions();
options.authority =
  'https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/openid-configuration';
options.validIssuers = ['https://cognito-idp.{region}.amazonaws.com/{userPoolId}'];
options.validAudiences = ['{appClientId}'];
options.validAlgorithms = ['RS256'];

Azure AD:

const options = new OAuth2BearerOptions();
options.authority = 'https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration';
options.validIssuers = ['https://login.microsoftonline.com/{tenantId}/v2.0'];
options.validAudiences = ['{applicationIdUri or clientId}'];
options.validAlgorithms = ['RS256'];

Any OIDC-compliant provider works the same way — point authority at its discovery document (or set jwksUri to a bare JWKS URL) and fill in the issuer/audience/algorithm your provider issues.

Why validAlgorithms has no default

Every option above is required with no permissive fallback, but validAlgorithms specifically guards against a well-known class of attack: "algorithm confusion" (RFC 8725 §3.1). If a validator trusted whatever alg a token's own header claimed, an attacker could take a service's RSA public key (routinely not secret) and use it as an HMAC secret to forge an HS256-signed token the validator would accept as if it were one of the service's real RS256 tokens. Requiring an explicit, non-empty allowlist is what closes that off — OAuth2BearerOptions.validate() (called by useOAuth2Bearer at wire-up) throws if validAlgorithms, validIssuers, or validAudiences is empty, or if authority and jwksUri aren't set exactly one at a time, so a misconfigured pipeline fails at startup, not silently on the first request.

Basic Authentication

For a simpler gate than full OAuth2 — a single service account, an internal admin surface — implement IBasicAuthCredentialValidator and pass it to useBasicAuth:

import { Claim, ClaimsIdentity, ClaimsPrincipal, ClaimTypes } from '@benzene/auth-core';
import { IBasicAuthCredentialValidator } from '@benzene/auth-basic';

export class ServiceAccountValidator implements IBasicAuthCredentialValidator {
  async validateAsync(username: string, password: string): Promise<ClaimsPrincipal | undefined> {
    const expectedPassword = process.env.SERVICE_ACCOUNT_PASSWORD;
    if (username !== 'service-account' || password !== expectedPassword) {
      return undefined; // C# `null` -> `undefined`: an ordinary Unauthorized, not an error to throw
    }

    const identity = new ClaimsIdentity([new Claim(ClaimTypes.name, username)], 'Basic');
    return new ClaimsPrincipal(identity);
  }
}
import { useBasicAuth } from '@benzene/auth-basic';

useApiGateway(app, (api) => {
  useBasicAuth(api, new ServiceAccountValidator()); // optional 3rd arg: realm, defaults to "Benzene"
  useMessageHandlers(api, AdminHandler);
});

@benzene/auth-basic ships no default IBasicAuthCredentialValidator on purpose — hardcoding a credential store in the package would be a footgun waiting to be deployed. Implement it against whatever your service actually uses: an environment variable for a single service account (above), a secrets manager, a user table. Return undefined for bad credentials — never throw; that's an ordinary unauthorized, not an application error.

A missing/malformed Authorization: Basic header or a validator returning undefined produces unauthorized (401) with a WWW-Authenticate: Basic realm="..." challenge header, per RFC 7617 — this is what makes browsers and HTTP clients that understand Basic auth actually prompt for credentials. The middleware splits the decoded credentials on the first : only (RFC 7617 allows : inside the password) and strictly validates the Base64 before decoding (Node's Buffer.from(..., 'base64') silently drops invalid characters, so malformed input is rejected rather than mis-decoded).

Scope-Based Authorization

Once a caller is authenticated via useOAuth2Bearer, require a scope before reaching a route:

import { requireScope, useOAuth2Bearer } from '@benzene/auth-oauth2';

useApiGateway(app, (api) => {
  useOAuth2Bearer(api, oauth2Options);
  requireScope(api, 'orders:write');
  useMessageHandlers(api, CreateOrderHandler);
});

requireScope reads both the scope claim (RFC 8693's convention — a single space-delimited string) and the scp claim (Azure AD's convention — a space-delimited string or a JSON array, depending on issuer) and normalizes them into one set, so it works the same regardless of which convention your identity provider uses. Pass multiple scopes to require any one of them:

requireScope(api, 'orders:write', 'orders:admin'); // either scope is sufficient

Two distinct failure statuses matter here, and requireScope keeps them distinct on purpose:

Situation Status Meaning
No Authorization header at all, or the token failed validation unauthorized (401) Caller not authenticated
A validly authenticated caller, but missing every required scope forbidden (403) Caller authenticated but not permitted

Collapsing these into one status would leave an API consumer unable to tell "you're not logged in" apart from "you're logged in but don't have permission" from the response code alone.

Authorization: Roles, Policies & Resource Checks

Scopes are an OAuth2/JWT concept, so requireScope lives in @benzene/auth-oauth2. For authorization that reads off any authenticated caller — regardless of whether the principal came from a bearer token, Basic auth, or a custom mechanism — @benzene/auth-core adds three mechanism-agnostic checks. Benzene owns the enforcement (each short-circuits with unauthorized when there's no caller and forbidden when the caller lacks permission, the same distinction requireScope keeps); what a role/policy means stays in your app.

All three compose the same way as requireScope, and on any transport (not just HTTP) whose pipeline sets a principal.

Roles — requireRole

import { requireRole } from '@benzene/auth-core';

requireRole(api, 'admin', 'operator'); // any one role is sufficient

Roles are read from the caller's ClaimsPrincipal.isInRole and the common role claim types (ClaimTypes.role, role, roles), with the roles claim also accepted as a JSON array — Azure AD's app-roles shape — so it works whether your issuer emits one claim per role or a single array.

Named or inline policies — requirePolicy

A policy is a named rule over the principal. Define it inline, or register it once and reference it by name:

import { addAuthorizationPolicy, requirePolicy } from '@benzene/auth-core';

// Inline (a sync or async predicate over the principal), named for the failure detail message:
requirePolicy(api, 'employees-only', (principal) => principal.hasClaim('department', 'engineering'));

// Or register once (in configureServices, or via api.register), reference by name from many pipelines:
const entryPoint = new InlineAwsLambdaStartUp()
  .configureServices((services) => {
    addBenzene(services);
    addAuthorizationPolicy(services, 'mfa-required', (principal) =>
      principal.hasClaim((c) => c.type === 'amr' && c.value === 'mfa'),
    );
  })
  .configure((app) =>
    useApiGateway(app, (api) => {
      useOAuth2Bearer(api, oauth2Options);
      requirePolicy(api, 'mfa-required'); // resolved from DI by name, per message
      useMessageHandlers(api, TransferFundsHandler);
    }),
  )
  .build();

For anything more involved than a predicate, implement IAuthorizationPolicy (a name plus isSatisfiedAsync(principal): Promise<boolean>) and pass the instance to requirePolicy(api, policy) or register it with addAuthorizationPolicy(services, policy). The inline overloads are backed by DelegateAuthorizationPolicy, which normalizes a sync-or-async predicate to a promise. A requirePolicy(api, name) with no matching registered policy throws a teaching error at request time naming the fix (register one with addAuthorizationPolicy).

Resource-based checks — requireAuthorization

When the decision depends on which resource is being acted on (ownership, tenant match), implement IAuthorizationHandler<TResource> and map the message to the resource it targets:

import { ClaimsPrincipal, IAuthorizationHandler, requireAuthorization } from '@benzene/auth-core';
import { ApiGatewayContext } from '@benzene/aws-lambda-api-gateway';

class OrderResource {
  constructor(readonly tenant: string) {}
}

class SameTenantHandler implements IAuthorizationHandler<OrderResource> {
  isAuthorizedAsync(principal: ClaimsPrincipal, resource: OrderResource): Promise<boolean> {
    return Promise.resolve(principal.hasClaim('tenant', resource.tenant));
  }
}

useApiGateway(app, (api) => {
  useOAuth2Bearer(api, oauth2Options);
  api.register((x) =>
    x.addScopedFactory(
      IAuthorizationHandler,
      () => new SameTenantHandler() as IAuthorizationHandler<unknown>,
    ),
  );
  // The resource is derived from the transport context (here, a query-string value).
  requireAuthorization(
    api,
    (context: ApiGatewayContext) =>
      new OrderResource(context.apiGatewayProxyRequest.queryStringParameters?.tenant ?? ''),
  );
  useMessageHandlers(api, GetOrderHandler);
});

Because C#'s open-generic registration (AddScoped(typeof(IAuthorizationHandler<>), ...)) has no erased-generic equivalent in TypeScript, every resource handler registers under the one shared IAuthorizationHandler service token (the <unknown> precedent used across the port), and requireAuthorization casts the resolved handler back to its TResource.

The resource is derived from the transport context (topic, headers, a route/query value) because authorization runs before the pipeline maps the request into a typed handler argument. When a decision needs the fully-deserialized request, do that check inside the handler against the same AuthenticationHolder principal instead.

Protecting Only Some Routes

Every use*/require* call here is ordinary middleware — add it in front of the routes that need it, not necessarily the whole pipeline. The straightforward way is to protect the entire pipeline (one transport builder, authentication first, useMessageHandlers last) when every route in the service needs it, exactly as the examples above do.

For a genuine mix of public and protected routes, the natural split differs by host:

On the Express host, benzene(...) is strangler-fig middleware: it only claims the method+path combinations it has handlers for and calls Express next() for everything else. So two benzene(...) mounts coexist cleanly — each builds its own DI container and only answers its own routes:

import express from 'express';
import { useMessageHandlers } from '@benzene/core-message-handlers';
import { benzene } from '@benzene/express';
import { useOAuth2Bearer, requireScope } from '@benzene/auth-oauth2';

const app = express();

// Public routes - no auth middleware.
app.use(benzene((pipeline) => useMessageHandlers(pipeline, PublicStatusHandler)));

// Protected routes - their own pipeline with auth in front, mounted under a URL prefix.
app.use(
  '/admin',
  benzene((pipeline) => {
    useOAuth2Bearer(pipeline, oauth2Options);
    requireScope(pipeline, 'admin');
    useMessageHandlers(pipeline, AdminHandler);
  }),
);

app.listen(3000);

On AWS Lambda / Azure Functions, the deployment boundary is the split: point each protected route group at its own function with its own entry point and auth middleware, and leave public functions without it. This is a wiring choice, not a rewrite — the same handlers move between grouped and protected functions unchanged.

Either way, keep authentication and authorization on the same builder as the handlers they protect: a short-circuiting middleware only guards steps registered after it on its own pipeline.

Try It

The auth packages' own test suite is the best runnable reference for exactly this wiring without a real identity provider. test/Benzene.Core.Test/Auth/ drives useOAuth2Bearer and requireScope against a locally-generated fake JWKS server (FakeJwksServer.ts, plain-HTTP loopback — hence requireHttpsMetadata = false), through the API Gateway HTTP host, and asserts on the resulting 200 / 401 / 403 status codes:

import { describe, expect, it } from 'vitest';
import { OAuth2BearerOptions, requireScope, useOAuth2Bearer } from '@benzene/auth-oauth2';
import { createSecureEvent, runSecure } from './authHost';
import { FakeJwksServer, withJwks } from './FakeJwksServer';

describe('scope-protected route', () => {
  it('a valid token missing the scope is Forbidden, not Unauthorized', async () => {
    await withJwks(async (jwks) => {
      const key = await jwks.addKey('kid1');
      const token = await FakeJwksServer.createToken(
        key,
        'kid1',
        'https://issuer.example.com',
        'my-api',
        { scope: 'read write' }, // authenticated, but no 'admin' scope
      );

      const options = new OAuth2BearerOptions();
      options.jwksUri = jwks.jwksUri;
      options.validIssuers = ['https://issuer.example.com'];
      options.validAudiences = ['my-api'];
      options.validAlgorithms = ['RS256'];
      options.requireHttpsMetadata = false;

      const response = await runSecure((api) => {
        useOAuth2Bearer(api, options);
        requireScope(api, 'admin');
      }, createSecureEvent(`Bearer ${token}`));

      expect(response.statusCode).toBe(403); // authenticated but not permitted
    });
  });
});

test/Benzene.Core.Test/Auth/AuthorizationTest.test.ts shows the same shape for requireRole / requirePolicy / requireAuthorization (seeding a principal directly to exercise the authorization primitives in isolation, plus one end-to-end case composing real useBasicAuth with requireRole). See Testing Benzene for the full testing guide and Mocking External Dependencies for faking a validator or authorization handler.

Security Notes

What This Doesn't Cover

Further Reading