@smb-tech/service-framework-js
Reusable TypeScript utilities for SMB Tech Node.js services.
Intended use: this package is maintained for SMB Tech and QuickFade services. It is published to npm for controlled reuse across BFFs, APIs, microservices, gateways, and internal services. It is not a general-purpose community framework.
Overview
@smb-tech/service-framework-js centralizes cross-cutting service behavior:
- request tracing and B3-compatible propagation
- request-scoped MDC backed by one
AsyncLocalStorage - structured logging through
@smb-tech/logger-coreand@smb-tech/logger-node - deep sensitive-data redaction
- outbound HTTP clients with timeouts, retries, response limits, and REST logs
- Bearer token extraction and scope enforcement
- opaque token validation through introspection or token info
- JWT validation through static JWKS, remote JWKS, or OAuth discovery
- OAuth Gateway operations
- RS256 Client Assertion and JWT Bearer Assertion signing
- pure Node.js PKCS#12/P12 loading with alias selection and key rotation
- standard application errors and safe error presentation
- Next.js, Express, and Fastify adapters
- optional telemetry hooks that never change request outcomes
The core package has no framework dependency. Next.js, Express, and Fastify are optional peers.
Contents
- Requirements
- Installation
- Quick Start
- Choose a Service Preset
- Basic Usage
- Advanced Usage
- Configuration
- Configuration Reference
- Default Values
- API Reference
- Examples
- Error Handling
- Logging and Observability
- Security
- Troubleshooting
- Best Practices
- Migration Guide
- CLI
- AI Usage Notes
- Versioning and Development
Requirements
| Requirement | Supported value |
|---|---|
| Node.js | >=20 |
| TypeScript | Supported; declarations are included |
| Module formats | ESM and CommonJS |
| Next.js | >=14, Node.js runtime only |
| Express | >=4 or >=5 |
| Fastify | >=4 or >=5 |
The package uses the native Node.js fetch, Request, Response, Headers, URL, and
AbortController APIs.
Runtime P12 signing uses node-forge and Node.js crypto. It does not require Java, keytool,
openssl, temporary files, or child processes, so it works in Docker, ECS, Lambda, Render native
runtimes, and local Node.js environments.
Installation
npm install @smb-tech/service-framework-js
Install only the framework used by the consuming application:
npm install next
# or
npm install express
# or
npm install fastify
Quick Start
1. Minimal Next.js API Route
This preset provides tracing, structured logging, standardized errors, and an HTTP client without requiring OAuth configuration.
SERVICE_NAME=example-next-api
SERVICE_VERSION=1.0.0
LOGGER_TIMEZONE=America/Santiago
// app/api/health/route.ts
import { createNextApiService } from "@smb-tech/service-framework-js";
export const runtime = "nodejs";
const service = createNextApiService();
export const GET = service.route(async () => {
service.logger.info("Health check completed", {
operation: "health.read",
tags: ["health"]
});
return Response.json({ status: "ok" });
});
The route wrapper creates or accepts trace headers, initializes logging once, catches thrown values, returns the standard error body, adds trace response headers, and logs request completion.
2. Protected Next.js BFF Route
SERVICE_NAME=quickfade-bff-web
SERVICE_VERSION=1.0.0
CORE_OAUTH_GATEWAY_BASE_URL=https://oauth.example.com
CORE_OAUTH_GATEWAY_CERTS_URL=https://oauth.example.com/oauth2/v1/certs
CORE_OAUTH_GATEWAY_ISSUER=https://oauth.example.com
CORE_OAUTH_JWT_AUDIENCE=quickfade-bff-web
CORE_OAUTH_ALLOWED_ALGORITHMS=RS256
// app/api/profile/route.ts
import { createNextBffService } from "@smb-tech/service-framework-js";
export const runtime = "nodejs";
const service = createNextBffService();
export const GET = service.authRoute(
async (_request, { auth }) => {
return Response.json({
subject: auth.subject,
scopes: auth.scopes
});
},
{
requiredScopes: "cl:core:profile:read"
}
);
requiredScopes is evaluated per route. A missing or invalid token returns 401; a valid token
without the required scope returns 403.
3. Protected Express API
import express from "express";
import { createExpressApiService } from "@smb-tech/service-framework-js";
const service = createExpressApiService();
const app = express();
app.use(express.json());
app.use(service.traceMiddleware);
app.get("/profiles/:id", service.auth({ requiredScopes: "cl:core:profile:read" }), (req, res) => {
res.json({
subject: req.auth?.subject,
profileId: req.params.id
});
});
app.use(service.errorHandler);
app.listen(3000);
Use the same OAuth environment variables shown in the protected Next.js example.
Choose a Service Preset
| Preset | Use when | Includes |
|---|---|---|
createNextApiService() |
A Next.js API does not validate Bearer tokens | logger, tracing, route wrapper, error policy, HTTP client, lifecycle |
createNextBffService() |
A Next.js BFF validates opaque or JWT access tokens | everything above plus OAuth client, token validator, authRoute() |
createExpressApiService() |
An Express API exposes protected routes | logger, tracing middleware, auth middleware factory, error handler, HTTP client |
createInternalServiceClient() |
A service calls another internal service | base URL resolution, trace propagation, REST logging, timeout/retry controls |
Fastify currently uses the lower-level fastifyTracePlugin() and fastifyErrorHandler() adapters;
there is no Fastify service preset.
Basic Usage
Tracing
Supported headers:
X-Request-Id
X-B3-TraceId
X-B3-SpanId
X-B3-ParentSpanId
X-Client-Channel
X-Client-Platform
X-Client-App
X-Client-Version
X-Service-Name
X-Service-Version
import {
createTraceContext,
getTraceRequestHeaders,
getTraceResponseHeaders,
runWithTraceContext
} from "@smb-tech/service-framework-js/tracing";
const context = createTraceContext(request.headers, {
serviceName: "profile-service",
serviceVersion: "1.0.0"
});
await runWithTraceContext(context, async () => {
const downstreamHeaders = getTraceRequestHeaders();
const responseHeaders = getTraceResponseHeaders();
});
Invalid incoming trace/span IDs are replaced. Missing values are generated. By default, an incoming
span becomes parentSpanId and a new child span is created. Set createChildSpan: false to reuse a
valid incoming span.
Request-Scoped MDC
import { getMdc, setMdcField, setMdcFields, withMdc } from "@smb-tech/service-framework-js";
setMdcFields({
tenantId: "tenant-1",
operation: "profile.read"
});
await withMdc({ useCase: "GetProfile" }, async () => {
console.log(getMdc());
});
setMdcField("cacheHit", true);
Framework-owned fields such as requestId, traceId, and serviceName cannot be overwritten.
Sensitive MDC key names are rejected. Custom MDC is limited to 32 fields, 64 characters per key,
and 256 characters per value.
Logging
import { createLogger } from "@smb-tech/service-framework-js/logging";
const logger = createLogger({
contextName: "ProfileService",
serviceName: "core-auth-service"
});
logger.info("Profile loaded", {
operation: "profile.read",
target_service: "profile-store",
duration_ms: 24,
tags: ["profile", "read"]
});
try {
throw new Error("Database unavailable");
} catch (error) {
logger.error("Profile loading failed", {
operation: "profile.read",
error
});
}
Use meta.error only for exceptions. It is normalized into the canonical exception field and may
include native Error, DOMException, AggregateError, AppError, cause chains, primitives,
or non-standard thrown values. Use meta.tags for domain tags.
HTTP Client
import { createHttpClient } from "@smb-tech/service-framework-js/http";
const http = createHttpClient({
serviceName: "quickfade-bff-web",
targetService: "core-auth-service",
profile: "internal",
timeoutMs: 10_000,
retry: {
retries: 1,
retryDelayMs: 100
}
});
const profile = await http.getJson<{ display_name: string }>(
"https://core-auth-service.example.com/profiles/user-1",
{
operation: "profile.get"
}
);
getJson(), postJson(), and postForm() throw normalized errors for non-2xx responses.
request() returns the raw Response, including non-2xx responses.
Internal Service Client
import { createInternalServiceClient } from "@smb-tech/service-framework-js/presets";
const authService = createInternalServiceClient({
serviceName: "quickfade-bff-web",
targetService: "core-auth-service",
baseUrl: "https://core-auth-service.example.com"
});
const profile = await authService.getJson("/api/v1/profiles/user-1", {
operation: "profile.get"
});
Relative paths resolve against baseUrl. Absolute URLs remain absolute.
Bearer Tokens and Scopes
import { extractBearerToken, hasAllScopes, hasAnyScope, requireScopes } from "@smb-tech/service-framework-js";
const bearer = extractBearerToken(request.headers.get("authorization"));
const validated = await tokenValidator.validate(bearer);
requireScopes(validated, ["cl:core:profile:read"]);
const canReadOrWrite = hasAnyScope(validated, ["cl:core:profile:read", "cl:core:profile:write"]);
const canManage = hasAllScopes(validated, ["cl:core:profile:read", "cl:core:profile:write"]);
Advanced Usage
Opaque and JWT Token Validation
Token classification follows the package contract:
- token contains
.: JWT - token does not contain
.: opaque
import { createOAuthTokenValidatorFromEnv } from "@smb-tech/service-framework-js";
const validator = createOAuthTokenValidatorFromEnv();
const token = await validator.validate(accessToken);
console.log(token.tokenType, token.subject, token.clientId, token.scopes);
Opaque tokens use CORE_OAUTH_OPAQUE_VALIDATION_MODE=introspect|tokeninfo. JWTs validate:
algagainst an allowlist;noneis rejectedtypwhen configuredkid- signature
expnbfwhen presentisswhen configuredaudwhen configured
The normalized result is:
type ValidatedToken = {
active: boolean;
tokenType: "opaque" | "jwt";
subject?: string;
clientId?: string;
scopes: string[];
claims: Record<string, unknown>;
expiresAt?: number;
issuedAt?: number;
jwtId?: string;
};
Use claimNormalizer, jwtClaimNormalizer, or opaqueClaimNormalizer when provider claims do not
match the built-in normalization rules.
OAuth Gateway Client
import { createOAuthGatewayClientFromEnv } from "@smb-tech/service-framework-js";
const oauth = createOAuthGatewayClientFromEnv({
enableAssertionSigning: true
});
const serviceToken = await oauth.tokenByClientCredentials({
scope: "cl:core:profile:read"
});
const delegatedToken = await oauth.tokenByJwtBearer({
claims: {
customer_id: "customer-123",
tenant: {
id: "tenant-1",
channel: "web"
}
},
assertionScopes: ["cl:bff:web:profile:read"],
requestedScopes: ["cl:bff:web:profile:read"]
});
Additional methods:
await oauth.tokenByAuthorizationCode({ code, redirectUri, codeVerifier });
await oauth.tokenByRefreshToken({ refreshToken, scope });
await oauth.introspectToken(accessToken);
await oauth.tokenInfo(accessToken);
await oauth.revokeToken(accessToken);
await oauth.userAuthorize(payload);
await oauth.getJwks();
assertionScopes becomes the signed JWT scope claim. requestedScopes becomes the OAuth form
scope parameter. The deprecated scope field on tokenByJwtBearer() aliases requestedScopes.
Do not provide both.
The client still accepts a pre-signed assertion, but it cannot be combined with automatic
generation fields.
P12 Client Assertions
CORE_OAUTH_GATEWAY_TOKEN_URL=https://oauth.example.com/oauth2/v1/token
CORE_OAUTH_CLIENT_ID=replace-with-client-id
CORE_OAUTH_ASSERTION_AUDIENCE=https://oauth.example.com/oauth2/v1/token
CORE_OAUTH_ASSERTION_TTL_SECONDS=300
CORE_OAUTH_P12_BASE64=replace-with-base64-p12
CORE_OAUTH_P12_PASSWORD_BASE64=replace-with-base64-password
CORE_OAUTH_P12_ALIAS=replace-with-friendly-name
The P12 password is Base64-encoded and decoded before use. The alias must match exactly one private key entry. The associated certificate must contain the matching RSA public key.
import { createOAuthSignerFromEnv } from "@smb-tech/service-framework-js/security";
const signer = createOAuthSignerFromEnv();
const clientAssertion = await signer.signClientAssertion();
const jwtBearerAssertion = await signer.signJwtBearerAssertion({
claims: {
account_id: "account-1"
},
scopes: ["cl:bff:web:profile:read"]
});
Generated assertions always use RS256 and contain:
iss, sub, aud, iat, exp, jti
JWT bearer custom claims are plain JSON data. They cannot override iss, sub, aud, iat,
exp, or jti.
Claim limits:
| Limit | Value |
|---|---|
| Properties per object | 32 |
| Nesting depth | 5 |
| Items per array | 100 |
| Claim name length | 128 |
| Total serialized claims | 8 KiB |
| Assertion TTL | 1-600 seconds |
Unsafe keys (__proto__, prototype, constructor), accessors, cycles, non-finite numbers, and
non-JSON values are rejected.
Signing-Key Cache and Rotation
const current = await oauth.getAssertionSigningKeyInfo();
// Clear the cache. The next signing request reloads the configured provider.
oauth.reloadAssertionSigningKey();
// Validate the candidate before atomically replacing the active key.
const rotated = await oauth.rotateAssertionSigningKey();
console.log(current.keyId, rotated.keyId);
The cache deduplicates concurrent loads. Rotation validates the P12, alias, certificate, RSA pair,
fingerprint, and derived kid before activation. A failed rotation preserves the active key.
Environment-backed rotation only sees changes available to the running process. Platforms that inject environment variables only at startup require a restart or redeploy.
Client Credentials Token Cache
import { ClientCredentialsClient } from "@smb-tech/service-framework-js/oauth";
const tokens = new ClientCredentialsClient({
oauthGatewayClient: oauth,
scope: "cl:core:profile:read",
cacheSkewSeconds: 30
});
const accessToken = await tokens.getAccessToken();
Concurrent cache misses share one request. The default token lifetime is 300 seconds when the token
response omits expires_in; the default early-refresh skew is 30 seconds.
Secure Remote JWKS
import { createRemoteJwksResolver } from "@smb-tech/service-framework-js/security";
const resolver = createRemoteJwksResolver({
allowedHosts: ["identity.example.com", "*.trusted.example.com"],
allowedAlgorithms: ["RS256"],
allowedKeyTypes: ["RSA"]
});
const jwks = await resolver.resolve("https://identity.example.com/.well-known/jwks.json");
*.trusted.example.com matches subdomains only, not trusted.example.com. Omit allowedHosts or
use ["*"] to permit any public host. HTTPS, port validation, private-network blocking, response
limits, and public-key-only validation remain active.
Use blockPrivateNetworks: false only for a deliberately trusted private JWKS endpoint.
Fastify
import Fastify from "fastify";
import { fastifyErrorHandler, fastifyTracePlugin } from "@smb-tech/service-framework-js/framework/fastify";
const app = Fastify();
await app.register(
fastifyTracePlugin({
serviceName: "core-messaging-engine",
serviceVersion: "1.0.0"
})
);
app.setErrorHandler(fastifyErrorHandler());
app.get("/health", async () => ({ status: "ok" }));
await app.listen({ port: 3000 });
Fastify has tracing and error adapters, but no built-in Bearer/scope adapter. Compose the core
extractBearerToken(), OAuthTokenValidator, and ScopeGuard APIs in a hook when authentication is
required.
Configuration
Recommended Environment Profiles
Minimal Next API:
SERVICE_NAME=example-next-api
SERVICE_VERSION=1.0.0
JWT validation:
SERVICE_NAME=quickfade-bff-web
CORE_OAUTH_GATEWAY_BASE_URL=https://oauth.example.com
CORE_OAUTH_GATEWAY_CERTS_URL=https://oauth.example.com/oauth2/v1/certs
CORE_OAUTH_GATEWAY_ISSUER=https://oauth.example.com
CORE_OAUTH_JWT_AUDIENCE=quickfade-bff-web
OAuth discovery instead of explicit JWKS and issuer:
CORE_OAUTH_GATEWAY_BASE_URL=https://oauth.example.com
CORE_OAUTH_ISSUER_DISCOVERY_URL=https://oauth.example.com/.well-known/oauth-authorization-server
CORE_OAUTH_JWT_AUDIENCE=quickfade-bff-web
Opaque-only validation:
const validator = createOAuthTokenValidatorFromEnv({
requireJwt: false,
requireIssuer: false,
requireAudience: false
});
Automatic assertion signing:
CORE_OAUTH_GATEWAY_TOKEN_URL=https://oauth.example.com/oauth2/v1/token
CORE_OAUTH_CLIENT_ID=replace-with-client-id
CORE_OAUTH_P12_BASE64=replace-with-secret-manager-value
CORE_OAUTH_P12_PASSWORD_BASE64=replace-with-secret-manager-value
CORE_OAUTH_P12_ALIAS=replace-with-friendly-name
Programmatic Factory Options
createOAuthGatewayClientFromEnv(options)
| Option | Type | Default | Purpose |
|---|---|---|---|
env |
NodeJS.ProcessEnv |
process.env |
Alternate environment object; useful for tests |
serviceNameEnv |
string |
SERVICE_NAME |
Alternate service-name variable |
serviceVersionEnv |
string |
SERVICE_VERSION |
Alternate version variable |
requireBaseUrlOrTokenUrl |
boolean |
true |
Require a Gateway base URL or token URL |
enableAssertionSigning |
boolean |
false |
Build an environment-backed P12 signer |
assertionSigner |
OAuthAssertionSigner |
none | Inject a custom signer |
logger |
Logger |
generated logger | Logging adapter |
fetcher |
typeof fetch |
global fetch |
Custom fetch implementation or test mock |
traceContextProvider |
() => TraceContext | undefined |
current context | Custom trace source |
onHttpClientMetric |
hook | none | Outbound HTTP telemetry |
createOAuthTokenValidatorFromEnv(options)
Includes every OAuth Gateway option plus:
| Option | Type | Default | Purpose |
|---|---|---|---|
oauthGatewayClient |
OAuthGatewayClient |
created from env | Reuse an existing client |
requireJwt |
boolean |
true |
Require JWKS/discovery configuration |
requireIssuer |
boolean |
value of requireJwt |
Require an expected issuer without discovery |
requireAudience |
boolean |
value of requireJwt |
Require an expected audience |
onTokenValidationMetric |
hook | none | Token validation telemetry |
onJwksRefresh |
hook | none | JWKS refresh telemetry |
onAuthFailure |
hook | none | Next/Express auth-failure telemetry |
Service Presets
| Option | Applies to | Default | Purpose |
|---|---|---|---|
logger |
all presets | generated logger | Inject a shared logger |
initializeLogger |
Next/Express presets | true |
Initialize runtime logging in adapters |
loggerInitializationOptions |
Next/Express presets | environment defaults | Code-based logger setup |
errorPolicy |
Next/Express presets | standard policy | Override severity/reporting policy |
contextName |
Next API | NextApiService |
Logger class/context name |
httpClient |
Next API | environment HTTP config | Override client fields |
Route-level options override preset defaults. For example, setting requiredScopes on one route
does not affect another route.
Low-Level Option Reference
CreateTraceContextOptions
| Option | Type | Default | Purpose |
|---|---|---|---|
serviceName |
string |
incoming header or unset | Local service identity |
serviceVersion |
string |
incoming header or unset | Local service version |
defaultClientChannel |
string |
"unknown" |
Fallback client channel |
defaultClientPlatform |
string |
"unknown" |
Fallback client platform |
defaultClientApp |
string |
"unknown" |
Fallback client application |
defaultClientVersion |
string |
"unknown" |
Fallback client version |
createChildSpan |
boolean |
true |
Create a new span and preserve incoming span as parent |
HttpClientConfig
| Option | Type | Default | Purpose |
|---|---|---|---|
serviceName |
string |
required | Calling service |
profile |
internal | external |
none | Apply profile defaults |
logger |
Logger |
generated HttpClient logger |
REST lifecycle logs |
traceContextProvider |
function | current request context | Explicit trace provider |
fetcher |
typeof fetch |
global fetch |
Custom transport/test mock |
timeoutMs |
number |
profile or 10000 |
Per-attempt timeout |
retry |
HttpRetryConfig |
general retry defaults | Retry policy |
defaultHeaders |
HeadersInit |
none | Headers applied to every request |
targetService |
string |
serviceName |
Default log/metric target |
propagateTraceHeaders |
boolean |
profile or true |
Add trace headers |
maxResponseBodyBytes |
number |
profile or unlimited | Buffering limit |
responseBodyLogging |
policy | profile or errors |
never, errors, or always |
onHttpClientMetric |
hook | none | Attempt-level metric callback |
Every request accepts profile, headers, targetService, operation, timeoutMs, retry,
traceContext, propagateTraceHeaders, maxResponseBodyBytes, and responseBodyLogging overrides.
JSON/form methods also accept parseJson; it defaults to true.
HttpRetryConfig fields:
| Option | Type | Default |
|---|---|---|
retries |
non-negative integer | 0 |
retryDelayMs |
non-negative integer | 100 |
retryStatuses |
HTTP status array | 408, 425, 429, 500, 502, 503, 504 |
retryOnTimeout |
boolean | true |
OAuthGatewayClientConfig
| Option | Type | Default | Purpose |
|---|---|---|---|
serviceName |
string |
"unknown-service" |
Calling service |
baseUrl |
URL string | none | Base for /oauth2/v1/* fallbacks |
tokenUrl |
URL string | derived from baseUrl |
Token endpoint |
introspectionUrl |
URL string | derived | Introspection endpoint |
tokenInfoUrl |
URL string | derived | Token info endpoint |
revokeUrl |
URL string | derived | Revocation endpoint |
userAuthorizeUrl |
URL string | derived | User authorization endpoint |
jwksUrl |
URL string | derived | Certificates/JWKS endpoint |
clientId |
string |
unset | Default OAuth client |
clientSecret |
string |
unset | Secret authentication fallback |
assertionSigner |
OAuthAssertionSigner |
unset | Recommended automatic signer |
clientAssertionSigner |
legacy signer | unset | Deprecated Client Credentials-only adapter |
logger |
Logger |
generated logger | HTTP/signing logs |
traceContextProvider |
function | current context | Trace source |
fetcher |
typeof fetch |
global fetch |
Custom transport/test mock |
timeoutMs |
number | HTTP client default | Request timeout |
retry |
HttpRetryConfig |
HTTP client default | Retry policy |
onHttpClientMetric |
hook | none | OAuth HTTP telemetry |
OAuthTokenValidatorConfig
| Option | Type | Default | Purpose |
|---|---|---|---|
opaqueValidationMode |
introspect | tokeninfo |
introspect |
Opaque-token strategy |
introspectToken |
async function | none | Introspection callback |
tokenInfo |
async function | none | Token-info callback |
jwt |
JwksTokenVerifierConfig |
none | Build a JWT verifier |
jwksTokenVerifier |
JwksTokenVerifier |
none | Inject an existing verifier |
claimNormalizer |
function | built-in normalization | Shared claim mapper |
jwtClaimNormalizer |
function | shared mapper | JWT-specific mapper |
opaqueClaimNormalizer |
function | shared mapper | Opaque-specific mapper |
onTokenValidationMetric |
hook | none | Validation telemetry |
JwksTokenVerifierConfig
| Option | Type | Default | Purpose |
|---|---|---|---|
jwksUrl |
string | none | Direct JWKS URI |
discoveryUrl |
string | none | OAuth metadata URI |
discoveryClient |
OAuthDiscoveryClient |
built from URL | Inject discovery client |
jwks |
OAuthJwks |
none | Static in-memory JWKS |
expectedIssuer |
string | discovery issuer or unchecked | Exact issuer |
expectedAudience |
string/string[] | unchecked | Accepted audiences |
expectedType |
string/string[] | unchecked | Accepted JWT typ |
allowedAlgorithms |
string[] | ["RS256"] |
Algorithm allowlist |
cacheTtlMs |
number | 300000 |
Discovery/JWKS cache |
fetcher |
typeof fetch |
global fetch |
Custom transport |
clockSkewSeconds |
number | 0 direct; env factory uses 30 |
Temporal tolerance |
timeoutMs |
number | 10000 |
Remote timeout |
retryCount |
number | 1 |
Retries after initial attempt |
retryDelayMs |
number | 100 |
Fixed retry delay |
claimNormalizer |
function | built-in | JWT claim mapper |
onJwksRefresh |
hook | none | Refresh telemetry |
remoteJwks |
resolver config | secure defaults | Remote policy overrides |
jwksResolver |
resolver instance | generated | Inject a resolver |
RemoteJwksResolverConfig
| Option | Type | Default |
|---|---|---|
serviceName |
string | SERVICE_NAME or "service-framework" |
targetService |
string | "remote-jwks" |
logger |
Logger |
generated HTTP logger |
httpClient |
HttpClient |
external-profile client |
fetcher |
typeof fetch |
global fetch |
resolveHostname |
async function | DNS lookup |
allowedHosts |
string[] | any public host |
allowedPorts |
number[] | HTTPS 443; otherwise 80,443 |
allowedKeyTypes |
string[] | RSA, EC, OKP; verifier narrows to RSA |
allowedAlgorithms |
string[] | unrestricted at resolver level |
requireHttps |
boolean | true |
blockPrivateNetworks |
boolean | true |
requireJsonContentType |
boolean | true |
cacheTtlMs |
number | 300000 |
timeoutMs |
number | 5000 |
retryCount |
number | 1 |
retryDelayMs |
number | 100 |
maxResponseBytes |
number | 65536 |
maxKeys |
number | 20 |
maxRedirects |
number | 3 |
onJwksRefresh |
hook | none |
OAuthSignerEnvOptions
| Option | Type | Default |
|---|---|---|
env |
NodeJS.ProcessEnv |
process.env |
p12Base64Env |
string | CORE_OAUTH_P12_BASE64 |
p12PasswordBase64Env |
string | CORE_OAUTH_P12_PASSWORD_BASE64 |
p12AliasEnv |
string | CORE_OAUTH_P12_ALIAS |
clientIdEnv |
string | CORE_OAUTH_CLIENT_ID |
audienceEnv |
string | standard audience fallback chain |
ttlSecondsEnv |
string | CORE_OAUTH_ASSERTION_TTL_SECONDS |
keyIdEnv |
string | CORE_OAUTH_KEY_ID |
defaultTtlSeconds |
number | 300 |
defaultAudience |
string | unset |
logger |
Logger |
none |
Framework Adapter Options
All trace adapters accept CreateTraceContextOptions.
| Adapter | Additional options |
|---|---|
nextRouteHandler |
logger, initializeLogger=true, loggerInitializationOptions, errorPolicy |
nextAuthRouteHandler |
above plus required tokenValidator, requiredScopes, authorizationHeaderName="authorization", onAuthFailure |
createNextTraceMiddleware |
initializeLogger=true, loggerInitializationOptions |
expressTraceMiddleware |
logger, initializeLogger=true, loggerInitializationOptions, logRequestCompletion=true |
expressAuthMiddleware |
required tokenValidator, requiredScopes, authorizationHeaderName="authorization", serviceName, onAuthFailure |
expressErrorHandler |
logger, logErrors, errorPolicy |
fastifyTracePlugin |
initializeLogger=true, loggerInitializationOptions |
fastifyErrorHandler |
logger, logErrors, errorPolicy |
Redaction Options
| Option | Type | Default |
|---|---|---|
redactionText |
string | [REDACTED] |
sensitiveFields |
string[] | mandatory built-in fields plus additions |
sensitiveQueryParameters |
string[] | mandatory built-ins plus additions |
maxStringLength |
number | 4000 |
LoggerInitializationOptions
Code options override environment variables:
| Option | Type | Default |
|---|---|---|
level |
logger level | LOG_LEVEL, then INFO |
sampleRate |
number | LOGGER_SAMPLE_RATE, then 1 |
timezone |
IANA timezone | LOGGER_TIMEZONE, then UTC |
errorStackEnabled |
boolean | LOGGER_ERROR_STACK_ENABLED === "true" |
sensitiveKeys |
logger mask rules | mandatory defaults |
redactPlaceholder |
string | [REDACTED] |
maxRedactionInputLength |
number | logger-core default 4096 |
sampledLevels |
log levels | logger-core sampled levels |
internalErrorHandler |
function | logger-core safe stderr handler |
mode |
sync | async |
LOGGER_MODE, then async |
flushIntervalMs |
number | LOGGER_FLUSH_INTERVAL_MS, then 10 |
maxQueueSize |
number | LOGGER_MAX_QUEUE_SIZE, then 10000 |
overflowStrategy |
drop | sync-fallback |
environment, then sync-fallback |
shutdownTimeoutMs |
number | LOGGER_SHUTDOWN_TIMEOUT_MS, then 2000 |
metricsEnabled |
boolean | LOGGER_INTERNAL_METRICS_ENABLED === "true" |
Configuration Reference
List values accept comma-separated or whitespace-separated text. Boolean values accept
true/false, 1/0, yes/no, and y/n.
Service and HTTP
| Variable | Type | Required | Default | Example / notes |
|---|---|---|---|---|
SERVICE_NAME |
string | Yes for presets/env HTTP config | none | quickfade-bff-web |
SERVICE_VERSION |
string | No | unset | 1.0.0 |
SERVICE_HTTP_PROFILE |
internal | external |
No | internal |
Selects secure HTTP defaults |
SERVICE_HTTP_TIMEOUT_MS |
number | No | internal 10000; external 5000 |
Positive integer |
SERVICE_HTTP_PROPAGATE_TRACE_HEADERS |
boolean | No | internal true; external false |
Avoid propagation to untrusted external APIs |
SERVICE_HTTP_MAX_RESPONSE_BODY_BYTES |
number | No | internal 5242880; external 1048576 |
Positive integer |
SERVICE_HTTP_RESPONSE_BODY_LOGGING |
never | errors | always |
No | internal errors; external never |
Bodies are redacted before logging |
SERVICE_HTTP_RETRY_COUNT |
number | No | 0 |
Non-negative integer |
SERVICE_HTTP_RETRY_DELAY_MS |
number | No | 100 |
Fixed delay; no exponential backoff |
OAuth Gateway
| Variable | Type | Required | Default | Example / notes |
|---|---|---|---|---|
CORE_OAUTH_GATEWAY_BASE_URL |
URL | One base/token URL required by default | none | https://oauth.example.com |
CORE_OAUTH_GATEWAY_TOKEN_URL |
URL | Alternative to base URL | derived from base URL | /oauth2/v1/token |
CORE_OAUTH_GATEWAY_INTROSPECTION_URL |
URL | No | derived from base/token URL | /oauth2/v1/introspect |
CORE_OAUTH_GATEWAY_TOKENINFO_URL |
URL | No | derived from base/token URL | /oauth2/v1/tokeninfo |
CORE_OAUTH_GATEWAY_REVOKE_URL |
URL | No | derived from base/token URL | /oauth2/v1/revoke |
CORE_OAUTH_GATEWAY_USER_AUTHORIZE_URL |
URL | No | derived from base/token URL | /oauth2/v1/userauthorize |
CORE_OAUTH_GATEWAY_CERTS_URL |
URL | Conditional | none | Preferred direct JWKS variable |
CORE_OAUTH_JWKS_URL |
URL | Conditional | none | Alias for direct JWKS |
CORE_OAUTH_CLIENT_ID |
string | Required for automatic signing | unset | OAuth client identifier |
CORE_OAUTH_CLIENT_SECRET |
string | No | unset | Prefer private_key_jwt when supported |
CORE_OAUTH_HTTP_TIMEOUT_MS |
number | No | 10000 |
OAuth HTTP timeout |
CORE_OAUTH_HTTP_RETRY_COUNT |
number | No | 1 |
Retry attempts after the first request |
CORE_OAUTH_HTTP_RETRY_DELAY_MS |
number | No | 100 |
Fixed retry delay |
Token Validation and JWKS
| Variable | Type | Required | Default | Example / notes |
|---|---|---|---|---|
CORE_OAUTH_OPAQUE_VALIDATION_MODE |
introspect | tokeninfo |
No | introspect |
Used for tokens without . |
CORE_OAUTH_GATEWAY_ISSUER |
string | For JWT when discovery is absent | none | Exact iss match |
CORE_OAUTH_JWT_AUDIENCE |
string | For JWT by default | none | Expected access-token audience |
CORE_OAUTH_JWT_TYPES |
list | No | no typ restriction |
Example: JWT at+jwt |
CORE_OAUTH_REQUIRED_SCOPES |
list | No | none | Global preset scopes; route-level scopes are preferred |
CORE_OAUTH_ALLOWED_ALGORITHMS |
list | No | RS256 |
none is always rejected |
CORE_OAUTH_CLOCK_SKEW_SECONDS |
number | No | 30 |
Applied to exp and nbf |
CORE_OAUTH_JWKS_CACHE_TTL_MS |
number | No | 300000 |
Five minutes |
CORE_OAUTH_JWKS_TIMEOUT_MS |
number | No | 10000 |
Remote JWKS/discovery timeout |
CORE_OAUTH_JWKS_RETRY_COUNT |
number | No | 1 |
Retry attempts after first request |
CORE_OAUTH_JWKS_RETRY_DELAY_MS |
number | No | 100 |
Fixed delay |
CORE_OAUTH_ISSUER_DISCOVERY_URL |
URL | Alternative to direct JWKS and issuer | none | Authorization Server Metadata URL |
CORE_OAUTH_JWKS_ALLOWED_HOSTS |
list | No | any public host | identity.example.com *.trusted.example.com |
CORE_OAUTH_JWKS_ALLOWED_PORTS |
number list | No | HTTPS: 443; HTTP allowed: 80,443 |
Ports 1-65535 |
CORE_OAUTH_JWKS_REQUIRE_HTTPS |
boolean | No | true |
Keep enabled in production |
CORE_OAUTH_JWKS_BLOCK_PRIVATE_NETWORKS |
boolean | No | true |
SSRF protection |
CORE_OAUTH_JWKS_MAX_RESPONSE_BYTES |
number | No | 65536 |
64 KiB |
CORE_OAUTH_JWKS_MAX_KEYS |
number | No | 20 |
Maximum accepted public keys |
CORE_OAUTH_JWKS_MAX_REDIRECTS |
number | No | 3 |
Every redirect target is revalidated |
JWT validation requires either a direct JWKS URL or discovery URL when requireJwt is true.
Discovery supplies issuer and jwks_uri; audience remains application-specific.
Assertion Signing
| Variable | Type | Required | Default | Example / notes |
|---|---|---|---|---|
CORE_OAUTH_P12_BASE64 |
Base64 string | When signing enabled | none | Binary .p12/.pfx encoded as Base64 |
CORE_OAUTH_P12_PASSWORD_BASE64 |
Base64 string | When signing enabled | none | P12 password encoded as Base64 |
CORE_OAUTH_P12_ALIAS |
string | When signing enabled | none | Exact PKCS#12 friendly name |
CORE_OAUTH_CLIENT_ID |
string | When signing enabled | none | Used for both iss and sub |
CORE_OAUTH_ASSERTION_AUDIENCE |
string | Conditional | token URL, then legacy JWT audience | Token endpoint is recommended |
CORE_OAUTH_ASSERTION_TTL_SECONDS |
number | No | 300 |
Integer from 1 to 600 |
CORE_OAUTH_KEY_ID |
string | No | RFC 7638 thumbprint derived from public key | Explicit JWT header kid override |
CORE_OAUTH_JWT_AUDIENCE is an inbound access-token validation setting. Use
CORE_OAUTH_ASSERTION_AUDIENCE for assertions unless both values intentionally match.
Logging
| Variable | Type | Required | Default | Example / notes |
|---|---|---|---|---|
LOG_LEVEL |
logger level | No | INFO |
TRACE, DEBUG, INFO, WARN, ERROR, and logger-specific levels |
LOGGER_TIMEZONE |
IANA timezone | No | UTC |
America/Santiago; invalid values fall back to UTC |
LOGGER_MODE |
sync | async |
No | async |
sync is useful for short examples/tests |
LOGGER_SAMPLE_RATE |
number | No | 1 |
Clamped by logger-core |
LOGGER_ERROR_STACK_ENABLED |
boolean text | No | false |
Only exact true enables stack output |
LOGGER_FLUSH_INTERVAL_MS |
number | No | 10 |
Async sink interval |
LOGGER_MAX_QUEUE_SIZE |
number | No | 10000 |
Async queue size |
LOGGER_OVERFLOW_STRATEGY |
drop | sync-fallback |
No | sync-fallback |
Any value except drop selects fallback |
LOGGER_SHUTDOWN_TIMEOUT_MS |
number | No | 2000 |
Shutdown flush timeout |
LOGGER_INTERNAL_METRICS_ENABLED |
boolean text | No | false |
Only exact true enables sink metrics |
Default Values
HTTP Profiles
| Behavior | internal |
external |
|---|---|---|
| Timeout | 10 seconds | 5 seconds |
| Trace propagation | enabled | disabled |
| Maximum response body | 5 MiB | 1 MiB |
| Response body logging | errors only | never |
General HTTP retry defaults:
{
retries: 0,
retryDelayMs: 100,
retryStatuses: [408, 425, 429, 500, 502, 503, 504],
retryOnTimeout: true
}
OAuth environment clients override retries to 1. Retries use a fixed delay.
Tracing
| Value | Default |
|---|---|
requestId |
UUID v4 |
traceId |
32 lowercase hexadecimal characters |
spanId |
16 lowercase hexadecimal characters |
| client metadata | "unknown" |
| child span creation | enabled |
OAuth and Security
| Behavior | Default |
|---|---|
| Opaque validation | introspection |
| Allowed JWT algorithms | RS256 |
| Environment JWT clock skew | 30 seconds |
| JWKS cache | 5 minutes |
| Assertion TTL | 300 seconds |
| Maximum assertion TTL | 600 seconds |
| P12 key selection | exact alias match |
| JWKS HTTPS | required |
| JWKS private networks | blocked |
API Reference
All public APIs are exported from the package root unless a subpath is shown.
Runtime and Presets
| API | Purpose |
|---|---|
initializeServiceFramework(options?) |
Idempotently initialize logging |
shutdownServiceFramework(options?) |
Flush and stop logging; returns sink metrics |
isServiceFrameworkInitialized() |
Report lifecycle state |
createNextApiService(options?) |
Non-OAuth Next.js preset |
createNextBffService(options?) |
OAuth-aware Next.js BFF preset |
createExpressApiService(options?) |
OAuth-aware Express preset |
createInternalServiceClient(options) |
Internal downstream client |
Configuration Factories
| API | Purpose |
|---|---|
createServiceMetadataFromEnv() |
Read required service identity |
createServiceHttpClientConfigFromEnv() |
Build HTTP config from SERVICE_HTTP_* |
createOAuthGatewayConfigFromEnv() |
Build OAuthGatewayClientConfig |
createOAuthGatewayClientFromEnv() |
Build the OAuth client |
createOAuthTokenValidatorConfigFromEnv() |
Build validator config |
createOAuthTokenValidatorFromEnv() |
Build the validator |
createNextBffConfigFromEnv() |
Build lower-level Next BFF config |
createExpressServiceConfigFromEnv() |
Build lower-level Express config |
validateEnv() |
Validate a custom environment schema |
requiredEnv(), optionalEnv() |
Read string values |
requiredUrlEnv(), optionalUrlEnv() |
Read URL values |
numberEnv(), booleanEnv(), stringListEnv() |
Read typed values |
Tracing and MDC
| API | Purpose |
|---|---|
createTraceContext(headers?, options?) |
Normalize/generate trace metadata |
getTraceRequestHeaders(context?) |
Build downstream propagation headers |
getTraceResponseHeaders(context?) |
Build response trace headers |
runWithTraceContext(context, callback) |
Run work in request context |
getCurrentTraceContext() |
Read current normalized trace context |
traceContextToMdc() |
Convert context to canonical camelCase MDC |
traceContextToLogFields() |
Convert context to snake_case log fields |
traceContextToMetricLabels() |
Produce low-cardinality metric labels by default |
setMdcField(), setMdcFields() |
Add custom request fields |
getMdc() |
Return an immutable MDC snapshot |
removeMdcField() |
Remove one custom field |
withMdc() |
Add scoped MDC and restore it afterward |
traceContextToMetricLabels(context, { includeHighCardinality: true }) adds request, trace, and span
IDs. Do not use high-cardinality labels in Prometheus-style metrics.
Lower-level tracing exports also include createTraceMiddleware(), applyTraceResponseHeaders(),
createRequestTraceContext(), header constants, and generic header readers/writers.
Logging
| API | Purpose |
|---|---|
initializeLogger(options?) |
Initialize logger-core/logger-node directly |
shutdownLogger(options?) |
Flush the logger sink |
createLogger(config?) |
Create the service logger facade |
redactSensitiveData(value, options?) |
Deeply redact values, errors, headers, arrays, and URLs |
normalizeLogException(error) |
Normalize any thrown value |
createRestCallLogger(logger?) |
Low-level REST lifecycle logger |
HTTP
| API | Purpose |
|---|---|
createHttpClient(config) |
Create raw/JSON/form HTTP methods |
extractBearerToken(header) |
Parse Bearer authorization or throw UnauthorizedError |
isBearerTokenAuthorizationHeader(header) |
Boolean Bearer validation |
createHeaders(), withJsonHeaders(), withFormHeaders() |
Header utilities |
applyTraceHeaders() |
Add trace headers to an existing Headers |
mapResponseToAppError() |
Map non-2xx responses to standard errors |
safeResponseBody() |
Read and redact a cloned response body |
OAuth
| API | Main methods |
|---|---|
OAuthTokenValidator |
validate(token) |
OpaqueTokenIntrospector |
validate(token) |
JwksTokenVerifier |
verify(token), getJwks() |
OAuthDiscoveryClient |
getMetadata({ forceRefresh? }) |
OAuthGatewayClient |
token grants, introspection, token info, revoke, authorize, JWKS, key rotation |
ClientCredentialsClient |
getAccessToken() with cache |
ScopeGuard |
requireScopes, hasScope, hasAnyScope, hasAllScopes |
Function forms of all scope helpers are also exported.
Security and Signing
| API | Purpose |
|---|---|
createOAuthSignerFromEnv(options?) |
Recommended unified environment signer |
OAuthSigner |
Unified Client Assertion/JWT Bearer signer with rotation |
PrivateKeyJwtSigner |
Lower-level Client Assertion signer |
JwtBearerAssertionSigner |
Lower-level JWT Bearer signer |
P12JwtBearerAssertionSigner |
Compatibility signer with cached P12 and rotation |
loadKeyMaterialFromP12Base64(config) |
Load private/public key, kid, fingerprint, and alias |
loadPrivateKeyFromP12Base64(config) |
Load only the private KeyObject |
P12KeyMaterialCache |
Cache and atomically rotate verified P12 material |
RemoteJwksResolver |
Secure remote JWKS loading and caching |
clientAssertionSigner in OAuthGatewayClientConfig is deprecated. Use assertionSigner.
userId remains a compatibility shortcut that creates user_id; new integrations should prefer
the claims record.
Low-level JWT utilities (parseJwt, signJwtRs256, verifyJwtRs256, publicKeyFromJwk,
privateKeyFromPem, and claim normalization helpers) are public for infrastructure use. Prefer the
higher-level verifier and signer classes in application code.
Framework Adapters
Next.js:
nextRouteHandler
nextAuthRouteHandler / withAuthRoute
createNextTraceMiddleware
createNextErrorResponse
createCookieSafeNextJsonResponse
Express:
expressTraceMiddleware
expressAuthMiddleware
expressErrorHandler
runWithExpressTraceContext
Fastify:
fastifyTracePlugin
fastifyErrorHandler
The package exports no decorators.
Key Public Types
| Area | Types |
|---|---|
| Tracing | TraceContext, RequestTraceContext, TraceLogFields, TraceMetricLabels, CreateTraceContextOptions, MdcFields |
| Logging | Logger, LogMetadata, LoggerInitializationOptions, RedactionOptions |
| HTTP | HttpClient, HttpClientConfig, HttpRetryConfig, HttpRequestOptions, JsonRequestOptions |
| OAuth | ValidatedToken, OAuthTokenResponse, OAuthGatewayClientConfig, token grant parameter types, OAuthTokenValidatorConfig, JwksTokenVerifierConfig |
| Signing | OAuthAssertionSigner, OAuthSignerConfig, OAuthSignerEnvOptions, P12LoaderConfig, P12KeyMaterial, OAuthSigningKeyInfo |
| Errors | AppErrorOptions, ErrorBody, ErrorPresentation, PresentErrorOptions, error-policy types |
| Observability | ObservabilityHooks, HttpClientMetric, TokenValidationMetric, JwksRefreshEvent, AuthFailureEvent |
| Frameworks | route callbacks/contexts and adapter option types under each framework subpath |
Package Subpaths
import { createHttpClient } from "@smb-tech/service-framework-js/http";
import { OAuthTokenValidator } from "@smb-tech/service-framework-js/oauth";
import { createOAuthSignerFromEnv } from "@smb-tech/service-framework-js/security";
import { createNextBffService } from "@smb-tech/service-framework-js/presets";
import { initializeServiceFramework } from "@smb-tech/service-framework-js/runtime";
import type { ObservabilityHooks } from "@smb-tech/service-framework-js/observability";
import { nextAuthRouteHandler } from "@smb-tech/service-framework-js/framework/next";
import { expressAuthMiddleware } from "@smb-tech/service-framework-js/framework/express";
Available subpaths: /tracing, /logging, /http, /oauth, /security, /errors, /config,
/observability, /presets, /runtime, /framework/next, /framework/express, and
/framework/fastify.
Examples
Repository examples:
| Directory | Demonstrates |
|---|---|
examples/next-bff |
Next route wrapper, Bearer validation, tracing, safe response |
examples/express-service |
Express tracing, protected route, scopes, errors |
examples/fastify-service |
Fastify trace plugin and error handler |
examples/oauth-validation |
Opaque/JWT validation and scopes |
examples/client-credentials |
Automatic RS256 client_assertion |
examples/jwt-bearer-assertion |
Automatic JWT bearer assertion with custom claims |
examples/rest-client-logging |
Success/error REST logs and redaction |
Demo examples:
npm run example:next
npm run example:express
npm run example:fastify
OAuth signing examples require real non-committed configuration:
cp examples/client-credentials/.env.example examples/client-credentials/.env.local
cp examples/jwt-bearer-assertion/.env.example examples/jwt-bearer-assertion/.env.local
# Replace placeholders:
npm run example:client-credentials
npm run example:jwt-bearer
These examples print only public key metadata and non-sensitive token response metadata.
Error Handling
All standard errors produce:
{
"error": "forbidden",
"error_description": "Missing required scope: cl:core:profile:read"
}
| Class | Status | Code | Exposed by default |
|---|---|---|---|
InvalidRequestError |
400 | invalid_request |
Yes |
UnauthorizedError |
401 | unauthorized |
Yes |
ForbiddenError |
403 | forbidden |
Yes |
NotFoundError |
404 | not_found |
Yes |
ConflictError |
409 | invalid_state |
Yes |
UnprocessableEntityError |
422 | unprocessable_entity |
Yes |
InternalServerError |
500 | internal_server_error |
No |
ExternalServiceError |
502 | external_service_error |
No |
OAuth signing also exports OAuthConfigurationError, P12LoadError,
P12AliasNotFoundError, P12AliasConflictError, P12CertificateError,
OAuthAssertionSigningError, and OAuthAssertionSignerNotConfiguredError.
import { AppError, NotFoundError } from "@smb-tech/service-framework-js/errors";
throw new NotFoundError("Profile was not found");
throw AppError.externalService("Profile provider failed", {
cause: providerError
});
Internal messages and stacks are hidden by default. presentError(error, { includeStack: true, production: false }) may expose an internal message only in an explicitly
non-production path.
Custom policy:
import { createErrorPolicyResolver, ExternalServiceError } from "@smb-tech/service-framework-js/errors";
const errorPolicy = createErrorPolicyResolver({
rules: [
{
errorType: ExternalServiceError,
policy: {
logLevel: "error",
operational: true,
shouldReport: true
}
}
]
});
Pass the resolver to a preset or framework error adapter.
Logging and Observability
Canonical Log Shape
Request fields are stored in mdc; operation fields remain in data:
{
"ts": "2026-07-02T10:30:00.000-04:00",
"type": "INFO",
"msg": "External service call completed",
"mdc": {
"requestId": "request-id",
"traceId": "trace-id",
"spanId": "span-id",
"serviceName": "quickfade-bff-web"
},
"data": {
"target_service": "core-auth-service",
"operation": "profile.get",
"status_code": 200,
"duration_ms": 42
},
"tags": ["service-framework", "profile"]
}
LOGGER_TIMEZONE controls the timestamp offset. It does not change the instant being logged.
Automatic Redaction
Redaction covers nested objects, arrays, native errors, headers, cookies, URL credentials, query parameters, fragments, Bearer values, binary content, tokens, assertions, P12/PFX values, passwords, private keys, API keys, and OAuth signatures.
Custom fields extend mandatory defaults; they cannot disable built-in protection:
redactSensitiveData(payload, {
sensitiveFields: ["national_id"],
sensitiveQueryParameters: ["invite_code"],
maxStringLength: 2_000
});
Optional Hooks
const service = createNextBffService({
onHttpClientMetric(event) {
metrics.httpClient.observe(event);
},
onTokenValidationMetric(event) {
metrics.tokenValidation.observe(event);
},
onJwksRefresh(event) {
metrics.jwksRefresh.increment(event);
},
onAuthFailure(event) {
metrics.authFailure.increment(event);
}
});
Hooks are optional, receive frozen copies, and are failure-isolated. Synchronous throws and rejected promises are ignored so telemetry cannot alter authentication or request behavior.
Security
- Never commit P12/PFX content, passwords, tokens, cookies, or assertions.
- Store
CORE_OAUTH_P12_BASE64andCORE_OAUTH_P12_PASSWORD_BASE64in a secret manager. - Use
private_key_jwtinstead of a client secret when supported. - Keep JWT algorithms allowlisted; the environment default is RS256.
- Keep JWKS HTTPS and private-network blocking enabled.
- Use the
externalHTTP profile for third-party hosts. - Avoid
responseBodyLogging: "always"in production. - Do not put tokens, user identifiers, or request IDs in metric labels.
- Do not place sensitive values in custom MDC.
- Call
shutdownServiceFramework()on graceful process termination. - Rotate any credential that has appeared in a terminal transcript, chat, issue, or commit.
createCookieSafeNextJsonResponse() adds Cache-Control: no-store, Pragma: no-cache, and
Vary: Cookie, Authorization.
Troubleshooting
SERVICE_NAME is required
Environment-based presets require SERVICE_NAME.
SERVICE_NAME=example-service npm run example:express
For repository examples, use their .env.example as the starting point.
JWT validation is not configured
The token contains ., so it is treated as JWT, but no direct JWKS or discovery URL was configured.
Set one of:
CORE_OAUTH_GATEWAY_CERTS_URL=https://oauth.example.com/oauth2/v1/certs
# or
CORE_OAUTH_JWKS_URL=https://oauth.example.com/oauth2/v1/certs
# or
CORE_OAUTH_ISSUER_DISCOVERY_URL=https://oauth.example.com/.well-known/oauth-authorization-server
Invalid JWT issuer or Invalid JWT audience
Decode the token payload only for diagnosis; do not log the complete token. Compare iss and aud
with CORE_OAUTH_GATEWAY_ISSUER and CORE_OAUTH_JWT_AUDIENCE. Assertion audience and access-token
audience are different settings.
JWT algorithm is not allowed
The token header alg is outside CORE_OAUTH_ALLOWED_ALGORITHMS. Do not add an algorithm merely to
silence the error. Confirm the OAuth issuer's signing policy first. alg=none is always rejected.
P12 alias not found
CORE_OAUTH_P12_ALIAS must match the key entry friendly name, not merely a certificate subject.
The error includes available aliases but never includes key material or passwords.
P12 loads locally but fails in production
Confirm that:
- the
.p12binary was Base64-encoded without accidental quoting - the password itself was separately Base64-encoded
- the alias matches exactly, including case
- all three values belong to the same P12 deployment
- the P12 contains an RSA private key and matching certificate
No external binary is needed in production.
Rotation does not use the new environment value
Many hosting platforms do not mutate environment variables inside an already-running process.
Redeploy/restart, or inject a runtime P12KeyMaterialProvider. Failed rotations intentionally keep
the old key active.
Remote JWKS host is blocked
Check HTTPS, host allowlist, port allowlist, DNS resolution, redirects, and private/reserved IP
blocking. A wildcard such as *.example.com excludes the apex example.com; list both when needed.
HTTP call times out or retries unexpectedly
Check the selected profile, effective timeoutMs, retry.retries, and retry status list. The
retries value is the number of retries after the initial attempt.
Logs do not appear before process exit
Async mode buffers briefly. Call:
await shutdownServiceFramework();
Use LOGGER_MODE=sync only for short-lived scripts or diagnosis.
Next.js Edge runtime failure
Use:
export const runtime = "nodejs";
P12, Node.js crypto, logger-node, and AsyncLocalStorage are not Edge-runtime APIs.
Best Practices
- Prefer a service preset over assembling adapters manually.
- Configure
requiredScopesper endpoint; use global scopes only when every endpoint shares them. - Reuse one
OAuthGatewayClientand one signer per process. - Reuse
ClientCredentialsClientwhen downstream tokens should be cached. - Name every outbound operation (
profile.get,oauth.token.introspect) for stable metrics. - Use
internalHTTP only for trusted service-to-service calls. - Inject
fetcherand an explicitenvobject in tests. - Keep domain behavior in the service; use this package for cross-cutting infrastructure.
- Validate a replacement P12 before activating it and retain the previous registration during rollout.
- Run
npm run prepublishOnlybefore publishing.
Migration Guide
From Local Cross-Cutting Utilities
| Local responsibility | Replacement |
|---|---|
| trace header parsing | createTraceContext or framework adapters |
| custom AsyncLocalStorage | framework MDC (getMdc, withMdc) |
| JSON console logger | createLogger |
| custom redaction | redactSensitiveData |
| fetch wrapper | createHttpClient or createInternalServiceClient |
| Bearer parser | extractBearerToken |
| introspection/JWKS validation | OAuthTokenValidator |
| scope middleware | route-level requiredScopes / ScopeGuard |
| assertion shell scripts | environment-enabled OAuthGatewayClient |
| error JSON | AppError and framework error handlers |
Delete local code only after all imports and behavior have migrated. Keep controllers, use cases, repositories, domain validation, and authorization-server-specific protocol logic.
From JKS and Manual Assertions
| Previous | 0.3.0 |
|---|---|
| JKS file/Base64 | CORE_OAUTH_P12_BASE64 |
| store/key password | CORE_OAUTH_P12_PASSWORD_BASE64 |
| JKS alias | CORE_OAUTH_P12_ALIAS |
--client-id |
CORE_OAUTH_CLIENT_ID |
--aud |
CORE_OAUTH_ASSERTION_AUDIENCE |
manual client_assertion |
tokenByClientCredentials() |
manual JWT bearer assertion |
tokenByJwtBearer() |
export const oauthGateway = createOAuthGatewayClientFromEnv({
enableAssertionSigning: true
});
Remove JKS loaders, keytool, openssl, temporary-file handling, execFile, and shell assertion
invocations after both grants pass against the target environment.
Recommended service migration order:
| Service | First replacement |
|---|---|
quickfade-bff-web |
Next BFF preset, route scopes, internal HTTP |
core-login-provider |
Next adapters, OAuth Gateway client, P12 assertions |
core-auth-service |
protected routes, scope guards, downstream HTTP |
core-messaging-engine |
Express tracing/errors, internal HTTP |
core-oauth-gateway |
tracing, logging, redaction, HTTP; keep authorization-server domain logic |
CLI
npx @smb-tech/service-framework-js --help
Client Assertion:
npx @smb-tech/service-framework-js client-assertion \
--client-id core-auth-service \
--p12 ./client.p12 \
--p12-password secret \
--p12-alias client-key \
--aud https://oauth.example.com/oauth2/v1/token
JWT Bearer Assertion:
npx @smb-tech/service-framework-js jwt-bearer-assertion \
--client-id core-auth-service \
--claims-json '{"customer_id":"example-customer","tenant_id":"example-tenant"}' \
--p12-base64 "$CORE_OAUTH_P12_BASE64" \
--p12-password-base64 "$CORE_OAUTH_P12_PASSWORD_BASE64" \
--p12-alias "$CORE_OAUTH_P12_ALIAS" \
--aud https://oauth.example.com/oauth2/v1/token \
--scope cl:bff:web:profile:read
Encoding:
npx @smb-tech/service-framework-js p12-base64 --p12 ./client.p12
npx @smb-tech/service-framework-js password-base64 --password secret
Supported assertion flags:
--client-id
--aud
--p12 / --pfx
--p12-base64
--p12-password
--p12-password-base64
--p12-alias
--ttl-seconds
--json
--user-id
--claim
--claims-json
--scope
CLI-only environment fallbacks also include OAUTH_ASSERTION_USER_ID and
OAUTH_ASSERTION_CLAIMS_JSON.
The CLI prints generated assertions because that is its explicit purpose. Treat stdout as secret material and avoid CI logs or shell history.
AI Usage Notes
When generating or modifying a service that uses this package:
- Target Node.js
>=20. For Next.js, emitexport const runtime = "nodejs". - Start with
createNextApiService,createNextBffService, orcreateExpressApiService; do not duplicate tracing, error, or auth wrappers. - Treat
SERVICE_NAMEas required for all presets. - Do not configure OAuth for
createNextApiService()unless the application explicitly needs an OAuth client separately. - For protected Next routes, use
service.authRoute(handler, { requiredScopes }). - For Express, preserve middleware order: JSON parser, trace middleware, route auth, handlers, error handler.
- Distinguish
CORE_OAUTH_JWT_AUDIENCE(incoming tokens) fromCORE_OAUTH_ASSERTION_AUDIENCE(outgoing assertions). - For JWT bearer, place domain data in
claims; useassertionScopesfor the signed claim andrequestedScopesfor the OAuth form. - Never create or override
iss,sub,aud,iat,exp, orjtiinside custom claims. - Never output tokens, assertions, P12, passwords, cookies, private keys, or complete Authorization headers in examples or logs.
- Use
profile: "external"for public third-party APIs and do not propagate trace headers unless explicitly approved. - Observability hooks are optional and must remain side-effect-only.
- Use
AppErrorsubclasses for expected service failures. Do not expose internal stacks. - Do not introduce JKS, Java,
keytool,openssl, temporary key files, orchild_process. - Do not assume a P12 has one key; always require and use
p12Alias. - Before suggesting a new API, verify it exists in the package exports or source.
Canonical protected Next pattern:
const service = createNextBffService();
export const GET = service.authRoute(handler, {
requiredScopes: "cl:core:profile:read"
});
Canonical automatic signing pattern:
const oauth = createOAuthGatewayClientFromEnv({
enableAssertionSigning: true
});
await oauth.tokenByClientCredentials({ scope });
await oauth.tokenByJwtBearer({ claims, assertionScopes, requestedScopes });
Versioning and Development
Current package version: 0.3.0.
See CHANGELOG.md for release details. Coordinate breaking API changes with SMB Tech service owners before publishing.
npm install
npm run lint
npm run typecheck
npm test
npm run test:unit
npm run test:integration
npm run test:package
npm run build
npm run prepublishOnly
npm run test:package verifies ESM, CommonJS, package subpaths, declarations, CLI resolution, and
installation of the generated npm tarball in a temporary consumer.
Source maps are intentionally not published in 0.3.0.