Initializing Services in Node.js Application
An in-depth guide on managing service initialization in Node.js applications, illustrated with a refined JWT Service example.
Join the DZone community and get the full member experience.
Join For FreeWhile working on a user model, I found myself navigating through best practices and diverse strategies for managing a token service, transitioning from straightforward functions to a fully-fledged, independent service equipped with handy methods. I delved into the nuances of securely storing and accessing secret tokens, discerning between what should remain private and what could be public. Additionally, I explored optimal scenarios for deploying the service or function and pondered the necessity of its existence. This article chronicles my journey, illustrating the evolution from basic implementations to a comprehensive, scalable solution through a variety of examples.
Services
In a Node.js application, services are modular, reusable components responsible for handling specific business logic or functionality, such as user authentication, data access, or third-party API integration. These services abstract away complex operations behind simple interfaces, allowing different parts of the application to interact with these functionalities without knowing the underlying details. By organizing code into services, developers achieve separation of concerns, making the application more scalable, maintainable, and easier to test. Services play a crucial role in structuring the application’s architecture, facilitating a clean separation between the application’s core logic and its interactions with databases, external services, and other application layers. I decided to show an example with JWT Service. Let’s jump to the code.
First Implementation
In our examples, we are going to use jsonwebtoken
as a popular library in the Node.js ecosystem. It will allow us to encode, decode, and verify JWTs easily. This library excels in situations requiring the safe and quick sharing of data between web application users, especially for login and access control.
To create a token:
jsonwebtoken.sign(payload, JWT_SECRET)
and verify:
jsonwebtoken.verify(token, JWT_SECRET, (error, decoded) => {
if (error) {
throw error
}
return decoded;
});
For the creation and verifying tokens we have to have JWT_SECRET
which lying in env.
process.env.JWT_SECRET
That means we have to read it to be able to proceed to methods.
if (!JWT_SECRET) {
throw new Error('JWT secret not found in environment variables!');
}
So, let’s sum it up to the one object with methods:
require('dotenv').config();
import jsonwebtoken from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!;
export const jwt = {
verify: <Result>(token: string): Promise<Result> => {
if (!JWT_SECRET) {
throw new Error('JWT secret not found in environment variables!');
}
return new Promise((resolve, reject) => {
jsonwebtoken.verify(token, JWT_SECRET, (error, decoded) => {
if (error) {
reject(error);
} else {
resolve(decoded as Result);
}
});
});
},
sign: (payload: string | object | Buffer): Promise<string> => {
if (!JWT_SECRET) {
throw new Error('JWT secret not found in environment variables!');
}
return new Promise((resolve, reject) => {
try {
resolve(jsonwebtoken.sign(payload, JWT_SECRET));
} catch (error) {
reject(error);
}
});
},
};
jwt.ts file jwt
Object With Methods
This object demonstrates setting up JWT authentication functionality in a Node.js application. To read env variables helps: require(‘dotenv’).config();
and with access to process
, we are able to get JWT_SECRET
value. Let’s reduce repentance of checking the secret.
checkEnv: () => {
if (!JWT_SECRET) {
throw new Error('JWT_SECRET not found in environment variables!');
}
},
Incorporating a dedicated function within the object to check the environment variable for the JWT secret can indeed make the design more modular and maintainable. But still some repentance, because we still have to call it in each method: this.checkEnv();
Additionally, I have to consider the usage of this
context because I have arrow functions. My methods have to become function declarations instead of arrow functions for verify
and sign
methods to ensure this.checkEnv
works as intended.
Having this we can create tokens:
const token: string = await jwt.sign({
id: user.id,
})
or verify them:
jwt.verify(token)
At this moment we can think, is not better to create a service that is going to handle all of this stuff?
Token Service
By using the service we can improve scalability. I still checking the existing secret within the TokenService
for dynamic reloading of environment variables (just as an example), I streamline it by creating a private method dedicated to this check. This reduces repetition and centralizes the logic for handling missing configurations:
require('dotenv').config();
import jsonwebtoken from 'jsonwebtoken';
export class TokenService {
private static jwt_secret = process.env.JWT_SECRET!;
private static checkSecret() {
if (!TokenService.jwt_secret) {
throw new Error('JWT token not found in environment variables!');
}
}
public static verify = <Result>(token: string): Promise<Result> => {
TokenService.checkSecret();
return new Promise((resolve, reject) => {
jsonwebtoken.verify(token, TokenService.jwt_secret, (error, decoded) => {
if (error) {
reject(error);
} else {
resolve(decoded as Result);
}
});
});
};
public static sign = (payload: string | object | Buffer): Promise<string> => {
TokenService.checkSecret();
return new Promise((resolve, reject) => {
try {
resolve(jsonwebtoken.sign(payload, TokenService.jwt_secret));
} catch (error) {
reject(error);
}
});
};
}
TokenService.ts File
But I have to consider moving the check for the presence of necessary configuration outside of the methods and into the initialization or loading phase of my application, right? This ensures that my application configuration is valid before it starts up, avoiding runtime errors due to missing configuration. And in this moment the word proxy
comes to my mind. Who knows why, but I decided to check it out:
Service With Proxy
First, I need to refactor my TokenService
to remove the repetitive checks from each method, assuming that the secret is always present:
require('dotenv').config();
import jsonwebtoken from 'jsonwebtoken';
export class TokenService {
private static jwt_secret = process.env.JWT_SECRET!;
public static verify<TokenPayload>(token: string): Promise<TokenPayload> {
return new Promise((resolve, reject) => {
jsonwebtoken.verify(token, TokenService.jwt_secret, (error, decoded) => {
if (error) {
reject(error);
} else {
resolve(decoded as TokenPayload);
}
});
});
}
public static sign(payload: string | object | Buffer): Promise<string> {
return new Promise((resolve, reject) => {
try {
resolve(jsonwebtoken.sign(payload, TokenService.jwt_secret));
} catch (error) {
reject(error);
}
});
}
}
Token Service Without Checking Function the Secret
Then I created a proxy handler that checks the JWT secret before forwarding calls to the actual service methods:
const tokenServiceHandler = {
get(target, propKey, receiver) {
const originalMethod = target[propKey];
if (typeof originalMethod === 'function') {
return function(...args) {
if (!TokenService.jwt_secret) {
throw new Error('Secret not found in environment variables!');
}
return originalMethod.apply(this, args);
};
}
return originalMethod;
}
};
Token Service Handler
Looks fancy. Finally, for the usage of the proxied token service, I have to create an instance of the Proxy class:
const proxiedTokenService = new Proxy(TokenService, tokenServiceHandler);
Now, instead of calling TokenService.verify
or TokenService.sign
directly, I can use proxiedTokenService
for these operations. The proxy ensures that JWT secret check is performed automatically before any method logic is executed:
try {
const token = proxiedTokenService.sign({ id: 123 });
console.log(token);
} catch (error) {
console.error(error.message);
}
try {
const payload = proxiedTokenService.verify('<token>');
console.log(payload);
} catch (error) {
console.error(error.message);
}
This approach abstracts away the repetitive pre-execution checks into the proxy mechanism, keeping this method's implementations clean and focused on their core logic. The proxy handler acts as a middleware layer for my static methods, applying the necessary preconditions transparently.
Constructor
What about constructor usage? There’s a significant distinction between initializing and checking environment variables in each method call; the former approach doesn’t account for changes to environment variables after initial setup:
export class TokenService {
private jwt_secret: string;
constructor() {
if (!process.env.JWT_SECRET) {
throw new Error('JWT secret not found in environment variables!');
}
this.jwt_secret = process.env.JWT_SECRET;
}
public verify(token: string) {
// Implementation...
}
public sign(payload) {
// Implementation...
}
}
const tokenService = new TokenService();
Constructor Approach
The way the service is utilized will stay consistent; the only change lies in the timing of the service’s initialization.
Service Initialization
We’ve reached the stage of initialization where we can perform necessary checks before using the service. This is a beneficial practice with extensive scalability options.
require('dotenv').config();
import jsonwebtoken from 'jsonwebtoken';
export class TokenService {
private static jwt_secret: string = process.env.JWT_SECRET!;
static initialize = () => {
if (!this.jwt_secret) {
throw new Error('JWT secret not found in environment variables!');
}
this.jwt_secret = process.env.JWT_SECRET!;
};
public static verify = <Result>(token: string): Promise<Result> =>
new Promise((resolve, reject) => {
jsonwebtoken.verify(token, TokenService.jwt_secret, (error, decoded) => {
if (error) {
reject(error);
} else {
resolve(decoded as Result);
}
});
});
public static sign = (payload: string | object | Buffer): Promise<string> =>
new Promise((resolve, reject) => {
try {
resolve(jsonwebtoken.sign(payload, TokenService.jwt_secret));
} catch (error) {
reject(error);
}
});
}
Token Service With Initialization
Initialization acts as a crucial dependency, without which the service cannot function. To use this approach effectively, I need to call TokenService.initialize()
early in my application startup sequence, before any other parts of my application attempt to use the TokenService
. This ensures that my service is properly configured and ready to use.
import { TokenService } from 'src/services/TokenService';
TokenService.initialize();
This approach assumes that my environment variables and any other required setup do not change while my application is running. But what if my application needs to support dynamic reconfiguration, I might consider additional mechanisms to refresh or update the service configuration without restarting the application, right?
Dynamic Reconfiguration
Supporting dynamic reconfiguration in the application, especially for critical components like TokenService
that rely on configurations like JWT_SECRET
, requires a strategy that allows the service to update its configurations at runtime without a restart.
For that, we need something like configuration management which allows us to refresh configurations dynamically from a centralized place. Dynamic configuration refresh mechanism — this could be a method in my service that can be called to reload its configuration without restarting the application:
export class TokenService {
private static jwt_secret = process.env.JWT_SECRET!;
public static refreshConfig = () => {
this.jwt_secret = process.env.JWT_SECRET!;
if (!this.jwt_secret) {
throw new Error('JWT secret not found in environment variables!');
}
};
// our verify and sign methods will be the same
}
Token Service With Refreshing Config
I need to implement a way to monitor my configuration sources for changes. This could be as simple as watching a file for changes or as complex as subscribing to events from a configuration service. This is just an example:
import fs from 'fs';
fs.watch('config.json', (eventType, filename) => {
if (filename) {
console.log(`Configuration file changed, reloading configurations.`);
TokenService.refreshConfig();
}
});
If active monitoring is not feasible or reliable, we can consider scheduling periodic checks to refresh configurations. This approach is less responsive but can be sufficient depending on how frequently my configurations change.
Cron Job
Another example can be valuable with using a cron job within a Node.js application to periodically check and refresh configuration for services, such as a TokenService
, is a practical approach for ensuring my application adapts to configuration changes without needing a restart. This can be especially useful for environments where configurations might change dynamically (e.g., in cloud environments or when using external configuration management services).
For that, we can use node-cron
package to achieve the periodical check:
import cron from 'node-cron''
import { TokenService } from 'src/services/TokenService'
cron.schedule('0 * * * *', () => {
TokenService.refreshConfiguration();
}, {
scheduled: true,
timezone: "America/New_York"
});
console.log('Cron job scheduled to refresh TokenService configuration every hour.');
Cron Job periodically checks the latest configurations.
In this setup, cron.schedule
is used to define a task that calls TokenService.refreshConfiguration
every hour ('0 * * * *'
is a cron expression that means "at minute 0 of every hour").
Conclusion
Proper initialization ensures the service is configured with essential environment variables, like the JWT secret, safeguarding against runtime errors and security vulnerabilities. By employing best practices for dynamic configuration, such as periodic checks or on-demand reloading, applications can adapt to changes without downtime. Effectively integrating and managing the TokenService
enhances the application's security, maintainability, and flexibility in handling user authentication.
I trust this exploration has provided you with meaningful insights and enriched your understanding of service configurations.
Published at DZone with permission of Anton Kalik. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments