Skip to content

Commit d9ab1c2

Browse files
chore: make cached token configurable and added UT
1 parent cc5afe3 commit d9ab1c2

File tree

3 files changed

+204
-9
lines changed

3 files changed

+204
-9
lines changed

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,10 @@ const client = new ReferralExchangeClient({
168168
### JWT Authenticated Client
169169

170170
If you need to authenticate requests by signing short-lived JWTs locally, use the `ReferralExchangeJwtClient`. It
171-
accepts a PEM-encoded ES256 private key and the API key name to embed as the issuer claim. Each request automatically
172-
receives a freshly signed token (15 second TTL) in the `Authorization` header.
171+
accepts a PEM-encoded ES256 private key and the API key name to embed as the issuer claim. By default each request
172+
receives a freshly signed token (15 second TTL) in the `Authorization` header, but you can opt into caching with a
173+
refresh buffer to reuse tokens safely. When opting in, you must supply the refresh buffer explicitly so the SDK knows
174+
how early to rotate tokens.
173175

174176
```typescript
175177
import { ReferralExchangeJwtClient } from "@opengovsg/refx-ts-sdk";
@@ -178,11 +180,19 @@ const client = new ReferralExchangeJwtClient({
178180
privateKey: process.env.REFX_PRIVATE_KEY!,
179181
apiKeyName: process.env.REFX_API_KEY_NAME!,
180182
environment: "Production", // or pass baseUrl
183+
tokenCache: {
184+
// The refresh buffer (required when enabling caching) is how much time must remain
185+
// before we stop reusing a JWT so requests don't arrive after the token's exp timestamp.
186+
refreshBufferSeconds: 5,
187+
},
181188
});
182189

183190
const offerings = await client.offerings.list();
184191
```
185192

193+
Omit the `tokenCache` block (the default) to sign a new token for every request. When you opt in, you must specify
194+
`refreshBufferSeconds` to control how early the SDK rotates tokens.
195+
186196
## Contributing
187197

188198
While we value open-source contributions to this SDK, this library is generated programmatically.

src/wrapper/ReferralExchangeJwtClient.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ export declare namespace ReferralExchangeJwtClient {
1010
export interface Options extends Omit<ReferralExchangeClient.Options, "fetcher" | "apiKey"> {
1111
privateKey: string;
1212
apiKeyName: string;
13+
tokenCache?: TokenCacheOptions;
14+
}
15+
16+
export interface TokenCacheOptions {
17+
refreshBufferSeconds: number;
1318
}
1419
}
1520

1621
export class ReferralExchangeJwtClient extends ReferralExchangeClient {
1722
constructor(options: ReferralExchangeJwtClient.Options) {
18-
const { privateKey, apiKeyName, ...baseOptions } = options;
19-
const signer = new JwtSigner({ privateKey, issuer: apiKeyName });
23+
const { privateKey, apiKeyName, tokenCache, ...baseOptions } = options;
24+
const signer = new JwtSigner({ privateKey, issuer: apiKeyName, tokenCache });
2025
const fetcher = createJwtFetcher(signer);
2126
super({
2227
...baseOptions,
@@ -28,30 +33,47 @@ export class ReferralExchangeJwtClient extends ReferralExchangeClient {
2833
interface JwtSignerConfig {
2934
privateKey: string;
3035
issuer: string;
36+
tokenCache?: ReferralExchangeJwtClient.TokenCacheOptions;
3137
}
3238

3339
class JwtSigner {
3440
private readonly privateKey: string;
3541
private readonly issuer: string;
42+
private readonly cacheEnabled: boolean;
43+
private readonly refreshBufferSeconds: number;
3644
private cachedToken: { token: string; expiresAtEpochSeconds: number } | undefined;
3745

38-
constructor({ privateKey, issuer }: JwtSignerConfig) {
46+
constructor({ privateKey, issuer, tokenCache }: JwtSignerConfig) {
3947
this.privateKey = privateKey;
4048
this.issuer = issuer;
49+
const cacheEnabled = tokenCache != null;
50+
this.cacheEnabled = cacheEnabled;
51+
this.refreshBufferSeconds = cacheEnabled ? clampRefreshBufferSeconds(tokenCache.refreshBufferSeconds) : 0;
4152
}
4253

4354
public async getToken(): Promise<string> {
55+
if (!this.cacheEnabled) {
56+
return this.signToken().token;
57+
}
58+
4459
const nowSeconds = Math.floor(Date.now() / 1000);
45-
if (this.cachedToken != null && nowSeconds < this.cachedToken.expiresAtEpochSeconds - 1) {
60+
if (
61+
this.cachedToken != null &&
62+
nowSeconds < this.cachedToken.expiresAtEpochSeconds - this.refreshBufferSeconds
63+
) {
4664
return this.cachedToken.token;
4765
}
4866

49-
const { token, expiresAtEpochSeconds } = createSignedJwt({
67+
const signedToken = this.signToken();
68+
this.cachedToken = signedToken;
69+
return signedToken.token;
70+
}
71+
72+
private signToken(): { token: string; expiresAtEpochSeconds: number } {
73+
return createSignedJwt({
5074
privateKey: this.privateKey,
5175
issuer: this.issuer,
5276
});
53-
this.cachedToken = { token, expiresAtEpochSeconds };
54-
return token;
5577
}
5678
}
5779

@@ -95,3 +117,9 @@ function createSignedJwt({ privateKey, issuer }: CreateSignedJwtArgs): {
95117
expiresAtEpochSeconds,
96118
};
97119
}
120+
121+
function clampRefreshBufferSeconds(refreshBufferSeconds: number): number {
122+
const lowerBound = 0;
123+
const upperBound = Math.max(JWT_TTL_SECONDS - 1, 0);
124+
return Math.min(Math.max(refreshBufferSeconds, lowerBound), upperBound);
125+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
jest.mock("../../../src/core/fetcher/Fetcher", () => ({
2+
fetcherImpl: jest.fn(),
3+
}));
4+
5+
jest.mock("jsonwebtoken", () => ({
6+
__esModule: true,
7+
default: {
8+
sign: jest.fn(),
9+
},
10+
}));
11+
12+
import jwt from "jsonwebtoken";
13+
14+
import { ReferralExchangeJwtClient } from "../../../src/wrapper/ReferralExchangeJwtClient";
15+
16+
const fetcherImplMock = jest.requireMock("../../../src/core/fetcher/Fetcher").fetcherImpl as jest.Mock;
17+
const signMock = jwt.sign as jest.Mock<string, any[]>;
18+
19+
describe("ReferralExchangeJwtClient", () => {
20+
const requestArgs = { url: "https://refx.test/resource", method: "GET" };
21+
22+
beforeEach(() => {
23+
fetcherImplMock.mockReset();
24+
fetcherImplMock.mockResolvedValue({ ok: true });
25+
signMock.mockReset();
26+
});
27+
28+
it("signs a new token for every request when caching is disabled", async () => {
29+
signMock.mockReturnValueOnce("token-1").mockReturnValueOnce("token-2");
30+
31+
const client = new ReferralExchangeJwtClient({
32+
privateKey: "fake-private-key",
33+
apiKeyName: "issuer",
34+
});
35+
36+
const fetcher = ((client as any)._options.fetcher) as (args: typeof requestArgs) => Promise<unknown>;
37+
38+
await fetcher(requestArgs);
39+
await fetcher(requestArgs);
40+
41+
expect(signMock).toHaveBeenCalledTimes(2);
42+
expect(fetcherImplMock).toHaveBeenNthCalledWith(
43+
1,
44+
expect.objectContaining({
45+
headers: expect.objectContaining({
46+
Authorization: "Bearer token-1",
47+
}),
48+
}),
49+
);
50+
expect(fetcherImplMock).toHaveBeenNthCalledWith(
51+
2,
52+
expect.objectContaining({
53+
headers: expect.objectContaining({
54+
Authorization: "Bearer token-2",
55+
}),
56+
}),
57+
);
58+
});
59+
60+
it("reuses cached tokens until the refresh buffer elapses", async () => {
61+
signMock.mockReturnValueOnce("token-1").mockReturnValueOnce("token-2");
62+
63+
const client = new ReferralExchangeJwtClient({
64+
privateKey: "fake-private-key",
65+
apiKeyName: "issuer",
66+
tokenCache: { refreshBufferSeconds: 5 },
67+
});
68+
const fetcher = ((client as any)._options.fetcher) as (args: typeof requestArgs) => Promise<unknown>;
69+
70+
let nowMs = 0;
71+
const dateSpy = jest.spyOn(Date, "now").mockImplementation(() => nowMs);
72+
73+
nowMs = 0;
74+
await fetcher(requestArgs);
75+
nowMs = 2_000; // 2 seconds, still outside the 5 second refresh buffer window.
76+
await fetcher(requestArgs);
77+
nowMs = 11_000; // 11 seconds -> only 4 seconds remain before expiry, so refresh.
78+
await fetcher(requestArgs);
79+
80+
expect(signMock).toHaveBeenCalledTimes(2);
81+
expect(fetcherImplMock).toHaveBeenNthCalledWith(
82+
1,
83+
expect.objectContaining({
84+
headers: expect.objectContaining({
85+
Authorization: "Bearer token-1",
86+
}),
87+
}),
88+
);
89+
expect(fetcherImplMock).toHaveBeenNthCalledWith(
90+
2,
91+
expect.objectContaining({
92+
headers: expect.objectContaining({
93+
Authorization: "Bearer token-1",
94+
}),
95+
}),
96+
);
97+
expect(fetcherImplMock).toHaveBeenNthCalledWith(
98+
3,
99+
expect.objectContaining({
100+
headers: expect.objectContaining({
101+
Authorization: "Bearer token-2",
102+
}),
103+
}),
104+
);
105+
106+
dateSpy.mockRestore();
107+
});
108+
109+
it("clamps refresh buffer to the JWT lifetime", async () => {
110+
signMock.mockReturnValueOnce("token-1").mockReturnValueOnce("token-2");
111+
112+
const client = new ReferralExchangeJwtClient({
113+
privateKey: "fake-private-key",
114+
apiKeyName: "issuer",
115+
tokenCache: { refreshBufferSeconds: 30 }, // larger than JWT_TTL_SECONDS
116+
});
117+
const fetcher = ((client as any)._options.fetcher) as (args: typeof requestArgs) => Promise<unknown>;
118+
119+
let nowMs = 0;
120+
const dateSpy = jest.spyOn(Date, "now").mockImplementation(() => nowMs);
121+
122+
nowMs = 0;
123+
await fetcher(requestArgs);
124+
nowMs = 500; // 0.5 seconds < (15s - 14s clamp) so the cached token is still valid
125+
await fetcher(requestArgs);
126+
nowMs = 1_500; // 1.5 seconds -> exceeds the clamped threshold, so a new token is signed
127+
await fetcher(requestArgs);
128+
129+
expect(signMock).toHaveBeenCalledTimes(2);
130+
expect(fetcherImplMock).toHaveBeenNthCalledWith(
131+
1,
132+
expect.objectContaining({
133+
headers: expect.objectContaining({
134+
Authorization: "Bearer token-1",
135+
}),
136+
}),
137+
);
138+
expect(fetcherImplMock).toHaveBeenNthCalledWith(
139+
2,
140+
expect.objectContaining({
141+
headers: expect.objectContaining({
142+
Authorization: "Bearer token-1",
143+
}),
144+
}),
145+
);
146+
expect(fetcherImplMock).toHaveBeenNthCalledWith(
147+
3,
148+
expect.objectContaining({
149+
headers: expect.objectContaining({
150+
Authorization: "Bearer token-2",
151+
}),
152+
}),
153+
);
154+
155+
dateSpy.mockRestore();
156+
});
157+
});

0 commit comments

Comments
 (0)