Skip to content

Commit c5c2ab0

Browse files
authored
Merge branch 'main' into feat/rs256-jwt-support
2 parents 3279720 + 638e69c commit c5c2ab0

5 files changed

Lines changed: 241 additions & 22 deletions

File tree

src/app.module.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
33
import { ConfigModule } from '@nestjs/config';
44
import { TypeOrmModule } from '@nestjs/typeorm';
55
import { ScheduleModule } from '@nestjs/schedule';
6+
import { envValidationSchema } from './config/env.validation';
67

78
import { AppController } from './app.controller';
89
import { SearchModule } from './search/search.module';
@@ -40,7 +41,11 @@ const featureFlags = loadFeatureFlags();
4041

4142
@Module({
4243
imports: [
43-
ConfigModule.forRoot({ isGlobal: true }),
44+
ConfigModule.forRoot({
45+
isGlobal: true,
46+
validationSchema: envValidationSchema,
47+
validationOptions: { abortEarly: false },
48+
}),
4449
TypeOrmModule.forRoot(getDatabaseConfig()),
4550
ScheduleModule.forRoot(),
4651
SessionModule,

src/auth/auth.service.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export class AuthService {
3939
try {
4040
// Verify token signature and expiration
4141
decoded = this.jwtService.verify(refreshToken, {
42-
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
42+
secret: process.env.JWT_REFRESH_SECRET,
4343
});
4444
} catch (_e) {
4545
throw new UnauthorizedException('Invalid or expired refresh token');
@@ -118,20 +118,14 @@ export class AuthService {
118118
const refreshJti = uuidv4();
119119

120120
const [accessToken, refreshToken] = await Promise.all([
121-
isRS256Configured()
122-
? this.jwtService.signAsync(payload, {
123-
privateKey: this.getPrivateKey(),
124-
algorithm: 'RS256',
125-
expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any,
126-
})
127-
: this.jwtService.signAsync(payload, {
128-
secret: process.env.JWT_SECRET || 'default-jwt-secret',
129-
expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any,
130-
}),
121+
this.jwtService.signAsync(payload, {
122+
secret: process.env.JWT_SECRET,
123+
expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any,
124+
}),
131125
this.jwtService.signAsync(
132126
{ ...payload, jti: refreshJti },
133127
{
134-
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
128+
secret: process.env.JWT_REFRESH_SECRET,
135129
expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as any,
136130
},
137131
),

src/config/env.validation.spec.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { envValidationSchema } from './env.validation';
2+
3+
const validEnv = {
4+
DATABASE_HOST: 'localhost',
5+
DATABASE_PORT: '5432',
6+
DATABASE_USER: 'teachlink',
7+
DATABASE_PASSWORD: 'password',
8+
DATABASE_NAME: 'teachlink',
9+
REDIS_HOST: 'localhost',
10+
REDIS_PORT: '6379',
11+
JWT_SECRET: 'a-very-secret-jwt-key',
12+
JWT_REFRESH_SECRET: 'a-very-secret-refresh-key',
13+
ENCRYPTION_SECRET: 'a'.repeat(32),
14+
STRIPE_SECRET_KEY: 'sk_test_123',
15+
STRIPE_WEBHOOK_SECRET: 'whsec_123',
16+
SMTP_HOST: 'smtp.example.com',
17+
SMTP_PORT: '587',
18+
SMTP_USER: 'user',
19+
SMTP_PASS: 'pass',
20+
EMAIL_FROM: 'noreply@example.com',
21+
AWS_ACCESS_KEY_ID: 'AKIA123',
22+
AWS_SECRET_ACCESS_KEY: 'secret',
23+
AWS_S3_BUCKET: 'teachlink-bucket',
24+
SENDGRID_API_KEY: 'SG.123',
25+
SENDGRID_SENDER_EMAIL: 'sender@example.com',
26+
SENDGRID_WEBHOOK_TOKEN: 'token',
27+
SESSION_SECRET: 'a-very-secret-session-key',
28+
};
29+
30+
function validate(env: Record<string, string | undefined>) {
31+
return envValidationSchema.validate(env, { abortEarly: false });
32+
}
33+
34+
describe('envValidationSchema', () => {
35+
it('passes validation when all required vars are present and valid', () => {
36+
const { error } = validate(validEnv);
37+
expect(error).toBeUndefined();
38+
});
39+
40+
it('fails startup when JWT_SECRET is missing', () => {
41+
const { JWT_SECRET, ...envWithoutSecret } = validEnv;
42+
const { error } = validate(envWithoutSecret);
43+
44+
expect(error).toBeDefined();
45+
expect(error?.message).toContain('JWT_SECRET');
46+
});
47+
48+
it('lists every missing required var in a single descriptive error', () => {
49+
const { error } = validate({});
50+
51+
expect(error).toBeDefined();
52+
const messages = error?.details.map((d) => d.message).join('\n') ?? '';
53+
expect(messages).toContain('DATABASE_HOST');
54+
expect(messages).toContain('REDIS_HOST');
55+
expect(messages).toContain('JWT_REFRESH_SECRET');
56+
expect(messages).toContain('ENCRYPTION_SECRET');
57+
});
58+
59+
it('fails validation when ENCRYPTION_SECRET is shorter than 32 characters', () => {
60+
const { error } = validate({ ...validEnv, ENCRYPTION_SECRET: 'too-short' });
61+
62+
expect(error).toBeDefined();
63+
expect(error?.message).toContain('ENCRYPTION_SECRET');
64+
});
65+
66+
it('allows JWT_SECRET to be omitted when JWT_SECRETS (rotation list) is set', () => {
67+
const { JWT_SECRET, ...rest } = validEnv;
68+
const { error } = validate({ ...rest, JWT_SECRETS: 'v1:secret-one,v2:secret-two' });
69+
70+
expect(error).toBeUndefined();
71+
});
72+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
jest.mock('ioredis', () => {
2+
return jest.fn();
3+
});
4+
5+
import Redis from 'ioredis';
6+
import { DistributedLockService } from './distributed-lock.service';
7+
8+
describe('DistributedLockService', () => {
9+
let store: Map<string, string>;
10+
let setSpy: jest.Mock;
11+
let evalSpy: jest.Mock;
12+
13+
beforeEach(() => {
14+
store = new Map<string, string>();
15+
16+
setSpy = jest.fn(
17+
async (key: string, value: string, mode: string, ttl: number, flag: string) => {
18+
if (flag === 'NX' && store.has(key)) {
19+
return null;
20+
}
21+
store.set(key, value);
22+
return 'OK';
23+
},
24+
);
25+
26+
evalSpy = jest.fn(async (_script: string, _numKeys: number, key: string, token: string) => {
27+
if (store.get(key) === token) {
28+
store.delete(key);
29+
return 1;
30+
}
31+
return 0;
32+
});
33+
34+
(Redis as unknown as jest.Mock).mockImplementation(() => ({
35+
on: jest.fn(),
36+
set: setSpy,
37+
del: jest.fn(async (key: string) => {
38+
store.delete(key);
39+
return 1;
40+
}),
41+
eval: evalSpy,
42+
}));
43+
});
44+
45+
it('acquires the lock using a single SET key value NX PX command', async () => {
46+
const service = new DistributedLockService();
47+
48+
const token = await service.acquireLock('lock:test', 5000);
49+
50+
expect(token).not.toBeNull();
51+
expect(setSpy).toHaveBeenCalledTimes(1);
52+
expect(setSpy).toHaveBeenCalledWith('lock:test', token, 'PX', 5000, 'NX');
53+
});
54+
55+
it('returns null when the lock is already held', async () => {
56+
const service = new DistributedLockService();
57+
58+
const first = await service.acquireLock('lock:test', 5000);
59+
const second = await service.acquireLock('lock:test', 5000);
60+
61+
expect(first).not.toBeNull();
62+
expect(second).toBeNull();
63+
});
64+
65+
it('allows exactly one caller to acquire the lock under 100 concurrent attempts', async () => {
66+
const service = new DistributedLockService();
67+
68+
const results = await Promise.all(
69+
Array.from({ length: 100 }, () => service.acquireLock('lock:contended', 5000)),
70+
);
71+
72+
const winners = results.filter((token) => token !== null);
73+
expect(winners).toHaveLength(1);
74+
});
75+
76+
it('releaseLock only removes the lock if the token matches', async () => {
77+
const service = new DistributedLockService();
78+
const token = await service.acquireLock('lock:test', 5000);
79+
80+
await service.releaseLock('lock:test', 'wrong-token');
81+
expect(await service.acquireLock('lock:test', 5000)).toBeNull();
82+
83+
await service.releaseLock('lock:test', token as string);
84+
expect(await service.acquireLock('lock:test', 5000)).not.toBeNull();
85+
});
86+
87+
it('withLock releases the lock even if fn throws', async () => {
88+
const service = new DistributedLockService();
89+
90+
await expect(
91+
service.withLock('lock:test', 5000, async () => {
92+
throw new Error('boom');
93+
}),
94+
).rejects.toThrow('boom');
95+
96+
// Lock must be free again — a fresh acquire should succeed immediately.
97+
const token = await service.acquireLock('lock:test', 5000);
98+
expect(token).not.toBeNull();
99+
});
100+
101+
it('withLock returns the value produced by fn and releases the lock', async () => {
102+
const service = new DistributedLockService();
103+
104+
const result = await service.withLock('lock:test', 5000, async () => 'done');
105+
106+
expect(result).toBe('done');
107+
expect(await service.acquireLock('lock:test', 5000)).not.toBeNull();
108+
});
109+
110+
it('withLock throws if the lock cannot be acquired', async () => {
111+
const service = new DistributedLockService();
112+
await service.acquireLock('lock:test', 5000);
113+
114+
await expect(service.withLock('lock:test', 5000, async () => 'unreachable')).rejects.toThrow(
115+
'Could not acquire lock',
116+
);
117+
});
118+
});
Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { Injectable } from '@nestjs/common';
22
import Redis from 'ioredis';
3+
import { randomUUID } from 'crypto';
4+
5+
const RELEASE_SCRIPT = `
6+
if redis.call('GET', KEYS[1]) == ARGV[1] then
7+
return redis.call('DEL', KEYS[1])
8+
end
9+
return 0
10+
`;
311

412
/**
513
* Provides distributed Lock operations.
@@ -15,21 +23,43 @@ export class DistributedLockService {
1523
}
1624

1725
/**
18-
* Executes acquire Lock.
26+
* Atomically acquires a lock via a single SET key value NX PX command,
27+
* so two concurrent callers can never both believe they hold it.
1928
* @param key The key.
20-
* @param ttl The ttl.
21-
* @returns Whether the operation succeeded.
29+
* @param ttl The ttl in milliseconds.
30+
* @returns A unique token if acquired, or null if the lock is already held.
2231
*/
23-
async acquireLock(key: string, ttl = 5000): Promise<boolean> {
24-
const result = await this.redis.set(key, 'locked', 'PX', ttl, 'NX');
25-
return result === 'OK';
32+
async acquireLock(key: string, ttl = 5000): Promise<string | null> {
33+
const token = randomUUID();
34+
const result = await this.redis.set(key, token, 'PX', ttl, 'NX');
35+
return result === 'OK' ? token : null;
2636
}
2737

2838
/**
29-
* Executes release Lock.
39+
* Releases the lock, but only if it is still held by this token. This
40+
* prevents releasing a lock that has since expired and been acquired by
41+
* another caller.
3042
* @param key The key.
43+
* @param token The token returned by acquireLock.
44+
*/
45+
async releaseLock(key: string, token: string): Promise<void> {
46+
await this.redis.eval(RELEASE_SCRIPT, 1, key, token);
47+
}
48+
49+
/**
50+
* Acquires the lock, runs fn, and always releases the lock afterwards
51+
* (even if fn throws). Throws if the lock cannot be acquired.
3152
*/
32-
async releaseLock(key: string): Promise<void> {
33-
await this.redis.del(key);
53+
async withLock<T>(key: string, ttl: number, fn: () => Promise<T>): Promise<T> {
54+
const token = await this.acquireLock(key, ttl);
55+
if (!token) {
56+
throw new Error(`Could not acquire lock: ${key}`);
57+
}
58+
59+
try {
60+
return await fn();
61+
} finally {
62+
await this.releaseLock(key, token);
63+
}
3464
}
3565
}

0 commit comments

Comments
 (0)