Skip to content

Commit c6ce9d4

Browse files
committed
feat(rds-signer): automatically refresh AWS credentials before getting token
1 parent 7ae617c commit c6ce9d4

3 files changed

Lines changed: 164 additions & 1 deletion

File tree

packages/rds-signer/src/Signer.spec.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,108 @@ describe("rds-signer", () => {
104104
const token = await signer.getAuthToken();
105105
expect(token).toBe("testhost:5432?other=url&parameters=here");
106106
});
107+
108+
describe("credential refresh wrapper", () => {
109+
it("should not force refresh when credentials have no expiration", async () => {
110+
const provider = vi.fn().mockResolvedValue({
111+
accessKeyId: "key",
112+
secretAccessKey: "secret",
113+
});
114+
const signer = new Signer({ ...minimalParams, credentials: provider });
115+
await signer.getAuthToken();
116+
//@ts-ignore
117+
const wrappedProvider = SignatureV4.mock.calls[0][0].credentials;
118+
await wrappedProvider();
119+
expect(provider).toHaveBeenCalledTimes(1);
120+
});
121+
122+
it("should not force refresh when credentials expire in more than 15 minutes", async () => {
123+
const provider = vi.fn().mockResolvedValue({
124+
accessKeyId: "key",
125+
secretAccessKey: "secret",
126+
expiration: new Date(Date.now() + 20 * 60_000),
127+
});
128+
const signer = new Signer({ ...minimalParams, credentials: provider });
129+
await signer.getAuthToken();
130+
//@ts-ignore
131+
const wrappedProvider = SignatureV4.mock.calls[0][0].credentials;
132+
await wrappedProvider();
133+
expect(provider).toHaveBeenCalledTimes(1);
134+
});
135+
136+
it("should force refresh when credentials expire within 15 minutes", async () => {
137+
const refreshedCreds = {
138+
accessKeyId: "refreshed",
139+
secretAccessKey: "secret",
140+
expiration: new Date(Date.now() + 60 * 60_000),
141+
};
142+
const provider = vi
143+
.fn()
144+
.mockResolvedValueOnce({
145+
accessKeyId: "expiring",
146+
secretAccessKey: "secret",
147+
expiration: new Date(Date.now() + 2 * 60_000),
148+
})
149+
.mockResolvedValueOnce(refreshedCreds);
150+
const signer = new Signer({ ...minimalParams, credentials: provider });
151+
await signer.getAuthToken();
152+
//@ts-ignore
153+
const wrappedProvider = SignatureV4.mock.calls[0][0].credentials;
154+
const result = await wrappedProvider();
155+
expect(provider).toHaveBeenCalledTimes(2);
156+
expect(provider).toHaveBeenLastCalledWith(expect.objectContaining({ forceRefresh: true }));
157+
expect(result).toEqual(refreshedCreds);
158+
});
159+
160+
it("should return original credentials if refresh still expires within 15 minutes", async () => {
161+
const originalCreds = {
162+
accessKeyId: "expiring",
163+
secretAccessKey: "secret",
164+
expiration: new Date(Date.now() + 2 * 60_000),
165+
};
166+
const provider = vi
167+
.fn()
168+
.mockResolvedValueOnce(originalCreds)
169+
.mockResolvedValueOnce({
170+
accessKeyId: "still-expiring",
171+
secretAccessKey: "secret",
172+
expiration: new Date(Date.now() + 3 * 60_000),
173+
});
174+
const signer = new Signer({ ...minimalParams, credentials: provider });
175+
await signer.getAuthToken();
176+
//@ts-ignore
177+
const wrappedProvider = SignatureV4.mock.calls[0][0].credentials;
178+
const result = await wrappedProvider();
179+
expect(provider).toHaveBeenCalledTimes(2);
180+
expect(result).toEqual(originalCreds);
181+
});
182+
183+
it("should return original credentials if force refresh throws", async () => {
184+
const originalCreds = {
185+
accessKeyId: "expiring",
186+
secretAccessKey: "secret",
187+
expiration: new Date(Date.now() + 2 * 60_000),
188+
};
189+
const provider = vi.fn().mockResolvedValueOnce(originalCreds).mockRejectedValueOnce(new Error("refresh failed"));
190+
const signer = new Signer({ ...minimalParams, credentials: provider });
191+
await signer.getAuthToken();
192+
//@ts-ignore
193+
const wrappedProvider = SignatureV4.mock.calls[0][0].credentials;
194+
const result = await wrappedProvider();
195+
expect(provider).toHaveBeenCalledTimes(2);
196+
expect(result).toEqual(originalCreds);
197+
});
198+
199+
it("should not wrap static credential objects", async () => {
200+
const staticCreds = {
201+
accessKeyId: "static",
202+
secretAccessKey: "secret",
203+
expiration: new Date(Date.now() + 2 * 60_000),
204+
};
205+
const signer = new Signer({ ...minimalParams, credentials: staticCreds });
206+
await signer.getAuthToken();
207+
//@ts-ignore
208+
expect(SignatureV4.mock.calls[0][0].credentials).toEqual(staticCreds);
209+
});
210+
});
107211
});

packages/rds-signer/src/Signer.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export interface SignerConfig {
5050
profile?: string;
5151
}
5252

53+
const MINUTE_MS = 60_000;
54+
5355
/**
5456
* The signer class that generates an auth token to a database.
5557
*/
@@ -66,7 +68,8 @@ export class Signer {
6668
constructor(configuration: SignerConfig) {
6769
const runtimeConfiguration = __getRuntimeConfig(configuration);
6870

69-
this.credentials = runtimeConfiguration.credentials;
71+
const creds = runtimeConfiguration.credentials;
72+
this.credentials = typeof creds === "function" ? this.createCredentialsWrapper(creds) : creds;
7073
this.hostname = runtimeConfiguration.hostname;
7174
this.port = runtimeConfiguration.port;
7275
this.region = runtimeConfiguration.region;
@@ -104,4 +107,24 @@ export class Signer {
104107
// https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.html
105108
return formatUrl(presigned).replace(`${this.protocol}//`, "");
106109
}
110+
111+
/**
112+
* Wraps a credential provider to force refresh when the resolved credentials
113+
* expire within 15 minutes. A presigned URL cannot outlive the credentials
114+
* used to sign it, so near-expiry credentials would produce a short-lived token.
115+
*/
116+
private createCredentialsWrapper(provider: AwsCredentialIdentityProvider): AwsCredentialIdentityProvider {
117+
return async (identityProperties?: Record<string, any>) => {
118+
const credentials = await provider(identityProperties);
119+
if (credentials.expiration && credentials.expiration.getTime() - Date.now() < 15 * MINUTE_MS) {
120+
try {
121+
const refreshed = await provider({ ...identityProperties, forceRefresh: true });
122+
if (!refreshed.expiration || refreshed.expiration.getTime() - Date.now() >= 15 * MINUTE_MS) {
123+
return refreshed;
124+
}
125+
} catch {}
126+
}
127+
return credentials;
128+
};
129+
}
107130
}

packages/rds-signer/src/rds-signer.integ.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,40 @@ describe("rds-signer integration", () => {
2828
/X-Amz-SignedHeaders=host/,
2929
]);
3030
});
31+
32+
it("force refreshes credentials expiring within 15 minutes", async () => {
33+
let callCount = 0;
34+
35+
const provider = async (options?: Record<string, any>) => {
36+
++callCount;
37+
if (callCount === 1) {
38+
return {
39+
accessKeyId: "EXPIRING_KEY",
40+
secretAccessKey: "secret",
41+
expiration: new Date(Date.now() + 14 * 60_000),
42+
};
43+
}
44+
return {
45+
accessKeyId: "REFRESHED_KEY",
46+
secretAccessKey: "secret",
47+
expiration: new Date(Date.now() + 60 * 60_000),
48+
};
49+
};
50+
51+
const signer = new Signer({
52+
credentials: provider,
53+
username: "me",
54+
hostname: "localhost",
55+
port: 443,
56+
region: "us-west-2",
57+
});
58+
59+
const token = await signer.getAuthToken();
60+
61+
expect(callCount).toBe(2);
62+
expect(token).toContain("X-Amz-Credential=REFRESHED_KEY");
63+
expect(token).not.toContain("EXPIRING_KEY");
64+
expect(token).toContain("X-Amz-Expires=900");
65+
expect(token).not.toMatch(/^https?:\/\//);
66+
});
3167
});

0 commit comments

Comments
 (0)