Initializing services in a Node.js application

While working on the user model, I found myself navigating best practices and different strategies for managing a token service, moving from simple functions to a fully fledged, independent service equipped with practical methods. I dove into the nuances of securely storing and accessing secret tokens, distinguishing between what should remain private and what could be public. Additionally, I researched optimal scenarios for implementing a service or function and thought about the necessity of its existence. This article describes my journey, illustrating the evolution from basic implementations to a comprehensive, scalable solution through various 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 complex operations behind simple interfaces, allowing different parts of the application to interact with these functions 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 key role in structuring an application’s architecture, enabling a clean separation between the core application logic and its interaction with databases, external services, and other application layers. I decided to show an example with a JWT service. Let’s get to the code.

First implementation

In our examples we will use jsonwebtoken as a popular library in the Node.js ecosystem. It will allow us to easily encode, decode and verify JWTs. This library excels in situations that require secure and fast data sharing between web application users, especially for login and access control.

To create a token:

jsonwebtoken.sign(payload, JWT_SECRET)

and confirm:

jsonwebtoken.verify(token, JWT_SECRET, (error, decoded) => 
  if (error) 
    throw error
  

  return decoded;
);

To create and verify the tokens we must have JWT_SECRET which lying in the district

This means we need to read it before we can move on to the methods.

if (!JWT_SECRET) 
  throw new Error('JWT secret not found in environment variables!');

So let’s boil it down to a single 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 An object with methods

This object demonstrates setting up JWT authentication functionality in a Node.js application. To read env variables it helps: require(‘dotenv’).config();and with access processwe are able to get JWT_SECRET value. Let’s reduce the repentance of the secret check.

checkEnv: () => 
  if (!JWT_SECRET) 
    throw new Error('JWT_SECRET not found in environment variables!');
  
,

Including a dedicated function inside the object to check the environment variable for the JWT secret can really make the design more modular and maintainable. But still a little repentance, because we still have to call him in every method: this.checkEnv();

Additionally, I have to consider usage this context because I have arrow functions. My methods need to become function declarations instead of arrow functions for verify and sign methods for insurance this.checkEnvworks as intended.

Having this we can create tokens:

const token: string = await jwt.sign(
  id: user.id,
)

or confirm them:

At this point we can think, isn’t it better to create a service that will handle all these things?

Token service

By using the service, we can improve scalability. I’m still checking out the existing secret within TokenService to dynamically reload environment variables (just as an example), I simplify this 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 need to think about moving checking for the presence of the necessary configuration outside of the methods and into the initialization or load phase of my application, right? This ensures that my application configuration is valid before launch, avoiding runtime errors due to missing configuration. And at this moment the word proxy comes to mind. Who knows why, but I decided to check:

Proxy service

First, I need to refactor mine TokenService to remove repeated checks from each method, assuming 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 verification function Secret

I then created a proxy handler that checks the JWT secret before passing 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

It looks classy. Finally, to use the proxy token service, I need 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 the 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 repetitive pre-execution checks into a proxy mechanism, keeping implementations of this method clean and focused on their core logic. The proxy handler acts as a middle layer for my static methods, transparently applying the necessary prerequisites.

Constructor

What about using constructors? There is a significant difference between initializing and checking environment variables in each method call; the previous approach does not 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();

Constructive approach

The way the service is used will remain consistent; the only change lies in the service start time.

Service initialization

We have reached the initialization phase where we can perform the necessary checks before using the service. This is a useful practice with great scalability possibilities.

require('dotenv').config();
import jsonwebtoken from 'jsonwebtoken';

export class TokenService  object 

Initialized token service

Initialization acts as a key dependency, without which the service cannot function. In order to use this approach effectively, I need to call TokenService.initialize() early in the startup sequence of my application, before any other part of my application tries to use it 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 all other necessary settings don’t change while my application is running. But what if my application needs to support dynamic reconfiguration, I could consider additional mechanisms to refresh or update the service configuration without restarting the application, right?

Dynamic reconfiguration

It supports dynamic reconfiguration in the application, especially for critical components like TokenService which rely on configurations like JWT_SECRETrequires a strategy that allows the service to update its configurations at runtime without restarting.

For this we need something like configuration management that allows us to dynamically refresh configurations from a centralized location. 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 configuration refresh

I need to implement a way to track changes in configuration sources. This can 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 may consider scheduling periodic checks to refresh configurations. This approach is less responsive, but may be sufficient depending on how often 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 TokenService, is a convenient approach to ensure my application adapts to configuration changes without requiring a reboot. This can be particularly useful for environments where configurations may change dynamically (eg in cloud environments or when using external configuration management services).

For that we can use node-cron package to achieve periodic verification:

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.');

The Cron Job periodically checks the latest configurations.

In this setting, cron.schedule is used to define the calling task TokenService.refreshConfiguration every hour ('0 * * * *' is a cron expression meaning “at minute 0 of every hour”).

Conclusion

Proper initialization ensures that the service is configured with essential environment variables, such as the JWT secret, protecting against runtime errors and security vulnerabilities. By applying best practices for dynamic configuration, such as periodic checks or on-demand reloads, applications can adapt to changes without disruption. Effective integration and management TokenService improves application security, maintainability, and flexibility in handling user authentication.

I trust that this research has provided you with valuable insights and enriched your understanding of service configurations.

Source link

Leave a Reply

Your email address will not be published. Required fields are marked *