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

@spidy092/service-auth

v1.0.0

Published

Keycloak Client Credentials service-to-service authentication library

Readme

service-auth

Keycloak Client Credentials service-to-service authentication library. Provides everything a microservice needs to call other services securely and receive authenticated calls from them.

Table of Contents


Overview

Problem: Microservices were authenticating with each other using:

  • Hardcoded shared secrets (x-service-secret) — insecure, not rotatable, no identity
  • Forwarded user browser JWTs (req.headers.authorization) — breaks for background jobs

Solution: This package provides three components:

| Component | Who Uses It | What It Does | | ----------------------- | ----------- | ------------------------------------------- | | ServiceTokenManager | Callers | Fetches + caches Keycloak tokens | | ServiceHttpClient | Callers | Auto-authenticated HTTP client | | serviceAuthMiddleware | Receivers | Validates incoming JWTs from other services |


Architecture

┌─────────────────┐     Client Credentials     ┌──────────────┐
│   PMS Service    │ ──────────────────────────► │   Keycloak   │
│                  │ ◄── JWT access_token ────── │              │
│  ServiceHttp     │                             └──────────────┘
│     Client       │
│                  │     Bearer <jwt>            ┌──────────────┐
│                  │ ──────────────────────────► │ Email Service│
│                  │                             │              │
│                  │                             │ serviceAuth  │
│                  │                             │  Middleware   │
└─────────────────┘                             └──────────────┘

                    ┌──────────────────────────────────────────┐
                    │        service-auth Package               │
                    │                                          │
                    │  ┌──────────────────┐  ┌──────────────┐  │
                    │  │ ServiceToken     │  │ serviceAuth  │  │
                    │  │   Manager        │  │  Middleware   │  │
                    │  │ (fetch + cache)  │  │ (validate)   │  │
                    │  └───────┬──────────┘  └──────────────┘  │
                    │          │                                │
                    │  ┌───────▼──────────┐                    │
                    │  │ ServiceHttp      │                    │
                    │  │   Client         │                    │
                    │  │ (auto-inject)    │                    │
                    │  └──────────────────┘                    │
                    └──────────────────────────────────────────┘

Installation

npm install service-auth

Or link locally during development:

# From your service directory
npm link ../service-auth

Quick Start

For Callers (Making Requests)

const { ServiceHttpClient } = require('service-auth');

const client = new ServiceHttpClient({
    keycloakUrl: process.env.KEYCLOAK_URL,       // http://keycloak:8080
    realm:       process.env.KEYCLOAK_REALM,      // my-projects
    clientId:    process.env.SERVICE_CLIENT_ID,    // pms-service
    clientSecret:process.env.SERVICE_CLIENT_SECRET,// from Keycloak
});

// Make authenticated requests — token is auto-managed
const result = await client.post('http://email-service:4011/api/v1/email/send', {
    type: 'SECURITY_ALERT',
    to: '[email protected]',
    data: { alertTitle: 'Login from new device' },
});

console.log(result.status); // 202
console.log(result.data);   // { success: true, ... }

For Receivers (Accepting Requests)

const { serviceAuthMiddleware } = require('service-auth');

// Protect internal API routes
app.use('/api/internal', serviceAuthMiddleware({
    keycloakUrl: process.env.KEYCLOAK_URL,
    realm:       process.env.KEYCLOAK_REALM,
    allowedClients: ['pms-service', 'auth-service'], // optional whitelist
}));

// In your route handler:
app.post('/api/internal/send', (req, res) => {
    console.log(req.service.clientId); // 'pms-service'
    console.log(req.service.isService); // true
    // ... handle request
});

API Reference

ServiceTokenManager

Low-level token manager. Use ServiceHttpClient instead unless you need raw tokens.

const { ServiceTokenManager } = require('service-auth');

const manager = new ServiceTokenManager({
    keycloakUrl: 'http://keycloak:8080',  // Required
    realm: 'my-projects',                  // Required
    clientId: 'pms-service',               // Required
    clientSecret: '<secret>',              // Required
    tokenRefreshBuffer: 30,                // Optional (seconds, default: 30)
});

| Method | Returns | Description | | -------------- | ----------------- | --------------------------------- | | getToken() | Promise<string> | Get valid token (cached or fresh) | | invalidate() | void | Force fresh fetch on next call |

Features:

  • In-memory caching — tokens cached until expires_in - buffer
  • Promise deduplication — concurrent getToken() calls share one fetch
  • Auto-retry — failed fetches don't poison the cache; next call retries

ServiceHttpClient

Pre-authenticated HTTP client. Recommended for all service-to-service calls.

const { ServiceHttpClient } = require('service-auth');

const client = new ServiceHttpClient({
    // Same config as ServiceTokenManager, plus:
    baseUrl: 'http://email-service:4011', // Optional — enables relative URLs
});

| Method | Params | Description | | --------------------------- | --------------- | ------------------------------- | | get(url, options?) | URL string | GET with Bearer token | | post(url, body, options?) | URL + JSON body | POST with Bearer + Content-Type | | put(url, body, options?) | URL + JSON body | PUT with Bearer + Content-Type | | delete(url, options?) | URL string | DELETE with Bearer token | | invalidateToken() | — | Force token refresh |

Response format:

{
    status: 200,      // HTTP status code
    data: { ... }     // Parsed JSON (or raw text for non-JSON)
}

Error handling:

try {
    await client.post('http://service/api', data);
} catch (err) {
    err.code;       // 'AUTH_FAILED' | 'NETWORK_ERROR' | 'SERVICE_CALL_FAILED'
    err.statusCode; // HTTP status or 502
    err.details;    // { url, method, status, body }
}

serviceAuthMiddleware

Express middleware factory for services that receive authenticated requests.

const { serviceAuthMiddleware } = require('service-auth');

const middleware = serviceAuthMiddleware({
    keycloakUrl: 'http://keycloak:8080',     // Required
    realm: 'my-projects',                     // Required
    issuerUrl: 'https://auth.example.com',    // Optional (if external URL differs)
    allowedClients: ['pms-service'],          // Optional (whitelist)
    legacySecret: process.env.SERVICE_SECRET, // Optional (enables dual mode)
    logger: customLogger,                     // Optional (default: console)
});

app.use('/api/v1', middleware);

On success, sets req.service:

req.service = {
    sub: 'keycloak-uuid',         // Keycloak subject ID
    clientId: 'pms-service',      // Which service is calling
    roles: ['uma_authorization'], // Keycloak roles
    isService: true,              // Always true for service tokens
    isLegacy: false,              // true if legacy x-service-secret
};

Also sets req.user for backward compatibility with existing code.

HTTP responses on failure:

| Status | Code | When | | ------ | -------------------- | -------------------------- | | 401 | MISSING_AUTH | No Authorization header | | 401 | TOKEN_EXPIRED | JWT expired | | 401 | INVALID_TOKEN | Bad signature or malformed | | 403 | CLIENT_NOT_ALLOWED | Client not in whitelist |


Error Classes

const { ServiceAuthError, TokenFetchError, TokenValidationError } = require('service-auth');

// Base error for all package errors
ServiceAuthError {
    name: 'ServiceAuthError',
    code: 'AUTH_FAILED',       // Error code string
    statusCode: 502,           // Suggested HTTP status
    details: { ... },          // Context (url, cause, etc.)
}

// Thrown when Keycloak token fetch fails
TokenFetchError extends ServiceAuthError {
    code: 'TOKEN_FETCH_ERROR',
    statusCode: 502,
}

// Thrown when incoming JWT validation fails
TokenValidationError extends ServiceAuthError {
    code: 'TOKEN_VALIDATION_ERROR',
    statusCode: 401,
}

Environment Variables

For Callers (PMS, Auth Service, any future service)

| Variable | Required | Default | Description | | ----------------------- | -------- | ------------- | ------------------------------------------ | | KEYCLOAK_URL | ✅ | — | Keycloak base URL (http://keycloak:8080) | | KEYCLOAK_REALM | ❌ | my-projects | Keycloak realm name | | SERVICE_CLIENT_ID | ✅ | — | This service's Keycloak client ID | | SERVICE_CLIENT_SECRET | ✅ | — | This service's Keycloak client secret |

For Receivers (Email Service, Auth Service)

| Variable | Required | Default | Description | | --------------------- | -------- | ------------- | ----------------------------------------------------- | | KEYCLOAK_URL | ✅ | — | Keycloak base URL | | KEYCLOAK_REALM | ❌ | my-projects | Keycloak realm name | | LEGACY_AUTH_ENABLED | ❌ | true | Set to false to disable x-service-secret fallback |


How It Works

Token Lifecycle

1. Service starts → ServiceHttpClient created (no token yet)

2. First API call → getToken() called
   ├─ Cache empty → POST /realms/{realm}/protocol/openid-connect/token
   │                 grant_type=client_credentials
   │                 client_id=pms-service
   │                 client_secret=<secret>
   ├─ Keycloak responds → { access_token: "eyJ...", expires_in: 300 }
   ├─ Token cached with expiresAt = now + 300s
   └─ Token injected as: Authorization: Bearer eyJ...

3. Subsequent calls within 270s (300s - 30s buffer)
   └─ getToken() returns cached token instantly (no network call)

4. After 270s → token considered "stale"
   └─ getToken() fetches a fresh token from Keycloak

5. Concurrent calls during refresh
   ├─ Call A triggers fetch → creates promise
   ├─ Call B arrives → detects in-flight promise → waits for same promise
   └─ Both calls get the same fresh token (only 1 HTTP request)

User vs Service Token Detection

The hybridAuthMiddleware in auth-service auto-detects token type by inspecting JWT claims:

| JWT Claim | User Token | Service Token | | -------------------- | -------------------- | ----------------------------- | | email | ✅ [email protected] | ❌ absent | | preferred_username | johndoe | service-account-pms-service | | clientId | ❌ absent | ✅ pms-service |

// Detection logic (in hybridAuthMiddleware):
const isService = !decoded.email &&
    (decoded.preferred_username?.startsWith('service-account-') || !!decoded.clientId);
  • Service token → JWKS validation only, no DB lookup, no ghost users
  • User token → Full authMiddleware flow (UserMetadata, roles, permissions)

Dual Mode (Migration Support)

During migration, services can accept both old x-service-secret and new JWT:

serviceAuthMiddleware({
    keycloakUrl: process.env.KEYCLOAK_URL,
    realm: process.env.KEYCLOAK_REALM,
    legacySecret: process.env.SERVICE_SECRET, // enables dual mode
});

Every legacy auth request logs a deprecation warning:

⚠️ DEPRECATED: Request authenticated via x-service-secret. Migrate to JWT.

To disable legacy auth permanently:

LEGACY_AUTH_ENABLED=false

Keycloak Setup

Automated (recommended)

Use the CLI tool to register a service as a Keycloak confidential client:

# Set admin credentials
export KEYCLOAK_ADMIN_URL=http://localhost:8080
export KEYCLOAK_ADMIN_USER=admin
export KEYCLOAK_ADMIN_PASS=your-admin-password

# Register a service
node scripts/setup-service-client.js --name=pms-service --realm=my-projects

Output:

═══════════════════════════════════════════════════════════
  📋 Add these to your service's .env file:
═══════════════════════════════════════════════════════════

SERVICE_CLIENT_ID=pms-service
SERVICE_CLIENT_SECRET=abc123-generated-secret
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=my-projects

Manual (via Keycloak Admin Console)

  1. Login to Keycloak Admin → Select your realm
  2. ClientsCreate Client
  3. Set:
    • Client ID: pms-service
    • Client authentication: ON
    • Authentication flow: check only Service accounts roles
  4. Save → go to Credentials tab → copy the Client secret
  5. Add to your service's .env:
    SERVICE_CLIENT_ID=pms-service
    SERVICE_CLIENT_SECRET=<copied-secret>

Usage Examples

PMS → Email Service

// pms/services/serviceClients.js — SINGLE source of truth
const { ServiceHttpClient } = require('service-auth');

let _emailClient = null;
function emailClient() {
    if (!_emailClient) {
        _emailClient = new ServiceHttpClient({
            keycloakUrl: process.env.KEYCLOAK_URL,
            realm: process.env.KEYCLOAK_REALM || 'my-projects',
            clientId: process.env.SERVICE_CLIENT_ID,
            clientSecret: process.env.SERVICE_CLIENT_SECRET,
            baseUrl: process.env.EMAIL_SERVICE_URL || 'http://email-service:4011',
        });
    }
    return _emailClient;
}

module.exports = { emailClient };
// pms/services/email-client.js — uses centralized client
const { emailClient } = require('./serviceClients');

async function send({ type, to, data }) {
    const client = emailClient();
    const result = await client.post('/api/v1/email/send', { type, to, data });
    return result.data;
}

PMS → Auth Service

// pms/services/task/task.service.js
const { authClient } = require('../serviceClients');

// OLD (insecure — forwarded user's browser JWT):
// const authResponse = await fetch(url, {
//     headers: { Authorization: req.headers.authorization }
// });

// NEW (service-to-service auth):
const client = authClient();
const authResponse = await client.post(
    `${DOMAIN.auth}/auth/workspaces/members/lookup`,
    { user_ids: uniqueUserIds, workspace_ids: uniqueDepartmentIds }
);

Auth Service → Email Service

// auth-service/services/serviceClients.js
const { ServiceHttpClient } = require('service-auth');

let _emailClient = null;
function emailClient() {
    if (!_emailClient) {
        _emailClient = new ServiceHttpClient({
            keycloakUrl: process.env.KEYCLOAK_URL,
            realm: process.env.KEYCLOAK_REALM || 'my-projects',
            clientId: process.env.AUTH_SERVICE_CLIENT_ID || 'auth-service',
            clientSecret: process.env.AUTH_SERVICE_CLIENT_SECRET,
            baseUrl: process.env.EMAIL_SERVICE_URL || 'http://email-service:4011',
        });
    }
    return _emailClient;
}

module.exports = { emailClient };

Adding a New Service

5-minute setup for any future service:

Step 1: Register in Keycloak

node scripts/setup-service-client.js --name=notification-service --realm=my-projects

Step 2: Install the package

npm install service-auth

Step 3: Add env vars (from Step 1 output)

SERVICE_CLIENT_ID=notification-service
SERVICE_CLIENT_SECRET=<generated>
KEYCLOAK_URL=http://keycloak:8080
KEYCLOAK_REALM=my-projects

Step 4: Create serviceClients.js

const { ServiceHttpClient } = require('service-auth');

let _emailClient = null;
function emailClient() {
    if (!_emailClient) {
        _emailClient = new ServiceHttpClient({
            keycloakUrl: process.env.KEYCLOAK_URL,
            realm: process.env.KEYCLOAK_REALM,
            clientId: process.env.SERVICE_CLIENT_ID,
            clientSecret: process.env.SERVICE_CLIENT_SECRET,
            baseUrl: 'http://email-service:4011',
        });
    }
    return _emailClient;
}
module.exports = { emailClient };

Step 5: Use it

const { emailClient } = require('./serviceClients');
await emailClient().post('/api/v1/email/send', { type: 'WELCOME', to: '[email protected]', data: {} });

Done. The token is fetched, cached, refreshed, and injected automatically.


Centralized Client Pattern

Important: Do NOT duplicate the ServiceHttpClient initialization in every file.

Bad — duplicated in every service file:

// email-client.js
const client = new ServiceHttpClient({ ...config });

// task.service.js
const client = new ServiceHttpClient({ ...config });  // same config!

// notification.service.js
const client = new ServiceHttpClient({ ...config });  // same config again!

Good — one serviceClients.js per microservice:

// serviceClients.js — SINGLE source of truth
function emailClient() { /* lazy singleton */ }
function authClient()  { /* lazy singleton */ }
module.exports = { emailClient, authClient };

// Any file that needs it:
const { emailClient } = require('./serviceClients');
await emailClient().post('/api/v1/email/send', data);

Testing

# Run all tests
cd service-auth
npm test

# Run specific test suite
npx jest tests/ServiceTokenManager.test.js --verbose
npx jest tests/ServiceHttpClient.test.js --verbose
npx jest tests/serviceAuthMiddleware.test.js --verbose

Current coverage: 37 tests across 3 suites (all passing)

| Suite | Tests | What's Covered | | --------------------- | ----- | ----------------------------------------------------------------- | | ServiceTokenManager | 16 | Cache hit/miss, expiry, deduplication, config validation, errors | | ServiceHttpClient | 9 | Token injection, base URL, error handling, caching, non-JSON | | serviceAuthMiddleware | 12 | JWT validation, expiry, client whitelist, JWKS failure, dual mode |


Troubleshooting

TokenFetchError: Failed to connect to Keycloak

  • Check KEYCLOAK_URL is reachable from your service container
  • Verify the Keycloak server is running
  • Check network connectivity (Docker networks, firewalls)

TokenFetchError: Failed to fetch service token: 401 Unauthorized

  • Verify SERVICE_CLIENT_ID and SERVICE_CLIENT_SECRET are correct
  • Check the client exists in Keycloak and is enabled
  • Ensure "Service Accounts Enabled" is checked in Keycloak

403 CLIENT_NOT_ALLOWED

  • The receiving service has allowedClients configured
  • Add your service's client ID to the whitelist

401 TOKEN_EXPIRED

  • Token expired between fetch and use (unlikely with 30s buffer)
  • Check system clock synchronization between services

Token not refreshing

  • The default buffer is 30 seconds before expiry
  • Keycloak default token lifetime is 5 minutes (300s)
  • Tokens refresh automatically — no manual intervention needed

Legacy x-service-secret rejected after migration

  • Set LEGACY_AUTH_ENABLED=true (or remove the env var) to re-enable
  • This is by design — set to false only after all callers are migrated