@spidy092/service-auth
v1.0.0
Published
Keycloak Client Credentials service-to-service authentication library
Maintainers
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
- Architecture
- Installation
- Quick Start
- API Reference
- Environment Variables
- How It Works
- Keycloak Setup
- Usage Examples
- Centralized Client Pattern
- Testing
- Troubleshooting
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-authOr link locally during development:
# From your service directory
npm link ../service-authQuick 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
authMiddlewareflow (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=falseKeycloak 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-projectsOutput:
═══════════════════════════════════════════════════════════
📋 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-projectsManual (via Keycloak Admin Console)
- Login to Keycloak Admin → Select your realm
- Clients → Create Client
- Set:
- Client ID:
pms-service - Client authentication: ON
- Authentication flow: check only Service accounts roles
- Client ID:
- Save → go to Credentials tab → copy the Client secret
- 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-projectsStep 2: Install the package
npm install service-authStep 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-projectsStep 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
ServiceHttpClientinitialization 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 --verboseCurrent 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_URLis 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_IDandSERVICE_CLIENT_SECRETare 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
allowedClientsconfigured - 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
falseonly after all callers are migrated
