@platform-x/shp-auth-client
v0.0.1
Published
Shared Shopify/Keycloak tenant config resolver, resilient GraphQL client, and Keycloak token verification for Platform-X microservices
Maintainers
Keywords
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:
- A resilient, tenant-scoped GraphQL client for Shopify's Admin or Storefront API.
- 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-rsasetup. - A small set of resilience helpers (retry, timeout, circuit breaker) and error classes shared across services.
Install
npm install @platform-x-shp/shp-auth-clientPeer 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>
clearTenantCache(siteName?: string): void
setConfigProvider(provider: ConfigProvider): void // mainly for tests — see Testing belowgetTenantConfig(siteName) fetches all 18 keys for that tenant — 9 Shopify + 9 Keycloak — in
parallel, and caches the result for 5 minutes per site name (clearTenantCache() to bust it
early, e.g. after rotating a secret).
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.
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 ifGOOGLE_PROJECT_IDis 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';
setConfigProvider(createConfigProvider('secret-manager')); // forces gsm just for this providerNote: unlike
hep-secret-access's ownEnvProvider(which silently returns''for a missing key), this package always throwsSecretNotFoundErroron 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.)
GraphQL client
getGraphQLClient(siteName: string, type?: 'admin' | 'storefront'): Promise<GraphQLClient>
clearGraphQLClientCache(siteName?: string): voidReturns 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.
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): Promise<KeycloakVerifyConfig>
requireKeycloakAuth(config: KeycloakVerifyConfig | ((req) => KeycloakVerifyConfig | Promise<KeycloakVerifyConfig>))extractBearerstrips theBearerprefix from anAuthorizationheader, throwingUnauthorizedErrorif it's missing or malformed.verifyKeycloakTokenchecks a token's RS256 signature (viaconfig.jwksUri), issuer, and — if given — audience. Onejwks-rsaclient is created per distinctjwksUriand 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.keycloakConfigForTenant(siteName, audience?)builds aKeycloakVerifyConfigfromgetTenantConfig(siteName).keycloak— the issuer/JWKS come from the tenant's resolved config, not a locally duplicated env var.audienceis passed through as-is since it's a per-caller restriction, not a per-tenant secret.requireKeycloakAuthis the Express middleware most services actually use. It takes either a staticKeycloakVerifyConfig(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 setsreq.user = { sub, role }andreq.keycloakUser(the full decoded payload) before callingnext().
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.
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 (optional)
extractSiteName(req): string | null // 'sitehost'/'sitename' header, or hostname, uppercasedNot currently wired into any consuming service's request pipeline (each service today resolves a
single fixed SITE_NAME from its own env at startup) — provided for a future per-request,
multi-tenant routing scenario.
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.
npm test # cross-env NODE_ENV=test jest --runInBand
npm run typecheck
npm run lintFull 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 {
TenantNotFoundError, SecretNotFoundError,
TokenExpiredError, TokenInvalidError, UnauthorizedError,
CrossTenantAccessError, ForbiddenError,
ShopifyApiError, ShopifyUserError,
TimeoutError, CircuitOpenError,
globalErrorHandler,
};
export type { TenantConfig, ShopifyConfig, KeycloakConfig, ConfigProvider };Development
npm run build # tsc -> dist/
npm run dev # tsc --watch
npm run clean # rm -rf distBump 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.
