Skip to content

Commit 6c8a4b7

Browse files
zeevdrclaude
andauthored
feat(client): add setToken() for JWT token rotation (#94)
Adds ConfigClient.setToken(token) to allow callers to rotate bearer tokens after construction. The method mutates the shared Metadata object, so active ConfigWatcher instances automatically use the new token on their next reconnect without any additional wiring. When called on a client that was constructed in header-mode (x-subject, x-role, x-tenant-id), setToken() removes those headers and switches fully to JWT auth for all subsequent RPCs. Closes #62 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f89d85 commit 6c8a4b7

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

src/client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,21 @@ export class ConfigClient {
429429
return new ConfigWatcher(this.configStub, this.metadata, this.timeout, tenantId);
430430
}
431431

432+
/**
433+
* Replace the bearer token used for all subsequent RPCs (including watcher reconnects).
434+
*
435+
* Switches the client to JWT auth mode: removes any metadata-header credentials
436+
* (x-subject, x-role, x-tenant-id) that may have been set at construction time.
437+
*
438+
* @param token - Raw JWT (without the "Bearer " prefix).
439+
*/
440+
setToken(token: string): void {
441+
this.metadata.remove("x-subject");
442+
this.metadata.remove("x-role");
443+
this.metadata.remove("x-tenant-id");
444+
this.metadata.set("authorization", `Bearer ${token}`);
445+
}
446+
432447
/**
433448
* Close the underlying gRPC channels.
434449
*/

test/client.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,61 @@ describe("ConfigClient", () => {
828828
});
829829
tenantClient.close();
830830
});
831+
832+
describe("setToken()", () => {
833+
it("subsequent RPCs use the new Bearer token", async () => {
834+
client.setToken("rotated-token");
835+
836+
configStub.getField.mockImplementation(
837+
(_req: unknown, meta: Metadata, _opts: unknown, cb: (...args: unknown[]) => void) => {
838+
expect(meta.get("authorization")).toEqual(["Bearer rotated-token"]);
839+
cb(null, {
840+
value: { fieldPath: "a", value: { stringValue: "v" }, checksum: "c" },
841+
});
842+
},
843+
);
844+
845+
await client.get("tenant-1", "a");
846+
});
847+
848+
it("clears metadata headers when switching from header-mode to token-mode", async () => {
849+
client.setToken("jwt-abc");
850+
851+
configStub.getField.mockImplementation(
852+
(_req: unknown, meta: Metadata, _opts: unknown, cb: (...args: unknown[]) => void) => {
853+
expect(meta.get("x-subject")).toEqual([]);
854+
expect(meta.get("x-role")).toEqual([]);
855+
expect(meta.get("authorization")).toEqual(["Bearer jwt-abc"]);
856+
cb(null, {
857+
value: { fieldPath: "a", value: { stringValue: "v" }, checksum: "c" },
858+
});
859+
},
860+
);
861+
862+
await client.get("tenant-1", "a");
863+
});
864+
865+
it("rotates token on a token-mode client", async () => {
866+
const tokenClient = new ConfigClient("localhost:9090", {
867+
token: "initial-token",
868+
retry: false,
869+
});
870+
871+
tokenClient.setToken("refreshed-token");
872+
873+
configStub.getField.mockImplementation(
874+
(_req: unknown, meta: Metadata, _opts: unknown, cb: (...args: unknown[]) => void) => {
875+
expect(meta.get("authorization")).toEqual(["Bearer refreshed-token"]);
876+
cb(null, {
877+
value: { fieldPath: "a", value: { stringValue: "v" }, checksum: "c" },
878+
});
879+
},
880+
);
881+
882+
await tokenClient.get("tenant-1", "a");
883+
tokenClient.close();
884+
});
885+
});
831886
});
832887

833888
describe("AbortSignal", () => {

0 commit comments

Comments
 (0)