diff --git a/.env.example b/.env.example index 7d334427..62369897 100644 --- a/.env.example +++ b/.env.example @@ -88,9 +88,27 @@ DB_WAIT_FOR_QUERIES=true # Wait for active queries to complete # ───────────────────────────────────────────────────────────────────────────── # REQUIRED: JWT secrets and encryption -# JWT signing secret [REQUIRED, min 32 chars recommended] +# ── JWT Signing ───────────────────────────────────────────────────────────── +# Choose ONE of the following signing methods: +# +# Option A: HS256 (symmetric) — set JWT_SECRET +# JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-min-10-chars +# +# Option B: RS256 (asymmetric) — set JWT_PRIVATE_KEY + JWT_PUBLIC_KEY +# JWT_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY----- +# JWT_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY----- +# +# PEM values can be provided inline or as file paths to .pem files. + +# HS256 signing secret [REQUIRED if JWT_PRIVATE_KEY is not set, min 32 chars recommended] JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-min-10-chars +# RS256 PEM private key (inline content or file path). Overrides JWT_SECRET when set. +JWT_PRIVATE_KEY= + +# RS256 PEM public key (inline content or file path). Required when JWT_PRIVATE_KEY is set. +JWT_PUBLIC_KEY= + # Multiple JWT secrets for key rotation (comma-separated, first is current) JWT_SECRETS= diff --git a/docs/authentication.md b/docs/authentication.md index 54d22c7c..f495eed7 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -2,6 +2,72 @@ TeachLink API uses **JWT (JSON Web Tokens)** for authentication, validated via Passport strategies. +## Signing Algorithms + +The API supports two JWT signing algorithms: + +| Algorithm | Type | Configuration | +|-----------|------|---------------| +| **HS256** (default) | Symmetric (HMAC + SHA-256) | `JWT_SECRET` — single shared secret | +| **RS256** | Asymmetric (RSA + SHA-256) | `JWT_PRIVATE_KEY` + `JWT_PUBLIC_KEY` — PEM key pair | + +### HS256 (Symmetric) + +HS256 uses a single shared secret to both sign and verify tokens. Simple to set up but any service that verifies tokens must also possess the signing secret. + +```env +JWT_SECRET=your-super-secret-key-min-32-chars +``` + +### RS256 (Asymmetric) + +RS256 uses a private key to sign tokens and a separate public key to verify them. This allows verification services to use a public key without access to the private signing key. + +```env +JWT_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\n... +JWT_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\n... +``` + +PEM values can be provided inline (as shown above) or as file paths pointing to `.pem` files. + +### Key Generation + +Generate an RS256 key pair for development: + +```bash +# Generate a 2048-bit RSA private key +openssl genrsa -out private.pem 2048 + +# Extract the corresponding public key +openssl rsa -in private.pem -pubout -out public.pem +``` + +Then reference the files in your `.env`: + +```env +JWT_PRIVATE_KEY=./private.pem +JWT_PUBLIC_KEY=./public.pem +``` + +Or use the raw PEM content directly (for `.env` files, replace newlines with `\n`): + +```env +JWT_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA... +JWT_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0B... +``` + +> **Production recommendation:** Use a key management service (AWS KMS, HashiCorp Vault) to store private keys. Set `SECRET_PROVIDER=aws` or `SECRET_PROVIDER=vault` to load secrets from external providers. + +### Key Rotation + +The `JwtStrategy` uses `secretOrKeyProvider` (a callback invoked on every request) rather than a static `secretOrKey`. This design allows key rotation without restarting services: + +1. Deploy the new public key to all verification services. +2. Update the signing service to use the new private key. +3. Tokens signed with the old key remain valid until expiration. + +For HS256 key rotation, use the `JWT_SECRETS` (comma-separated) and `JWT_SECRET_CURRENT_VERSION` environment variables (legacy support). + ## Authentication Flow ### 1. Obtain a Token diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 17bf6c76..15dfe3d4 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '../users/entities/user.entity'; import { JwtStrategy } from './jwt.strategy'; @@ -13,16 +14,19 @@ import { RolesGuard } from './guards/roles.guard'; import { PermissionsGuard } from './guards/permissions.guard'; import { SocialAuthService } from './services/social-auth.service'; import { SocialAuthController } from './controllers/social-auth.controller'; +import { createJwtOptions } from './config/jwt-config.factory'; /** * Registers the authentication module with Passport and JWT support. + * Supports both HS256 (symmetric) and RS256 (asymmetric) signing. */ @Module({ imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.register({ - secret: process.env.JWT_SECRET || 'default-jwt-secret', - signOptions: { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any }, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => createJwtOptions(configService), }), TypeOrmModule.forFeature([User]), ], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index f775e1f9..5835a64f 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -6,6 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as bcrypt from 'bcrypt'; import { User, UserStatus } from '../users/entities/user.entity'; import { TokenBlacklistService } from './services/token-blacklist.service'; +import { isRS256Configured, loadPEMKey } from './config/jwt-config.factory'; @Injectable() export class AuthService { @@ -135,4 +136,9 @@ export class AuthService { refreshToken, }; } + + private getPrivateKey(): string | Buffer { + const key = process.env.JWT_PRIVATE_KEY || ''; + return loadPEMKey(key) || key; + } } diff --git a/src/auth/config/jwt-config.factory.ts b/src/auth/config/jwt-config.factory.ts new file mode 100644 index 00000000..c086809c --- /dev/null +++ b/src/auth/config/jwt-config.factory.ts @@ -0,0 +1,69 @@ +import { ConfigService } from '@nestjs/config'; +import { JwtModuleOptions } from '@nestjs/jwt'; +import * as fs from 'fs'; + +export function loadPEMKey(value: string | undefined): string | undefined { + if (!value) return undefined; + + try { + const stats = fs.statSync(value); + if (stats.isFile()) { + return fs.readFileSync(value, 'utf8'); + } + } catch { + // Not a file path, treat as inline PEM content + } + + return value; +} + +export function isRS256Configured(): boolean { + return !!(process.env.JWT_PRIVATE_KEY || process.env.JWT_PUBLIC_KEY); +} + +export function getSigningKey(): string | Buffer { + const key = process.env.JWT_PRIVATE_KEY || process.env.JWT_SECRET || 'default-jwt-secret'; + if (isRS256Configured()) { + return loadPEMKey(key) || key; + } + return key; +} + +export function getVerificationKey(): string | Buffer { + if (isRS256Configured()) { + const pubKey = process.env.JWT_PUBLIC_KEY || ''; + return loadPEMKey(pubKey) || pubKey; + } + return process.env.JWT_SECRET || 'default-jwt-secret'; +} + +export function createJwtOptions(configService: ConfigService): JwtModuleOptions { + const privateKeyRaw = configService.get('JWT_PRIVATE_KEY'); + const publicKeyRaw = configService.get('JWT_PUBLIC_KEY'); + const expiresIn = (configService.get('JWT_EXPIRES_IN') || '15m') as any; + + if (privateKeyRaw || publicKeyRaw) { + const privateKey = loadPEMKey(privateKeyRaw) || privateKeyRaw; + const publicKey = loadPEMKey(publicKeyRaw) || publicKeyRaw; + + return { + privateKey, + publicKey, + signOptions: { + algorithm: 'RS256', + expiresIn, + }, + verifyOptions: { + algorithms: ['RS256'], + }, + }; + } + + return { + secret: configService.get('JWT_SECRET') || 'default-jwt-secret', + signOptions: { + algorithm: 'HS256', + expiresIn, + }, + }; +} diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 0f25ab40..6342f4f5 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -14,9 +14,13 @@ export interface JwtPayload { /** * Passport JWT strategy for validating Bearer tokens. + * Supports HS256 (symmetric) and RS256 (asymmetric) key verification + * via secretOrKeyProvider for runtime key rotation. */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + private readonly logger = new Logger(JwtStrategy.name); + constructor( @InjectRepository(User) private readonly userRepository: Repository, @@ -24,7 +28,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: process.env.JWT_SECRET || 'default-jwt-secret', + secretOrKeyProvider: (_request, _rawJwtToken, done) => { + try { + if (isRS256Configured()) { + const pubKey = process.env.JWT_PUBLIC_KEY || ''; + const resolved = loadPEMKey(pubKey) || pubKey; + done(null, resolved); + } else { + done(null, process.env.JWT_SECRET || 'default-jwt-secret'); + } + } catch (err) { + this.logger.error('Failed to resolve JWT verification key', err); + done(err, undefined); + } + }, }); } diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 9079fa2f..2a7ade61 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -26,11 +26,28 @@ export const envValidationSchema = Joi.object({ REDIS_PORT: Joi.number().required(), // JWT Configuration + // Either JWT_SECRET (HS256) or JWT_PRIVATE_KEY + JWT_PUBLIC_KEY (RS256) must be configured JWT_SECRETS: Joi.string().optional(), JWT_SECRET_CURRENT_VERSION: Joi.string().optional(), JWT_SECRET: Joi.string() .min(10) - .when('JWT_SECRETS', { is: Joi.exist(), then: Joi.optional(), otherwise: Joi.required() }), + .when('JWT_PRIVATE_KEY', { + is: Joi.exist(), + then: Joi.optional(), + otherwise: Joi.when('JWT_SECRETS', { + is: Joi.exist(), + then: Joi.optional(), + otherwise: Joi.required(), + }), + }), + JWT_PRIVATE_KEY: Joi.string().optional(), + JWT_PUBLIC_KEY: Joi.string() + .optional() + .when('JWT_PRIVATE_KEY', { + is: Joi.exist(), + then: Joi.required(), + otherwise: Joi.optional(), + }), JWT_EXPIRES_IN: Joi.string().default('15m'), JWT_REFRESH_SECRET: Joi.string().min(10).required(), JWT_REFRESH_EXPIRES_IN: Joi.string().default('7d'),