Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
66 changes: 66 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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]),
],
Expand Down
6 changes: 6 additions & 0 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -135,4 +136,9 @@ export class AuthService {
refreshToken,
};
}

private getPrivateKey(): string | Buffer {
const key = process.env.JWT_PRIVATE_KEY || '';
return loadPEMKey(key) || key;
}
}
69 changes: 69 additions & 0 deletions src/auth/config/jwt-config.factory.ts
Original file line number Diff line number Diff line change
@@ -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<string>('JWT_PRIVATE_KEY');
const publicKeyRaw = configService.get<string>('JWT_PUBLIC_KEY');
const expiresIn = (configService.get<string>('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<string>('JWT_SECRET') || 'default-jwt-secret',
signOptions: {
algorithm: 'HS256',
expiresIn,
},
};
}
19 changes: 18 additions & 1 deletion src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,34 @@ 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<User>,
) {
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);
}
},
});
}

Expand Down
19 changes: 18 additions & 1 deletion src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down