A minimal OIDC (OpenID Connect) resource server integration for @chubbyts/chubbyts-http-types: resolves the issuer's openid configuration, verifies JWT bearer tokens against its JWKS and passes the verified claims to the handler via request attributes.
It works with any implementation of the types (e.g. @chubbyts/chubbyts-http): the request headers get read, the
oidc attribute gets set and the 401 responses get created and changed through the injected factory and helpers
only.
The library is the resource server: it verifies the access token of a request, no matter how the client obtained it. The following flow diagrams show the common ways, the responsibilities of each party and the parts of this library involved:
- Frontend flow: a browser app (SPA) logs the user in with the Authorization Code Flow with PKCE and calls the backend with the access token.
- Backend flow: a server-side web app (backend for frontend) logs the user in, keeps the tokens in a session behind a cookie and calls the backend with the access token.
- Machine-to-machine flow: a service gets an access token for itself with the Client Credentials Grant, no user involved, and calls the backend with it.
- node: >=22
- @chubbyts/chubbyts-dic-config-factory: ^1.0.1
- @chubbyts/chubbyts-dic-types: ^2.3.1
- @chubbyts/chubbyts-log-types: ^3.3.1
- @chubbyts/chubbyts-http-types: ^4.0.0
- jose: ^6.2.12
Through NPM as @chubbyts/chubbyts-http-oidc.
npm i @chubbyts/chubbyts-http-oidc@^1.0.0import { createResponseFactory, createStreamFactory } from '@chubbyts/chubbyts-http/message-factory';
import { createResponseHelpers, createServerRequestHelpers } from '@chubbyts/chubbyts-http/message-helper';
import type { Handler } from '@chubbyts/chubbyts-http-types/handler';
import type { Response, ServerRequest } from '@chubbyts/chubbyts-http-types/message';
import { createOidcConfigurationResolver } from '@chubbyts/chubbyts-http-oidc/discovery';
import type { OidcAttributes } from '@chubbyts/chubbyts-http-oidc/middleware';
import { createOidcAuthenticationMiddleware } from '@chubbyts/chubbyts-http-oidc/middleware';
import { createBearerTokenExtractor, createJwtTokenVerifier } from '@chubbyts/chubbyts-http-oidc/token';
// any implementation of @chubbyts/chubbyts-http-types, here @chubbyts/chubbyts-http
const serverRequestHelpers = createServerRequestHelpers();
const responseFactory = createResponseFactory();
const responseHelpers = createResponseHelpers();
const streamFactory = createStreamFactory();
const oidcAuthenticationMiddleware = createOidcAuthenticationMiddleware(
responseFactory,
responseHelpers,
serverRequestHelpers,
createBearerTokenExtractor(serverRequestHelpers),
createJwtTokenVerifier(createOidcConfigurationResolver('https://issuer.example.com'), {
audience: 'https://api.example.com',
}),
'api',
);
// add the middleware to the routes you want to protect, e.g. within chubbyts-framework:
// createGroup({ path: '/api', ..., middlewares: [oidcAuthenticationMiddleware, ...] })
const handler: Handler<OidcAttributes> = async (serverRequest: ServerRequest<OidcAttributes>): Promise<Response> => {
const { claims } = serverRequest.attributes.oidc; // { token: string, claims: JWTPayload }
return responseHelpers.withHeader(
responseHelpers.withBody(responseFactory(200), streamFactory(JSON.stringify({ sub: claims.sub }))),
'content-type',
'application/json',
);
};The middleware is typed as Middleware<Attributes, OidcAttributes>: the handler behind it gets a
ServerRequest<OidcAttributes>.
- Audience:
audienceis required and must match theaudclaim your authorization server puts into access tokens for your API, otherwise any token of the issuer (even for other APIs, or ID tokens) would be accepted. If your server issues RFC 9068 access tokens (typ: at+jwtheader), passtyp: 'at+jwt'too. - Rejected requests: Without a valid token the handler is not called and a
401with a RFC 6750 challenge is returned:WWW-Authenticate: Bearer realm="api"(missing token) orBearer realm="api", error="invalid_token", error_description="The access token is invalid or expired"(invalid token). The actual reason (expired, wrong signature, ...) is only logged (levelinfo) via the optional logger, never sent to the client. Errors not related to the token (unreachable issuer, ...) are rethrown, so your error handling responds with a5xx. - Browser clients: Allow the
Authorizationrequest header and expose theWWW-Authenticateresponse header within your cors configuration. - Token in the request attribute: The
oidcattribute carries the raw bearer token (token) next to the verifiedclaims, so that handlers can forward it to downstream apis. Treat the attribute as sensitive: do not dump the request attributes into logs, error reports or responses.
import { createOidcConfigurationResolver } from '@chubbyts/chubbyts-http-oidc/discovery';
import { createOidcAuthenticationMiddleware } from '@chubbyts/chubbyts-http-oidc/middleware';
import { createBearerTokenExtractor, createJwtTokenVerifier } from '@chubbyts/chubbyts-http-oidc/token';
// resolves and caches {issuer}/.well-known/openid-configuration, lazily on first token verification
const oidcConfigurationResolver = createOidcConfigurationResolver('https://issuer.example.com', {
fetch, // custom fetch for the discovery request, default: globalThis.fetch
maxAge: 3600, // seconds a resolved configuration is cached (non-negative), default: 3600
timeout: 5, // seconds until the discovery request is aborted (non-negative), default: 5
cooldown: 30, // seconds until a failed (re)fetch is retried (non-negative), default: 30
});
// verifies signature (via the issuer's JWKS), "iss", "aud", "exp", "nbf" and returns the claims
const tokenVerifier = createJwtTokenVerifier(oidcConfigurationResolver, {
audience: 'https://api.example.com', // string | Array<string>, required (non-empty, enforced at runtime)
algorithms: ['RS256'], // non-empty subset of SUPPORTED_ALGORITHMS, default: SUPPORTED_ALGORITHMS
clockTolerance: 5, // seconds (0 to MAX_CLOCK_TOLERANCE: 300), default: 0
typ: 'at+jwt', // expected "typ" header (non-empty), default: not checked
requiredClaims: ['sub', 'iat', 'jti'], // additionally required claims (non-empty strings), "iss", "aud" and "exp" always are
fetch, // custom fetch for the jwks requests, default: globalThis.fetch
jwksMaxAge: 600, // seconds a fetched jwks is cached (positive, 0 would refetch it with every verification), default: 600
jwksTimeout: 5, // seconds until a jwks request is aborted (non-negative), default: 5
jwksCooldown: 30, // seconds until a failed jwks (re)fetch is retried, and between refetches for unknown key ids (non-negative), default: 30
jwksMaxStale: 86400, // seconds an expired jwks keeps being used while its refetch fails (non-negative, 0: never, Infinity: for as long as the outage lasts), default: 3600
});
const oidcAuthenticationMiddleware = createOidcAuthenticationMiddleware(
responseFactory, // creates the 401 response
responseHelpers, // sets the www-authenticate header of the 401 response
serverRequestHelpers, // sets the oidc attribute
createBearerTokenExtractor(serverRequestHelpers), // reads the "Authorization: Bearer <token>" header (exactly one)
tokenVerifier,
'api', // realm within the challenge, optional
logger, // @chubbyts/chubbyts-log-types compatible logger, optional, default: no-op
);- Issuer: Must be exactly the
issuerfrom the openid configuration (issclaim),https://issuer.example.comandhttps://issuer.example.com/are not the same. Only absolutehttp(s)urls are accepted. Usehttpsin production, whoever can tamper with an unprotected discovery or jwks response can forge tokens your api accepts, plainhttpis only meant for local development. Ahttpsissuer advertising a plainhttpjwks_uriis rejected in any case, and neither the discovery nor the jwks request follows redirects (ahttps→httpredirect would silently bypass these checks). - Algorithms: Only asymmetric signature algorithms are supported (
SUPPORTED_ALGORITHMSwithin@chubbyts/chubbyts-http-oidc/token:EdDSA,Ed25519,ES256,ES384,ES512,ML-DSA-44,ML-DSA-65,ML-DSA-87,PS256,PS384,PS512,RS256,RS384,RS512): a public (jwks) key must never be usable as a hmac secret (algorithm confusion). Anything else, includingHS*, is rejected at construction time (algorithmsoption) or verification time (token header). - Options: Invalid options (empty or unsupported
algorithms, negative or non-numeric durations, infinite timeouts, emptyaudience) throw at construction time instead of silently rejecting every token. - JWKS: Fetched from the
jwks_uriof the openid configuration and cached in memory forjwksMaxAge, an unknown key id (key rotation) triggers a refetch, but at most once perjwksCooldown. - Outages: If the issuer is unreachable while the cached configuration or jwks is expired, the last known one keeps being used (a refetch is retried after
cooldown/jwksCooldown), so a temporary issuer outage does not take your api down. Only if there never was a successful fetch the error is thrown (5xx), within the cooldown immediately without hitting the issuer again. Be aware that a stale jwks still contains keys the issuer removed in the meantime (e.g. a compromised one), so tokens signed with them stay valid while the stale jwks is used:jwksMaxStalebounds this window (default: one hour, afterjwksMaxAge + jwksMaxStalesince the last successful fetch, verification fails with the last jwks error until a refetch succeeds),0disables serving a stale jwks altogether,Infinitykeeps using it for as long as the outage lasts. A failed or invalid discovery / jwks response is reported asOidcConfigurationError/JwksError(@chubbyts/chubbyts-http-oidc/error, with the original error ascause), errors of the fetch implementation itself (dns, connection refused, ...) are passed through as they are. - Custom verifier: A
TokenVerifieris just(token: string) => Promise<JWTPayload>. Throw anInvalidTokenError(@chubbyts/chubbyts-http-oidc/error) to get the401response, any other error is rethrown.
The package ships service factories (abstract factories built on chubbyts-dic-config-factory) for a chubbyts-dic-config (or any chubbyts-dic-types compatible) container within @chubbyts/chubbyts-http-oidc/service-factory, configured through config.chubbyts.oidc. The implementation of the types gets read from the services serverRequestHelpers, responseFactory and responseHelpers, which have to be registered:
import type { ConfigFactory } from '@chubbyts/chubbyts-dic-config/dist/dic-config';
import { createContainerByConfigFactory } from '@chubbyts/chubbyts-dic-config/dist/dic-config';
import type { OidcConfig } from '@chubbyts/chubbyts-http-oidc/service-factory';
import { oidcAuthenticationMiddlewareServiceFactory } from '@chubbyts/chubbyts-http-oidc/service-factory';
import { createResponseFactory } from '@chubbyts/chubbyts-http/message-factory';
import { createResponseHelpers, createServerRequestHelpers } from '@chubbyts/chubbyts-http/message-helper';
import type { Middleware } from '@chubbyts/chubbyts-http-types/middleware';
const container = createContainerByConfigFactory({
chubbyts: {
oidc: {
issuer: 'https://issuer.example.com', // required
audience: 'https://api.example.com', // required
realm: 'api',
// fetch,
// maxAge: 3600,
// timeout: 5,
// cooldown: 30,
// algorithms: ['RS256'],
// clockTolerance: 5,
// typ: 'at+jwt',
// requiredClaims: ['sub', 'iat', 'jti'],
// jwksMaxAge: 600,
// jwksTimeout: 5,
// jwksCooldown: 30,
// jwksMaxStale: 86400,
} satisfies OidcConfig,
},
dependencies: {
factories: new Map<string, ConfigFactory>([
['serverRequestHelpers', () => createServerRequestHelpers()],
['responseFactory', () => createResponseFactory()],
['responseHelpers', () => createResponseHelpers()],
['oidcAuthenticationMiddleware', oidcAuthenticationMiddlewareServiceFactory()],
]),
},
})();
const oidcAuthenticationMiddleware = container.get<Middleware>('oidcAuthenticationMiddleware');The oidcAuthenticationMiddlewareServiceFactory uses the services oidcTokenExtractor, oidcTokenVerifier and (the jwtTokenVerifierServiceFactory behind it) oidcConfigurationResolver of the container if registered, and creates them through the shipped bearerTokenExtractorServiceFactory, jwtTokenVerifierServiceFactory and oidcConfigurationResolverServiceFactory otherwise. Register any of them under its name to replace it (e.g. a custom TokenVerifier) or to share it with other services. A logger service is used if registered.
To protect different parts of an api through different issuers / audiences, the same factories can be registered multiple times with a name: the config is then read from config.chubbyts.oidc.<name> and the name gets appended to each service id (oidcAuthenticationMiddlewareapi, oidcTokenVerifierapi, ...).
const container = createContainerByConfigFactory({
chubbyts: {
oidc: {
api: { issuer: 'https://issuer.example.com', audience: 'https://api.example.com', realm: 'api' },
admin: { issuer: 'https://admin-issuer.example.com', audience: 'https://admin.example.com', realm: 'admin' },
} satisfies Record<string, OidcConfig>,
},
dependencies: {
factories: new Map<string, ConfigFactory>([
// serverRequestHelpers, responseFactory and responseHelpers like above
['oidcAuthenticationMiddlewareapi', oidcAuthenticationMiddlewareServiceFactory('api')],
['oidcAuthenticationMiddlewareadmin', oidcAuthenticationMiddlewareServiceFactory('admin')],
]),
},
})();
const apiOidcAuthenticationMiddleware = container.get<Middleware>('oidcAuthenticationMiddlewareapi');
const adminOidcAuthenticationMiddleware = container.get<Middleware>('oidcAuthenticationMiddlewareadmin');Keycloak as a docker container is the easiest way to test manually:
docker run --rm -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.7 start-devWithin the admin console at http://localhost:8080 (admin/admin) create a realm test and a client api with Client authentication and Service accounts roles enabled, then:
curl -X POST http://localhost:8080/realms/test/protocol/openid-connect/token \
-d grant_type=client_credentials -d client_id=api -d client_secret=<client-secret>const oidcConfigurationResolver = createOidcConfigurationResolver('http://localhost:8080/realms/test');Keycloak specifics: access tokens contain aud: "account" until you add an audience mapper, have the header typ: "JWT" (not at+jwt) and the iss claim matches the URL the token was requested through, so use the same host for the resolver and the token request (or pin it, e.g. KC_HOSTNAME=http://keycloak:8080 in docker compose). See chubbyts-petstore for a complete docker compose setup with an imported realm.
For automated tests mock-oauth2-server is a lightweight alternative which issues tokens without any setup. This repository's integration tests start it via testcontainers (docker compatible daemon required, set MOCK_OAUTH2_SERVER_URL to reuse a running one):
pnpm test:integration --run2026 Dominik Zogg