All files / src/modules/jwks jwks.service.ts

82.14% Statements 69/84
66.66% Branches 26/39
100% Functions 6/6
81.48% Lines 66/81

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 16417x 17x 17x 17x                   17x 18x 18x   18x   18x 18x       18x 18x     18x 18x 2x 2x 2x 1x 1x 1x 1x           1x 1x     1x 1x 1x 1x     1x         17x 17x 17x 14x           14x 14x 14x 14x 14x     3x   3x 3x   1x 1x 1x                             1x         1x           1x 1x 1x 1x 1x   2x                 106x         427x     427x         3x 1x 1x     2x 2x 2x 2x               2x     2x       1x 1x 1x        
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createPublicKey, generateKeyPairSync } from 'crypto';
import { exportJWK, JWK } from 'jose';
 
interface KeyPair {
  kid: string;
  privateKeyPem: string;
  publicKeyPem: string;
  alg: string; // e.g., RS256
}
 
@Injectable()
export class JwksService {
  private readonly logger = new Logger(JwksService.name);
  private readonly keys: Map<string, KeyPair> = new Map();
  private currentKid: string | undefined;
  private initialized = false;
 
  constructor(private readonly config: ConfigService) {
    this.initializeKeys();
  }
 
  private initializeKeys(): void {
    try {
      const algorithm = this.config.get<string>('JWT_ALGORITHM') || 'RS256';
 
      // Preferred: JWKS_KEYS (base64 JSON array or {keys:[]})
      const jwksBase64 = this.config.get<string>('JWKS_KEYS');
      if (jwksBase64) {
        try {
          const decoded = Buffer.from(jwksBase64, 'base64').toString('utf8');
          const parsed = JSON.parse(decoded);
          const entries: any[] = Array.isArray(parsed) ? parsed : parsed?.keys || [];
          for (const entry of entries) {
            if (entry?.kid && entry?.privateKeyPem && entry?.publicKeyPem) {
              const kp: KeyPair = {
                kid: String(entry.kid),
                alg: String(entry.alg || algorithm),
                privateKeyPem: String(entry.privateKeyPem).replace(/\\n/g, '\n'),
                publicKeyPem: String(entry.publicKeyPem).replace(/\\n/g, '\n'),
              };
              this.keys.set(kp.kid, kp);
              if (!this.currentKid) this.currentKid = kp.kid;
            }
          }
          this.initialized = this.keys.size > 0;
          if (this.initialized) {
            this.logger.log(`JWKS initialized from JWKS_KEYS with ${this.keys.size} key(s). Current KID: ${this.currentKid}`);
            return;
          }
        } catch (e) {
          this.logger.warn(`Failed to parse JWKS_KEYS: ${e instanceof Error ? e.message : String(e)}`);
        }
      }
 
      // Fallback: use direct PEMs only when both provided
      const privateKeyPem = this.config.get<string>('JWT_PRIVATE_KEY');
      const publicKeyPem = this.config.get<string>('JWT_PUBLIC_KEY');
      if (privateKeyPem && publicKeyPem) {
        const keyPair: KeyPair = {
          kid: 'main',
          alg: algorithm,
          privateKeyPem: privateKeyPem.replace(/\\n/g, '\n'),
          publicKeyPem: publicKeyPem.replace(/\\n/g, '\n'),
        };
        this.keys.set(keyPair.kid, keyPair);
        this.currentKid = keyPair.kid;
        this.initialized = true;
        this.logger.log(`JWKS initialized with fixed keypair. Algorithm: ${algorithm}, KID: ${this.currentKid}`);
        return;
      }
 
      this.logger.warn('JWKS not initialized: supply JWKS_KEYS (preferred) or both JWT_PRIVATE_KEY and JWT_PUBLIC_KEY.');
      // In test mode, generate a deterministic ephemeral keypair to enable auth flows
      const isTest = process.env.NODE_ENV === 'test' || process.env.TEST_MODE === '1';
      if (isTest) {
        // Prefer keys from test global setup if present
        const testPrivate = this.config.get<string>('JWT_PRIVATE_KEY');
        const testPublic = this.config.get<string>('JWT_PUBLIC_KEY');
        Iif (testPrivate && testPublic) {
          const kp: KeyPair = {
            kid: 'test-main',
            alg: algorithm,
            privateKeyPem: String(testPrivate).replace(/\\n/g, '\n'),
            publicKeyPem: String(testPublic).replace(/\\n/g, '\n'),
          };
          this.keys.set(kp.kid, kp);
          this.currentKid = kp.kid;
          this.initialized = true;
          this.logger.log(`JWKS initialized in test mode using provided test keys. KID: ${this.currentKid}`);
          return;
        }
 
        // Otherwise, generate an ephemeral RSA keypair for tests
        const { privateKey, publicKey } = generateKeyPairSync('rsa', {
          modulusLength: 2048,
          publicKeyEncoding: { type: 'spki', format: 'pem' },
          privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
        });
        const kp: KeyPair = {
          kid: 'test-main',
          alg: algorithm,
          privateKeyPem: privateKey,
          publicKeyPem: publicKey,
        };
        this.keys.set(kp.kid, kp);
        this.currentKid = kp.kid;
        this.initialized = true;
        this.logger.log(`JWKS initialized in test mode using generated ephemeral keypair. KID: ${this.currentKid}`);
        return;
      }
      this.initialized = false;
    } catch (error) {
      this.logger.error(`Failed to initialize JWKS service: ${error instanceof Error ? error.message : String(error)}`);
      this.initialized = false;
    }
  }
 
  /** Return whether the JWKS service has been properly initialized with keys */
  get isInitialized(): boolean {
    return this.initialized;
  }
 
  /** Return the active private key for signing */
  get signingKey(): KeyPair | undefined {
    Iif (!this.initialized || !this.currentKid) {
      return undefined;
    }
    return this.keys.get(this.currentKid);
  }
 
  /** Return JWKS-compatible array of public keys */
  async getPublicJwks(): Promise<{ keys: JWK[] }> {
    if (!this.initialized) {
      this.logger.warn('JWKS service not initialized - returning empty key set');
      return { keys: [] };
    }
 
    const jwks: JWK[] = [];
    for (const kp of this.keys.values()) {
      try {
        const keyLike = createPublicKey(kp.publicKeyPem);
        const jwk = await exportJWK(keyLike) as JWK;
        jwk.kid = kp.kid;
        jwk.alg = kp.alg;
        jwk.use = 'sig';
        jwks.push(jwk);
      } catch (error) {
        // In certain environments the Node crypto decoder may not support the provided PEM; degrade gracefully
        this.logger.warn(`Failed to export JWK for key ${kp.kid}: ${error instanceof Error ? error.message : String(error)}`);
      }
    }
    return { keys: jwks };
  }
 
  async getPublicKeyPemByKid(kid: string): Promise<string | undefined> {
    if (!this.initialized) {
      this.logger.warn(`JWKS service not initialized - cannot get key for KID: ${kid}`);
      return undefined;
    }
    return this.keys.get(kid)?.publicKeyPem;
  }
}