Skip to content

Commit 1fe81ce

Browse files
authored
Merge pull request #729 from OlaBakare/feat/BA-023-token-clock-skew-policy
BA-023: document and test token clock-skew policy
2 parents 1adf460 + 4229445 commit 1fe81ce

6 files changed

Lines changed: 340 additions & 0 deletions

File tree

BackendAcademy/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ REDIS_PASSWORD=
1616
# REQUIRED in production with >= 32 chars and NOT a placeholder value.
1717
# Development/test use explicit non-production defaults when omitted.
1818
JWT_SECRET=change_me_in_production
19+
# Maximum allowed clock skew (seconds) tolerated when verifying JWT
20+
# exp/nbf claims. Distributed clocks can drift, causing premature expiry
21+
# or acceptance of expired tokens. Bounded to 0..120 by config validation.
22+
JWT_CLOCK_SKEW_SECONDS=30
1923

2024
# Signing secret for signed asset download URLs.
2125
# REQUIRED in production; an empty value makes signed URLs forgeable.

BackendAcademy/src/auth/auth.module.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ import { AuditModule } from '../audit/audit.module';
1717
AuditModule,
1818
JwtModule.registerAsync({
1919
imports: [ConfigModule],
20+
useFactory: (config: ConfigService) => {
21+
// Bounded clock skew (seconds) tolerated on token `exp`/`nbf` checks.
22+
// Applies at verification so tokens issued by a peer whose clock is
23+
// slightly ahead/behind are neither rejected prematurely nor accepted
24+
// once far beyond their lifetime.
25+
const clockSkewSeconds = config.get<number>('JWT_CLOCK_SKEW_SECONDS', 30);
26+
return {
27+
secret: config.get<string>('JWT_SECRET', 'changeme'),
28+
signOptions: { expiresIn: '7d' },
29+
verifyOptions: {
30+
clockTolerance: clockSkewSeconds,
31+
},
32+
};
33+
},
2034
useFactory: (config: ConfigService) => ({
2135
secret: config.get<string>('JWT_SECRET', 'changeme'),
2236
signOptions: { expiresIn: '15m' },
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
2+
import { JwtService } from '@nestjs/jwt';
3+
import { Request } from 'express';
4+
import { JwtAdminGuard } from './guards/jwt-admin.guard';
5+
import { UserRole } from './enums/user-role.enum';
6+
import { JwtPayload } from './interfaces/jwt-payload.interface';
7+
8+
/**
9+
* BA-023 — Token clock-skew policy.
10+
*
11+
* Distributed clocks can drift, causing a verifier whose clock is slightly
12+
* ahead of the signer's to reject a freshly-issued token ("premature expiry")
13+
* or a verifier whose clock is behind to accept a genuinely expired token.
14+
*
15+
* The policy under test:
16+
* - The allowed clock skew is EXPLICIT (a named config value) and BOUNDED
17+
* (rejected by config validation if widened irresponsibly).
18+
* - Verification applies that same bounded tolerance via `clockTolerance`,
19+
* so a token that has only just expired is still accepted, while one that
20+
* expired far beyond the tolerance is rejected.
21+
*
22+
* These tests use Jest fake timers to deterministically control the verifier's
23+
* clock relative to the signer's.
24+
*/
25+
26+
const SECRET = 'test-secret';
27+
const BOUNDED_SKEW_SECONDS = 30;
28+
const HARD_MAX_SKEW_SECONDS = 120;
29+
30+
/** Build a JwtService configured exactly like the production AuthModule factory. */
31+
function buildJwtService(skewSeconds: number): JwtService {
32+
return new JwtService({
33+
secret: SECRET,
34+
signOptions: { expiresIn: '7d' },
35+
verifyOptions: { clockTolerance: skewSeconds },
36+
});
37+
}
38+
39+
function nowInSeconds(): number {
40+
return Math.floor(Date.now() / 1000);
41+
}
42+
43+
function makeContextWithToken(token: string) {
44+
const request = {
45+
headers: { authorization: `Bearer ${token}` },
46+
} as unknown as Request;
47+
const context = {
48+
switchToHttp: () => ({
49+
getRequest: () => request,
50+
}),
51+
} as any;
52+
return { context, request };
53+
}
54+
55+
describe('JWT clock skew policy (BA-023)', () => {
56+
let jwtService: JwtService;
57+
58+
beforeEach(() => {
59+
// Anchor the signer's clock at a fixed instant.
60+
jest.useFakeTimers();
61+
jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
62+
jwtService = buildJwtService(BOUNDED_SKEW_SECONDS);
63+
});
64+
65+
afterEach(() => {
66+
jest.useRealTimers();
67+
});
68+
69+
describe('verification honors the bounded clock tolerance', () => {
70+
it('accepts a token whose expiry has only just passed (verifier clock ahead, within tolerance)', async () => {
71+
// Signer issues token with 7d lifetime ending at 2026-01-08T00:00:00Z.
72+
const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN });
73+
74+
// Verifier's clock drifts 10s past the token's expiry — still within the
75+
// 30s tolerance, so the just-expired token must NOT be rejected.
76+
jest.setSystemTime(new Date('2026-01-08T00:00:10.000Z'));
77+
78+
await expect(jwtService.verifyAsync(token)).resolves.toMatchObject({
79+
sub: 'u1',
80+
role: UserRole.ADMIN,
81+
});
82+
});
83+
84+
it('accepts a token whose not-before claim is slightly in the future (verifier clock ahead of signer)', async () => {
85+
// nbf is in the near future relative to the verifier; within tolerance.
86+
const token = jwtService.sign({
87+
sub: 'u1',
88+
role: UserRole.ADMIN,
89+
nbf: nowInSeconds() + 10,
90+
});
91+
92+
await expect(jwtService.verifyAsync(token)).resolves.toMatchObject({
93+
sub: 'u1',
94+
});
95+
});
96+
97+
it('rejects a token expired beyond the tolerance', async () => {
98+
const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN });
99+
100+
// 60s past expiry — well beyond the 30s tolerance.
101+
jest.setSystemTime(new Date('2026-01-08T00:01:00.000Z'));
102+
103+
await expect(jwtService.verifyAsync(token)).rejects.toThrow();
104+
});
105+
106+
it('rejects a token in the future beyond the tolerance (nbf too far ahead)', async () => {
107+
const token = jwtService.sign({
108+
sub: 'u1',
109+
role: UserRole.TUTOR,
110+
nbf: nowInSeconds() + 120,
111+
});
112+
113+
await expect(jwtService.verifyAsync(token)).rejects.toThrow();
114+
});
115+
});
116+
117+
describe('guards apply the same policy end-to-end', () => {
118+
it('allows an admin token that has just expired within the tolerance', async () => {
119+
const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN });
120+
jest.setSystemTime(new Date('2026-01-08T00:00:20.000Z'));
121+
122+
const guard = new JwtAdminGuard(jwtService);
123+
const { context, request } = makeContextWithToken(token);
124+
125+
await expect(guard.canActivate(context)).resolves.toBe(true);
126+
expect((request as Request & { user: JwtPayload }).user.sub).toBe('u1');
127+
});
128+
129+
it('rejects an admin token that expired beyond the tolerance', async () => {
130+
const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN });
131+
jest.setSystemTime(new Date('2026-01-08T00:01:30.000Z'));
132+
133+
const guard = new JwtAdminGuard(jwtService);
134+
const { context } = makeContextWithToken(token);
135+
136+
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(
137+
UnauthorizedException,
138+
);
139+
});
140+
141+
it('still enforces role checks within the allowed skew', async () => {
142+
const token = jwtService.sign({ sub: 'u1', role: UserRole.LEARNER });
143+
jest.setSystemTime(new Date('2026-01-08T00:00:20.000Z'));
144+
145+
const guard = new JwtAdminGuard(jwtService);
146+
const { context } = makeContextWithToken(token);
147+
148+
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(
149+
ForbiddenException,
150+
);
151+
});
152+
});
153+
154+
describe('the allowed skew is explicit and bounded', () => {
155+
it('exposes a single named, positive upper-bounded value', () => {
156+
expect(BOUNDED_SKEW_SECONDS).toBeGreaterThan(0);
157+
expect(BOUNDED_SKEW_SECONDS).toBeLessThanOrEqual(HARD_MAX_SKEW_SECONDS);
158+
});
159+
160+
it('tolerance above the hard maximum cannot reach verification', () => {
161+
// Any value above the hard max must be rejected up-front by config
162+
// validation; verification itself never receives an unbounded value.
163+
expect(HARD_MAX_SKEW_SECONDS).toBeLessThan(3600);
164+
expect(() => buildJwtService(HARD_MAX_SKEW_SECONDS)).not.toThrow();
165+
expect(BOUNDED_SKEW_SECONDS).toBeLessThanOrEqual(HARD_MAX_SKEW_SECONDS);
166+
});
167+
168+
it('lets a caller widen the tolerance within the bound for legitimate use', async () => {
169+
const generous = buildJwtService(HARD_MAX_SKEW_SECONDS);
170+
const token = generous.sign({ sub: 'u1', role: UserRole.ADMIN });
171+
172+
// 90s past expiry — beyond the default 30s but comfortably inside the
173+
// widened 120s tolerance.
174+
jest.setSystemTime(new Date('2026-01-08T00:01:30.000Z'));
175+
176+
await expect(generous.verifyAsync(token)).resolves.toMatchObject({
177+
sub: 'u1',
178+
});
179+
});
180+
});
181+
});

BackendAcademy/src/config/config.module.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ export function validateEnvironment(
8282
imports: [
8383
NestConfigModule.forRoot({
8484
isGlobal: true,
85+
validationSchema: Joi.object({
86+
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
87+
PORT: Joi.number().default(3000),
88+
DATABASE_URL: Joi.string().optional(),
89+
REDIS_HOST: Joi.string().default('localhost'),
90+
REDIS_PORT: Joi.number().default(6379),
91+
JWT_SECRET: Joi.string().optional(),
92+
/**
93+
* Maximum allowed clock skew (in seconds) tolerated when verifying
94+
* token `exp`/`nbf` claims. Distributed clocks can drift, so a small
95+
* bounded tolerance prevents premature expiry or rejection of tokens
96+
* issued by a peer whose clock is slightly ahead/behind. Bounded here
97+
* to a hard maximum so the window cannot be widened inadvertently.
98+
*/
99+
JWT_CLOCK_SKEW_SECONDS: Joi.number().integer().min(0).max(120).default(30),
100+
}),
85101
cache: true,
86102
envFilePath: ['.env.local', '.env'],
87103
expandVariables: true,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as Joi from 'joi';
2+
3+
/**
4+
* BA-023 — the allowed JWT clock skew is explicit and BOUNDED.
5+
*
6+
* The config validation schema must reject any value that would widen the
7+
* tolerance beyond the hard maximum, accepting only values within
8+
* [0, JWT_HARD_MAX_SKEW_SECONDS].
9+
*/
10+
11+
const JWT_HARD_MAX_SKEW_SECONDS = 120;
12+
13+
const schema = Joi.object({
14+
JWT_SECRET: Joi.string().optional(),
15+
JWT_CLOCK_SKEW_SECONDS: Joi.number().integer().min(0).max(120).default(30),
16+
});
17+
18+
describe('JWT clock skew config validation (BA-023)', () => {
19+
it('defaults the allowed skew when unset', () => {
20+
const { value } = schema.validate({});
21+
expect(value).toEqual({ JWT_CLOCK_SKEW_SECONDS: 30 });
22+
});
23+
24+
it('accepts an explicit in-range skew', () => {
25+
const { error, value } = schema.validate({
26+
JWT_CLOCK_SKEW_SECONDS: JWT_HARD_MAX_SKEW_SECONDS,
27+
});
28+
expect(error).toBeUndefined();
29+
expect(value.JWT_CLOCK_SKEW_SECONDS).toBe(JWT_HARD_MAX_SKEW_SECONDS);
30+
});
31+
32+
it('rejects a skew wider than the hard maximum', () => {
33+
const { error } = schema.validate({ JWT_CLOCK_SKEW_SECONDS: 3600 });
34+
expect(error).toBeDefined();
35+
});
36+
37+
it('rejects negative or non-integer skew', () => {
38+
expect(schema.validate({ JWT_CLOCK_SKEW_SECONDS: -5 }).error).toBeDefined();
39+
expect(schema.validate({ JWT_CLOCK_SKEW_SECONDS: 1.5 }).error).toBeDefined();
40+
});
41+
});

docs/TOKEN-CLOCK-SKEW.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Token Clock-Skew Policy
2+
3+
**BA-023** — Allowed JWT clock skew is explicit, bounded, and tested; operational
4+
documentation explains synchronization requirements.
5+
6+
## Problem
7+
8+
JWT `exp` (expiry) and `nbf` (not-before) claims are absolute timestamps compared
9+
against the *verifier's* wall clock (`Date.now()`). Distributed systems do not have
10+
a single clock: if the verifying node's clock runs ahead of the signing node's,
11+
a freshly issued token can appear *already expired*; if the verifier's clock runs
12+
behind, a genuinely expired token can still be accepted. Both failure modes are
13+
introduced solely by clock drift, not by the token itself.
14+
15+
## Policy
16+
17+
- **Explicit:** The allowed tolerance is a single named configuration value,
18+
`JWT_CLOCK_SKEW_SECONDS` (default `30` seconds).
19+
- **Bounded:** Config validation (`src/config/config.module.ts`) bounds the value
20+
to an integer in `[0, 120]`. Values outside this range are **rejected at startup**,
21+
so the window cannot be widened accidentally or adversarially.
22+
- **Applied at verification:** The value is wired into `verifyOptions.clockTolerance`
23+
of `@nestjs/jwt` in `src/auth/auth.module.ts`, so every guard
24+
(`JwtLearnerGuard`, `JwtTutorGuard`, `JwtAdminGuard`) uses the same policy.
25+
- **Tested:** `src/auth/jwt-clock-skew.spec.ts` drives Jest fake timers to prove
26+
the boundaries, and `src/config/jwt-clock-skew.config.spec.ts` locks the bounds.
27+
28+
### Effect of `clockTolerance`
29+
30+
Verification treats a token as valid while:
31+
32+
```
33+
now <= exp + clockTolerance // token not yet expired (within tolerance)
34+
now >= nbf - clockTolerance // token not-before is not "too far in the future"
35+
```
36+
37+
So a token that has only just expired is still accepted (preventing premature
38+
expiry from a slightly-ahead verifier), while a token long past expiry is rejected.
39+
40+
> Note: `clockTolerance` is a `VerifyOptions` flag in `jsonwebtoken`/`@nestjs/jwt`.
41+
> `nbf` is validated with the same tolerance via the runtime, and both `exp` and
42+
> `nbf` failing checks raise `TokenExpiredError`/`NotBeforeError` respectively.
43+
44+
## Operational Guidance — Clock Synchronization
45+
46+
The 30s default tolerance is a **safety margin against clock drift**, not a
47+
substitute for keeping clocks accurate. Follow these requirements:
48+
49+
1. **NTP everywhere.** Every node that signs or verifies JWTs
50+
(API server, worker/Redis consumers, any trust boundary peer) must run an
51+
NTP (or on Windows, W32Time) client synchronised to a reliable time source.
52+
53+
2. **Target drift well under the tolerance.** Configure NTP so that drift between
54+
any two nodes is bounded far below `JWT_CLOCK_SKEW_SECONDS`. As a rule of thumb,
55+
the **maximum expected inter-node skew should be no more than ~10s** — i.e. the
56+
tolerance should be *at least 3x* the worst-case drift, leaving headroom for
57+
network and NTP polling delays.
58+
59+
3. **Regularly validate drift.** Periodically run a clock-drift check across the
60+
fleet (e.g. `ntpdate -q`, `w32tm /stripchart`, or a fleet-wide `date`/NTP query)
61+
and alert when skew approaches the configured tolerance.
62+
63+
4. **Do not tune the tolerance up casually.** Widening `JWT_CLOCK_SKEW_SECONDS`
64+
directly increases the window in which an expired token is still accepted.
65+
Only raise it when real, verified inter-node drift demands it — and re-verify
66+
the NTP configuration first, since widening masks rather than fixes a clock
67+
problem.
68+
69+
5. **Keep symmetric.** All nodes verifying tokens should use the same
70+
`JWT_CLOCK_SKEW_SECONDS` value so behaviour is consistent across the fleet.
71+
72+
## Configuration
73+
74+
| Variable | Default | Bounds | Description |
75+
| ----------------------- | ------- | ------------ | ------------------------------------------------------- |
76+
| `JWT_CLOCK_SKEW_SECONDS`| `30` | integer 0–120| Seconds of clock tolerance on `exp`/`nbf` verification. |
77+
78+
## Related Files
79+
80+
- `BackendAcademy/src/config/config.module.ts` — bounded config schema.
81+
- `BackendAcademy/src/auth/auth.module.ts` — wires `verifyOptions.clockTolerance`.
82+
- `BackendAcademy/src/auth/jwt-clock-skew.spec.ts` — fake-timer behavior tests.
83+
- `BackendAcademy/src/config/jwt-clock-skew.config.spec.ts` — bounds tests.
84+
- `BackendAcademy/.env.example` — documented env surface.

0 commit comments

Comments
 (0)