npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@platform-x-shp/shp-auth-client

v0.0.21

Published

Shared Shopify/Keycloak tenant config resolver, resilient GraphQL client, and Keycloak token verification for Platform-X microservices

Readme

@platform-x-shp/shp-auth-client

Shared library for Platform-X's Shopify microservices — shp-auth-service, shp-cart-service, shp-customer-service, shp-order-service, shp-product-service. Given a tenant's site name (e.g. KIWI), it resolves that tenant's Shopify and Keycloak configuration from a secret store, and provides three ready-to-use pieces built on top of that config:

  1. A resilient, tenant-scoped GraphQL client for Shopify's Admin or Storefront API.
  2. An Express middleware that verifies a Keycloak-issued bearer token, so every consumer verifies tokens the same way instead of each running its own jwks-rsa setup.
  3. A small set of resilience helpers (retry, timeout, circuit breaker) and error classes shared across services.

Install

npm install @platform-x-shp/shp-auth-client

Peer dependency: express ^4.18.0.

Quick start

import { getTenantConfig, getGraphQLClient, gql } from '@platform-x-shp/shp-auth-client';

// Tenant config (Shopify + Keycloak settings for one site)
const config = await getTenantConfig('KIWI');
console.log(config.shopify.storeDomain); // e.g. "knma-vofh9g70.myshopify.com"

// A ready-to-use GraphQL client, pre-configured with the tenant's endpoint + auth header
const admin = await getGraphQLClient('KIWI', 'admin');
const data = await admin.request(gql`query { shop { name } }`);

Tenant config

How it's resolved

Every secret is looked up under an id built as:

${SITENAME}_${PROVIDER}_${KEY}   (uppercased)

e.g. buildSecretId('KIWI', 'SHOPIFY', 'STORE_DOMAIN') → KIWI_SHOPIFY_STORE_DOMAIN.

getTenantConfig(siteName: string): Promise<TenantConfig>
getActiveCommerceProvider(siteName: string): Promise<string>   // 'shopify' | 'commerce-tool' | 'none'
clearTenantCache(siteName?: string): Promise<void>
setConfigProvider(provider: ConfigProvider): Promise<void>   // mainly for tests — see Testing below

getTenantConfig(siteName) fetches all 18 keys for that tenant — 9 Shopify + 9 Keycloak — in parallel, and caches the assembled result in Redis for 5 minutes per site name (clearTenantCache() to bust it early, e.g. after rotating a secret) — shared across every instance/replica of every service that calls it, not just the one process that populated it. See Redis caching below for the connection config and fallback behavior.

Shopify keys: ADMIN_API_ACCESS_TOKEN, IS_ENABLED, PLAN_NAME, CLIENT_ID, CLIENT_SECRET, STORE_DOMAIN, STOREFRONT_API_VERSION, STOREFRONT_PRIVATE_TOKEN, WEBHOOK_HMAC_SECRET, CALLBACK_URL, POST_LOGOUT_REDIRECT_URI. IS_ENABLED is optional (unset reads as false) and informational only — it doesn't say which platform is active.

Active commerce platform: getActiveCommerceProvider(siteName) reads the single site-level ${SITENAME}_ECOMMERCE_ACTIVE_PROVIDER (shopify | commerce-tool | none) that Config Manager's Ecommerce form writes, falling back to the legacy ${SITENAME}_SHOPIFY_COMMERCE_PROVIDER and then to shopify. It needs no Shopify secrets, so it works for a tenant on another platform; cached in Redis for 5 minutes, cleared by clearTenantCache().

Keycloak keys: BASE_URL (well, KEYCLOCK_HOST — see below), REALM, CLIENT_ID, CLIENT_SECRET, DISCOVERY_URL, JWKS_URI, ISSUER.

CALLBACK_URL/POST_LOGOUT_REDIRECT_URI live under the SHOPIFY provider segment (KIWI_SHOPIFY_CALLBACK_URL, not KIWI_KEYCLOAK_CALLBACK_URL) even though they end up on TenantConfig.keycloak — they're Shopify's external-IdP redirect URLs for this store, not Keycloak realm data, so nothing about the realm can derive them.

Fetches are always per-key, never a bulk "list everything in the project" call — a GCP project may hold hundreds of unrelated secrets, and this package only ever needs these 18 per tenant.

Where secrets actually come from: SECRET_MODE

Each of those 18 lookups goes through hep-secret-access's secretManager(), which picks a backing provider based on the SECRET_MODE env var (the same convention hep-delivery-service uses):

| SECRET_MODE | Source | Notes | |---|---|---| | env | process.env[secretId] | For local dev — no GCP dependency. | | gsm | GCP Secret Manager, projects/{GOOGLE_PROJECT_ID}/secrets/{secretId}/versions/latest | Needs GOOGLE_PROJECT_ID and valid GCP credentials (ADC or a service account). | | csi | A file per secret under BASEPATH (default /var/secrets), named after the secret id | For a Kubernetes CSI Secret Store volume mount. |

If SECRET_MODE isn't set at all, createConfigProvider('auto') (used internally by getTenantConfig) falls back based on NODE_ENV:

  • NODE_ENV=production → gsm (throws if GOOGLE_PROJECT_ID is missing)
  • anything else → env

Force a specific mode for one call without touching the ambient env:

import { createConfigProvider, setConfigProvider } from '@platform-x-shp/shp-auth-client';

await setConfigProvider(createConfigProvider('secret-manager')); // forces gsm just for this provider

Note: unlike hep-secret-access's own EnvProvider (which silently returns '' for a missing key), this package always throws SecretNotFoundError on a missing/empty secret, regardless of mode — a misconfigured tenant should fail loudly, not propagate blank strings.

GOOGLE_PROJECT_ID vs GCP_PROJECT_ID: GSM mode reads GOOGLE_PROJECT_ID — that's what hep-secret-access's GoogleSecretManagerProvider actually uses. It is not the same as GCP_PROJECT_ID, which this package doesn't read at all. If GOOGLE_PROJECT_ID is missing while SECRET_MODE=gsm is explicitly set, nothing here will catch it — GSM lookups will silently target hep-secret-access's own fallback project (x-site-qa) instead of yours, and fail with a confusing "not found or no permission" error.

Environment variables

| Variable | Required when | Purpose | |---|---|---| | SECRET_MODE | optional | env | gsm | csi — overrides the NODE_ENV-based auto-selection. | | NODE_ENV | used by auto mode | production → defaults to gsm; anything else → env. | | GOOGLE_PROJECT_ID | gsm mode | GCP project id secrets are looked up in. | | BASEPATH | csi mode | Directory secret files are mounted under (default /var/secrets). | | KIWI_SHOPIFY_*, KIWI_KEYCLOAK_* | env mode | The 18 per-tenant values themselves (see table above), read directly from process.env. |

(Swap KIWI for whatever site name you call getTenantConfig/getGraphQLClient with — the prefix is derived from that argument, not hardcoded.)

Redis caching

getTenantConfig/getClientTenantDetails cache through Redis (src/redis-connector.ts) instead of an in-process cache — the same reference pattern hep-delivery-service uses (single shared client, cache-aside, resilient fallback):

| Variable | Required | Purpose | |---|---|---| | REDIS_HOST | for caching to work | Redis host. Read directly from process.env, like KEYCLOCK_HOST. | | REDIS_PORT | optional | Defaults to 6379. | | REDIS_PASS | optional | Resolved through the same secret provider (SECRET_MODE: env/gsm/csi) as every other secret in this package — not a plain env var in gsm/csi mode. |

Never blocks a request. Every Redis operation (getRedisValue/setRedisValue/ deleteFromRedis) swallows its own errors, logs them, and returns a value indistinguishable from a cache miss (null/'skipped'/0) — a Redis outage degrades every caller to "always fetch from Secret Manager/env," it never fails the request. The client is also configured to fail fast rather than hang: enable_offline_queue: false (a command issued while disconnected errors immediately instead of queuing) and a bounded retry_strategy (gives up reconnecting after 2 attempts, ~1.5s, instead of node-redis's default unbounded backoff) — without both, a caller awaiting getTenantConfig when Redis is unreachable would hang for a long time before falling through to Secret Manager, rather than failing over quickly.

If REDIS_HOST is unset or unreachable, caching is simply disabled — getTenantConfig still works, it just fetches from Secret Manager/env on every call instead of every 5 minutes.


GraphQL client

getGraphQLClient(siteName: string, type?: 'admin' | 'storefront'): Promise<GraphQLClient>
clearGraphQLClientCache(siteName?: string): void

Returns a cached graphql-request client, pre-configured per type:

| type | Endpoint | Auth header | |---|---|---| | admin (default) | https://{storeDomain}/admin/api/{storefrontApiVersion}/graphql.json | X-Shopify-Access-Token: {adminApiAccessToken} | | storefront | https://{storeDomain}/api/{storefrontApiVersion}/graphql.json | Shopify-Storefront-Private-Token: {storefrontPrivateToken} |

storefrontPrivateToken is a private (server-side) Storefront token — it must go through the Shopify-Storefront-Private-Token header, not the public-token header (X-Shopify-Storefront-Access-Token), which Shopify rejects it with a 401.

Call clearGraphQLClientCache(siteName?) after rotating a token so the next call rebuilds the client with fresh config instead of reusing the cached one.

This package also extends Node's default TLS trust store with the OS certificate store at import time (see tls-trust.ts), so getGraphQLClient calls succeed behind a TLS-intercepting corporate proxy without every consuming service needing its own --use-system-ca flag or CA bundle file.

Cache inspection

getCacheStats(): {
  tenantConfig: { size: number; entries: Array<{ key: string; expiresInMs: number }> };
  authTenantsInfo: { size: number; entries: Array<{ key: string; expiresInMs: number }> };
  graphqlClients: { size: number; keys: string[] };
}

A snapshot of every cache this package owns — the Redis-backed tenant-config cache (5-minute TTL), the Redis-backed AUTH_TENANTS_INFO cache (5-minute TTL), and the in-process (TTL-less) GraphQL client cache — keyed by site name (and siteName:type for GraphQL clients). Reports only keys and remaining TTL, never secret values or tokens, so it's safe to expose from a service's own debug endpoint, e.g.:

app.get('/api/_debug/cache', (_req, res) => res.json(getCacheStats()));

Resilience helpers

Building blocks for wrapping outbound calls — used internally by services that call getGraphQLClient, also exported for direct use:

withRetry(fn, retries = 3, baseMs = 200, shouldRetry?): Promise<T>   // exponential backoff
withTimeout(fn, ms = 5000): Promise<T>                               // throws TimeoutError
new CircuitBreaker(failureThreshold = 5, resetAfterMs = 30_000)       // .call(fn), .isOpen(), .reset()

Typical usage in a consuming service:

const breaker = new CircuitBreaker();

async function adminGraphql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
  return breaker.call(() =>
    withTimeout(() =>
      withRetry(async () => {
        const client = await getGraphQLClient('KIWI', 'admin');
        return client.request<T>(query, variables);
      }, 3, 300, (err) => err instanceof ClientError && (err.response?.status === 429 || (err.response?.status ?? 0) >= 500)),
      15000
    )
  );
}

Keycloak token verification

extractBearer(authorization?: string): string
verifyKeycloakToken(token: string, config: KeycloakVerifyConfig): Promise<KeycloakTokenPayload>
keycloakConfigForTenant(siteName: string, audience?: string, requestSource?: string): Promise<KeycloakVerifyConfig>
requireKeycloakAuth(config: KeycloakVerifyConfig | ((req) => KeycloakVerifyConfig | Promise<KeycloakVerifyConfig>))
  • extractBearer strips the Bearer prefix from an Authorization header, throwing UnauthorizedError if it's missing or malformed.
  • verifyKeycloakToken checks a token's RS256 signature (via config.jwksUri), issuer, and — if given — audience. One jwks-rsa client is created per distinct jwksUri and reused across calls (it has its own internal signing-key cache + rate limiter), so calling this repeatedly for the same tenant doesn't refetch keys. If config.expectedClientId is set, it also checks the token's azp (authorized party) claim against it, rejecting with TokenInvalidError on a mismatch — this is what stops a token issued to one flow's Keycloak client from being replayed against the other.
  • keycloakConfigForTenant(siteName, audience?, requestSource?) builds a KeycloakVerifyConfig from getTenantConfig(siteName).keycloak — the issuer/JWKS come from the tenant's resolved config, not a locally duplicated env var. audience is passed through as-is since it's a per-caller restriction, not a per-tenant secret. requestSource — normally the request's request-source header — sets expectedClientId to that flow's registered client (keycloak.clientId for 'delivery', keycloak.authoringClientId for 'authoring') so the token is validated against the client that flow is supposed to use; omit it, or pass anything else, to skip that check.
  • requireKeycloakAuth is the Express middleware most services actually use. It takes either a static KeycloakVerifyConfig (a service with its own fixed issuer/JWKS env vars — e.g. shp-auth-service's admin-facing Keycloak config) or a function that resolves one per request — typically () => keycloakConfigForTenant(SITE_NAME). On success it sets req.user = { sub, role } and req.keycloakUser (the full decoded payload) before calling next().
import { requireKeycloakAuth, keycloakConfigForTenant } from '@platform-x-shp/shp-auth-client';

export const requireAuth = requireKeycloakAuth(() =>
  keycloakConfigForTenant('KIWI', process.env.KEYCLOAK_AUDIENCE || undefined),
);

router.get('/orders/:id', requireAuth, ordersController.getOrder);

Failures reject with TokenExpiredError, TokenInvalidError, or UnauthorizedError (see Errors below) — requireKeycloakAuth forwards them to next(err), so a service's own globalErrorHandler (or this package's) maps them to the right HTTP status.


PlatformX token verification

A second, independent verification path — shp-auth-service signs a short-lived "PlatformX" customer bearer as part of the delivery-channel Shopify-first SSO flow (see its README), and the other 4 services verify it. Structurally mirrors Keycloak verification above, minus the JWKS/kid lookup — each tenant has exactly one RS256 keypair, not a rotating set.

signPlatformXToken(payload: { sub: string; sid: string; tenant: string }, config: PlatformXSigningConfig, expiresIn?: string | number): string
verifyPlatformXToken(token: string, config: PlatformXVerifyConfig): Promise<PlatformXTokenPayload>
platformXConfigForTenant(siteName: string): Promise<PlatformXVerifyConfig>
platformXSigningConfigForTenant(siteName: string): Promise<PlatformXSigningConfig>
requirePlatformXAuth(config: PlatformXVerifyConfig | ((req) => PlatformXVerifyConfig | Promise<PlatformXVerifyConfig>))
  • signPlatformXToken/verifyPlatformXToken sign/verify with an explicit algorithm: 'RS256' — only shp-auth-service is ever provisioned with the private key; every other service resolves an empty string for it (see below), so signing is not something they can do even by accident.
  • platformXConfigForTenant(siteName) / platformXSigningConfigForTenant(siteName) build a PlatformXVerifyConfig/PlatformXSigningConfig from getTenantConfig(siteName).platformX — same resolution pattern as keycloakConfigForTenant.
  • Key distribution is a provisioning concern, not a code branch: platformX.publicKey is resolved the same way for every service; platformX.privateKey only ever has a real value where ${SITENAME}_SHOPIFY_PLATFORMX_PRIVATE_KEY is actually provisioned (env/Secret Manager) — by design, only shp-auth-service's deployment has that secret. The 4 downstream services never receive it at all, so platformXSigningConfigForTenant resolves an unusable empty string for them.
  • Read under the SHOPIFY provider segment, not a dedicated PLATFORMX one — i.e. ${SITENAME}_SHOPIFY_PLATFORMX_PUBLIC_KEY / ${SITENAME}_SHOPIFY_PLATFORMX_PRIVATE_KEY. This matches where Config Manager's Ecommerce/Shopify form (platformx-mono-repo-nx) necessarily saves anything entered there — its backend unconditionally prefixes every field in that section with ${SITENAME}_SHOPIFY_, the same mechanism that already double-prefixes shopify_client_id/ shopify_client_secret (see fetchShopifyConfig's own doc comment in tenant-config.ts).
  • Stored base64-encoded. Config Manager's field for these is a plain single-line <input>, which would silently strip a raw multi-line PEM's newlines on paste — so the value is base64 before it's saved, the same reason AUTH_TENANTS_INFO already is. fetchPlatformXConfig detects and passes through an already-raw PEM (its -----BEGIN marker) for a key set directly in Secret Manager instead, so either provisioning path works.
  • requirePlatformXAuth mirrors requireKeycloakAuth's middleware shape, setting req.platformXUser on success.

Downstream services don't call this in isolation, though — buildContext tries verifyPlatformXToken first for a delivery (or unset request-source) request, falling back to verifyKeycloakToken only if that fails (keeping a still-live legacy ROPC session working). See any of the 4 consuming services' src/gqlLayer/context.ts for the reference pattern.


Errors

All thrown as plain Error subclasses with a distinct .name:

| Class | Meaning | |---|---| | TenantNotFoundError | Tenant config not found for a site name | | SecretNotFoundError | A specific secret was missing/empty | | TokenExpiredError / TokenInvalidError | Keycloak token verification failures | | UnauthorizedError / ForbiddenError / CrossTenantAccessError | Access control | | ShopifyApiError / ShopifyUserError | Shopify API / GraphQL user errors | | TimeoutError / CircuitOpenError | From withTimeout / CircuitBreaker |

globalErrorHandler(err, req, res, next) is an optional Express error-handling middleware that maps each of the above to an HTTP status code and returns { error, code } — use it directly, or as a reference for a service's own error handler.

Multi-tenancy from a request

extractSiteName(req): string | null   // 'sitename'/'sitehost' header, or hostname, uppercased

Every consuming service (auth, customer, product, cart, order) resolves its tenant this way now — per request, from the sitename/sitehost header (falling back to the Host header) — instead of a single fixed SITE_NAME env var at startup. Each service wires it into a siteContext middleware that rejects a request with neither header (400), then stashes the resolved site name in AsyncLocalStorage for the rest of that request (see any consuming service's src/middlewares/siteContext.ts / src/common/site-context.ts for the reference pattern).

Logging

Logger.info(message, method, payload?)
Logger.warn(message, method, payload?)
Logger.error(message, method, payload?)   // payload as an Error logs its stack instead
Logger.debug(message, method, payload?)

A bunyan-backed logger, configured entirely through env vars — no setup call needed:

| Env var | Effect | |---|---| | APP_NAME | Logger name (default shp-auth-client) | | NODE_ENV=development | Human-readable short output instead of raw bunyan JSON | | LOG_LEVELS | Comma-separated subset of info,warn,error,debug to actually emit — independent per level, not a threshold (e.g. LOG_LEVELS=error,debug shows only those two). Unset/empty enables all four. | | LOG_SHOW_SENSITIVE_FIELDS=true | Local debugging only: reveals fields normally masked (password, token, secret, authorization, etc. — see BLOCKED_KEYWORDS) instead of printing XXXXXXXXXX. Ignored when NODE_ENV=production. |

Payload objects are sanitized automatically — any key matching a blocked keyword (case-insensitive) is masked before it reaches the log stream.


Testing

setConfigProvider(provider) swaps the module-level ConfigProvider getTenantConfig uses (and clears the tenant cache) — pass a mock implementing { get(secretId): Promise<string> } in tests instead of hitting env/gsm/csi for real. It's async (the cache-clear is a Redis call) — await it.

Any test that exercises getTenantConfig/getClientTenantDetails (directly, or transitively through keycloakConfigForTenant/requireKeycloakAuth) should also jest.mock('@platform-x-shp/shp-auth-client/dist/redis-connector', ...) (or the src/ path in this package's own tests) with a fake in-memory store — see tests/helpers/fakeRedisConnector.ts. Without it, tests fall through to a real Redis connection attempt; the client fails fast (~1.5s) rather than hanging, but that's still real overhead you don't want in a unit test, and the "caches across repeated calls" assertions won't hold against a Redis instance you don't control.

npm test         # cross-env NODE_ENV=test jest --runInBand
npm run typecheck
npm run lint

Full API surface

export { getTenantConfig, clearTenantCache, setConfigProvider };
export { extractSiteName, buildSecretId };
export { createConfigProvider };
export type { ProviderMode };
export { HepSecretProvider };
export { TTLCache };
export { getGraphQLClient, clearGraphQLClientCache, gql, ClientError };
export { withRetry, withTimeout, CircuitBreaker };
export { extractBearer, verifyKeycloakToken, keycloakConfigForTenant, requireKeycloakAuth };
export type { KeycloakTokenPayload, KeycloakVerifyConfig };
export { signPlatformXToken, verifyPlatformXToken, platformXConfigForTenant, platformXSigningConfigForTenant, requirePlatformXAuth };
export type { PlatformXTokenPayload, PlatformXSigningConfig, PlatformXVerifyConfig };
export { Logger };
export type { LoggerInterface };
export { getCacheStats };
export {
  TenantNotFoundError, SecretNotFoundError,
  TokenExpiredError, TokenInvalidError, UnauthorizedError,
  CrossTenantAccessError, ForbiddenError,
  ShopifyApiError, ShopifyUserError,
  TimeoutError, CircuitOpenError,
  globalErrorHandler,
};
export type { TenantConfig, ShopifyConfig, KeycloakConfig, PlatformXConfig, ConfigProvider };

Development

npm run build   # tsc -> dist/
npm run dev     # tsc --watch
npm run clean   # rm -rf dist

Bump version in package.json and npm publish (registry: registry.npmjs.org, scope @platform-x per .npmrc) to ship changes to consuming services — they pin @platform-x-shp/shp-auth-client by version, so a local change here has no effect on them until published and their dependency is bumped + reinstalled.