Skip to content

Commit d9a308f

Browse files
bsod90claude
andauthored
feat(api-gateway): accept a list of API secrets for verification (#10985)
* feat(api-gateway): accept a list of API secrets for verification Adds `CUBEJS_API_SECRETS` (comma-separated) as a sibling of `CUBEJS_API_SECRET`. When set, `CUBEJS_API_SECRETS` takes precedence: the gateway tries each secret in the list when verifying a JWT and accepts the token if any of them matches the signature. Signing behavior is unchanged — callers still sign with whatever single secret they hold. This enables zero-downtime rotation of the API secret. During a rotation window operators can publish the outgoing, current, and incoming secrets together in `CUBEJS_API_SECRETS`; in-flight tokens signed by the outgoing secret keep verifying until they expire or are reissued. - New optional `apiSecrets: string[]` on `ApiGatewayOptions` and `CreateOptions` (server-core). - `createDefaultCheckAuth` iterates `apiSecrets` when present; otherwise falls back to the singular `apiSecret`. An explicit `options.key` (used by the playground/system auth path) still wins, so playground behavior is unchanged. - JWK auth is unaffected — when `jwkUrl` is configured the secret is fetched dynamically from JWKS, so iterating a static list would be meaningless. - `Cube.apiSecrets()` static accessor mirrors `Cube.apiSecret()`. Tests cover: accept any listed secret, reject unlisted, list takes precedence over singular, empty list falls back, playground path isolated. * refactor(api-gateway): move multi-secret iteration into default checkAuth Instead of looping over candidate secrets in the gateway's returned auth middleware, the default checkAuth implementation now tries each configured secret internally. The middleware reverts to a single checkAuthFn(auth) call with one try/catch, matching the original structure. This keeps the secret-rotation logic where it belongs (the default token verifier) and avoids invoking the verifier wrapper from an outer loop. JWK auth resolves its key dynamically by kid, so it remains a single-secret path. Expiry/nbf failures still short-circuit so the real cause isn't shadowed by a later "invalid signature". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(api-gateway): read API secrets via env.ts; cover playground+secrets Addresses code-review feedback: 1. Route env access through `@cubejs-backend/shared` env.ts instead of reading process.env directly. Adds `apiSecret` and `apiSecrets` accessors (the latter trims, drops empties and deduplicates), and switches OptsHandler and CubejsServer.apiSecret()/apiSecrets() to getEnv(). Removes the now-redundant apiSecretsEnv helper + its test; parsing/dedup coverage moves to env.test.ts. 2. Adds an api-gateway test for CUBEJS_API_SECRETS coexisting with playgroundAuthSecret: playground token and any listed secret are accepted, while the shadowed singular apiSecret and an unknown secret are rejected. 3. Verified consistency with cube-runtime: CloudApiGateway does not override createDefaultCheckAuth/createCheckAuthFn (it only overrides createCheckAuthSystemFn for a playground-secret list, calling createDefaultCheckAuth({ key }) per secret). The options.key-first precedence keeps that path single-key, so no override conflict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f401118 commit d9a308f

11 files changed

Lines changed: 330 additions & 8 deletions

File tree

docs/content/product/configuration/reference/environment-variables.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ The secret key used to sign and verify JWTs. Generated on project scaffold with
1919
See also the [`check_auth` configuration
2020
option](/product/configuration/reference/config#check_auth).
2121

22+
## `CUBEJS_API_SECRETS`
23+
24+
A comma-separated list of secret keys accepted when verifying JWTs. Lets
25+
operators rotate the API secret without downtime: during a rotation window,
26+
include the previous, current, and incoming secrets together so in-flight
27+
tokens signed by any of them continue to verify.
28+
29+
When `CUBEJS_API_SECRETS` is set, it takes precedence over
30+
[`CUBEJS_API_SECRET`](#cubejs-api-secret) — only the listed secrets are
31+
accepted. Whitespace around entries is trimmed and duplicates are dropped.
32+
Tokens are still signed with whichever single secret the issuer holds; this
33+
variable affects verification only.
34+
35+
| Possible Values | Default in Development | Default in Production |
36+
| ---------------------------------------- | ---------------------- | --------------------- |
37+
| A comma-separated list of valid strings | N/A | N/A |
38+
2239
## `CUBEJS_APP`
2340

2441
An application ID used to uniquely identify the Cube deployment. Can be

packages/cubejs-api-gateway/src/gateway.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ class ApiGateway {
177177

178178
protected readonly playgroundAuthSecret?: string;
179179

180+
protected readonly apiSecrets?: string[];
181+
180182
// eslint-disable-next-line @typescript-eslint/no-unused-vars
181183
protected readonly event: (name: string, props?: object) => void;
182184

@@ -202,6 +204,9 @@ class ApiGateway {
202204
this.standalone = options.standalone;
203205
this.basePath = options.basePath;
204206
this.playgroundAuthSecret = options.playgroundAuthSecret;
207+
this.apiSecrets = options.apiSecrets && options.apiSecrets.length > 0
208+
? options.apiSecrets
209+
: undefined;
205210

206211
this.queryRewrite = options.queryRewrite || (async (query) => query);
207212
this.subscriptionStore = options.subscriptionStore || new LocalSubscriptionStore();
@@ -2456,7 +2461,7 @@ class ApiGateway {
24562461
}
24572462

24582463
protected createDefaultCheckAuth(options?: JWTOptions, internalOptions?: CheckAuthInternalOptions): PreparedCheckAuthFn {
2459-
type VerifyTokenFn = (auth: string, secret: string) => Promise<object | string> | object | string;
2464+
type VerifyTokenFn = (auth: string) => Promise<object | string> | object | string;
24602465

24612466
const verifyToken = (auth, secret) => jwt.verify(auth, secret, {
24622467
algorithms: <JWTAlgorithm[] | undefined>options?.algorithms,
@@ -2465,7 +2470,44 @@ class ApiGateway {
24652470
subject: options?.subject,
24662471
});
24672472

2468-
let checkAuthFn: VerifyTokenFn = verifyToken;
2473+
// `options.key` wins (used by the playground/system path), then the
2474+
// rotation list (`CUBEJS_API_SECRETS`), then the singular `apiSecret`.
2475+
let candidateSecrets: string[];
2476+
if (options?.key) {
2477+
candidateSecrets = [options.key];
2478+
} else if (this.apiSecrets && this.apiSecrets.length > 0) {
2479+
candidateSecrets = this.apiSecrets;
2480+
} else if (this.apiSecret) {
2481+
candidateSecrets = [this.apiSecret];
2482+
} else {
2483+
candidateSecrets = [];
2484+
}
2485+
2486+
// Default implementation: accept the token if any configured secret
2487+
// verifies it. Token-level failures (expiry, nbf) reproduce for every
2488+
// secret, so surface them immediately rather than shadowing the real
2489+
// cause with a later "invalid signature".
2490+
let checkAuthFn: VerifyTokenFn = (auth) => {
2491+
let lastError: any;
2492+
2493+
for (const candidate of candidateSecrets) {
2494+
try {
2495+
return verifyToken(auth, candidate);
2496+
} catch (e: any) {
2497+
lastError = e;
2498+
if (e?.name === 'TokenExpiredError' || e?.name === 'NotBeforeError') {
2499+
throw e;
2500+
}
2501+
}
2502+
}
2503+
2504+
if (lastError) {
2505+
throw lastError;
2506+
}
2507+
2508+
// No secret configured at all — reproduce the upstream JWT error.
2509+
return verifyToken(auth, undefined);
2510+
};
24692511

24702512
if (options?.jwkUrl) {
24712513
const jwks = createJWKsFetcher(options, {
@@ -2519,12 +2561,10 @@ class ApiGateway {
25192561
};
25202562
}
25212563

2522-
const secret = options?.key || this.apiSecret;
2523-
25242564
return async (req, auth) => {
25252565
if (auth) {
25262566
try {
2527-
req.securityContext = await checkAuthFn(auth, secret);
2567+
req.securityContext = await checkAuthFn(auth);
25282568
req.signedWithPlaygroundAuthSecret = Boolean(internalOptions?.isPlaygroundCheckAuth);
25292569
} catch (e: any) {
25302570
if (this.enforceSecurityChecks) {

packages/cubejs-api-gateway/src/types/gateway.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ interface ApiGatewayOptions {
7070
subscriptionStore?: any;
7171
enforceSecurityChecks?: boolean;
7272
playgroundAuthSecret?: string;
73+
/** Rotation window: any listed secret verifies. Takes precedence over `apiSecret`. */
74+
apiSecrets?: string[];
7375
serverCoreVersion?: string;
7476
contextRejectionMiddleware?: ContextRejectionMiddlewareFn;
7577
wsContextAcceptor?: ContextAcceptorFn;

packages/cubejs-api-gateway/test/auth.test.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,211 @@ describe('test authorization', () => {
640640
]);
641641
});
642642

643+
test('apiSecrets - accepts tokens signed by any secret in the list', async () => {
644+
const loggerMock = jest.fn(() => {
645+
//
646+
});
647+
const handlerMock = jest.fn((req, res) => {
648+
res.status(200).end();
649+
});
650+
651+
const apiSecrets = ['outgoing-secret', 'current-secret', 'incoming-secret'];
652+
653+
const { app } = createApiGateway(handlerMock, loggerMock, {
654+
apiSecrets,
655+
});
656+
657+
for (const secret of apiSecrets) {
658+
const token = generateAuthToken({ uid: 5 }, {}, secret);
659+
// eslint-disable-next-line no-await-in-loop
660+
await request(app)
661+
.get('/test-auth-fake')
662+
.set('Authorization', `Authorization: ${token}`)
663+
.expect(200);
664+
}
665+
666+
expect(handlerMock.mock.calls.length).toEqual(apiSecrets.length);
667+
});
668+
669+
test('apiSecrets - rejects tokens not signed by any secret in the list', async () => {
670+
const loggerMock = jest.fn(() => {
671+
//
672+
});
673+
const handlerMock = jest.fn((req, res) => {
674+
res.status(200).end();
675+
});
676+
677+
const { app } = createApiGateway(handlerMock, loggerMock, {
678+
apiSecrets: ['a', 'b', 'c'],
679+
});
680+
681+
const badToken = generateAuthToken({ uid: 5 }, {}, 'not-in-list');
682+
683+
await request(app)
684+
.get('/test-auth-fake')
685+
.set('Authorization', `Authorization: ${badToken}`)
686+
.expect(403);
687+
688+
expect(handlerMock.mock.calls.length).toEqual(0);
689+
});
690+
691+
test('apiSecrets - takes precedence over apiSecret when both are configured', async () => {
692+
const loggerMock = jest.fn(() => {
693+
//
694+
});
695+
const handlerMock = jest.fn((req, res) => {
696+
res.status(200).end();
697+
});
698+
699+
// Base fixture's apiSecret='secret' must be ignored once apiSecrets is set.
700+
const { app } = createApiGateway(handlerMock, loggerMock, {
701+
apiSecrets: ['only-this-one'],
702+
});
703+
704+
const oldSingularToken = generateAuthToken({ uid: 5 }, {}, 'secret');
705+
706+
await request(app)
707+
.get('/test-auth-fake')
708+
.set('Authorization', `Authorization: ${oldSingularToken}`)
709+
.expect(403);
710+
711+
const listedToken = generateAuthToken({ uid: 5 }, {}, 'only-this-one');
712+
713+
await request(app)
714+
.get('/test-auth-fake')
715+
.set('Authorization', `Authorization: ${listedToken}`)
716+
.expect(200);
717+
718+
expect(handlerMock.mock.calls.length).toEqual(1);
719+
});
720+
721+
test('apiSecrets - empty array falls back to singular apiSecret', async () => {
722+
const loggerMock = jest.fn(() => {
723+
//
724+
});
725+
const handlerMock = jest.fn((req, res) => {
726+
res.status(200).end();
727+
});
728+
729+
const { app } = createApiGateway(handlerMock, loggerMock, {
730+
apiSecrets: [],
731+
});
732+
733+
const token = generateAuthToken({ uid: 5 }, {}, 'secret');
734+
735+
await request(app)
736+
.get('/test-auth-fake')
737+
.set('Authorization', `Authorization: ${token}`)
738+
.expect(200);
739+
740+
expect(handlerMock.mock.calls.length).toEqual(1);
741+
});
742+
743+
test('apiSecrets - expired token signed by a listed secret is rejected', async () => {
744+
const loggerMock = jest.fn(() => {
745+
//
746+
});
747+
const handlerMock = jest.fn((req, res) => {
748+
res.status(200).end();
749+
});
750+
751+
const { app } = createApiGateway(handlerMock, loggerMock, {
752+
apiSecrets: ['s1', 's2', 's3'],
753+
});
754+
755+
const expiredToken = jwt.sign({ uid: 5 }, 's1', { expiresIn: '-1s' });
756+
757+
await request(app)
758+
.get('/test-auth-fake')
759+
.set('Authorization', `Authorization: ${expiredToken}`)
760+
.expect(403);
761+
762+
expect(handlerMock.mock.calls.length).toEqual(0);
763+
});
764+
765+
test('apiSecrets - playground secret path is unaffected', async () => {
766+
const loggerMock = jest.fn(() => {
767+
//
768+
});
769+
const handlerMock = jest.fn((req, res) => {
770+
res.status(200).end();
771+
});
772+
773+
const playgroundAuthSecret = 'playgroundSecret';
774+
775+
const { app } = createApiGateway(handlerMock, loggerMock, {
776+
apiSecrets: ['outgoing', 'current'],
777+
playgroundAuthSecret,
778+
});
779+
780+
const playgroundToken = generateAuthToken({ uid: 5 }, {}, playgroundAuthSecret);
781+
782+
await request(app)
783+
.get('/test-auth-fake')
784+
.set('Authorization', `Authorization: ${playgroundToken}`)
785+
.expect(200);
786+
787+
const apiToken = generateAuthToken({ uid: 5 }, {}, 'current');
788+
789+
await request(app)
790+
.get('/test-auth-fake')
791+
.set('Authorization', `Authorization: ${apiToken}`)
792+
.expect(200);
793+
794+
expect(handlerMock.mock.calls.length).toEqual(2);
795+
});
796+
797+
test('apiSecrets - coexists with playgroundAuthSecret (both sources active)', async () => {
798+
const loggerMock = jest.fn(() => {
799+
//
800+
});
801+
const handlerMock = jest.fn((req, res) => {
802+
res.status(200).end();
803+
});
804+
805+
const playgroundAuthSecret = 'playgroundSecret';
806+
807+
// Base fixture's singular apiSecret='secret' is shadowed by apiSecrets.
808+
const { app } = createApiGateway(handlerMock, loggerMock, {
809+
apiSecrets: ['outgoing', 'current'],
810+
playgroundAuthSecret,
811+
});
812+
813+
// A token signed by the playground secret is accepted via the system path.
814+
const playgroundToken = generateAuthToken({ uid: 5 }, {}, playgroundAuthSecret);
815+
await request(app)
816+
.get('/test-auth-fake')
817+
.set('Authorization', `Authorization: ${playgroundToken}`)
818+
.expect(200);
819+
820+
// A token signed by any listed secret is accepted via the main path.
821+
for (const secret of ['outgoing', 'current']) {
822+
const apiToken = generateAuthToken({ uid: 5 }, {}, secret);
823+
// eslint-disable-next-line no-await-in-loop
824+
await request(app)
825+
.get('/test-auth-fake')
826+
.set('Authorization', `Authorization: ${apiToken}`)
827+
.expect(200);
828+
}
829+
830+
// The singular apiSecret is shadowed by apiSecrets and is not a playground
831+
// secret either, so a token signed with it is rejected by both paths.
832+
const shadowedSingularToken = generateAuthToken({ uid: 5 }, {}, 'secret');
833+
await request(app)
834+
.get('/test-auth-fake')
835+
.set('Authorization', `Authorization: ${shadowedSingularToken}`)
836+
.expect(403);
837+
838+
// A token signed by neither the playground secret nor any listed secret.
839+
const strangerToken = generateAuthToken({ uid: 5 }, {}, 'not-anywhere');
840+
await request(app)
841+
.get('/test-auth-fake')
842+
.set('Authorization', `Authorization: ${strangerToken}`)
843+
.expect(403);
844+
845+
expect(handlerMock.mock.calls.length).toEqual(3);
846+
});
847+
643848
test('coerceForSqlQuery claimsNamespace', async () => {
644849
const loggerMock = jest.fn(() => {
645850
//

packages/cubejs-backend-shared/src/env.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1933,6 +1933,20 @@ const variables: Record<string, (...args: any) => any> = {
19331933
.asString(),
19341934
playgroundAuthSecret: () => get('CUBEJS_PLAYGROUND_AUTH_SECRET')
19351935
.asString(),
1936+
apiSecret: () => get('CUBEJS_API_SECRET')
1937+
.asString(),
1938+
// Comma-separated rotation list. Trimmed, empties dropped, deduplicated.
1939+
// Takes precedence over the singular `apiSecret` when non-empty.
1940+
apiSecrets: (): string[] | undefined => {
1941+
const raw = get('CUBEJS_API_SECRETS').asString();
1942+
if (!raw) {
1943+
return undefined;
1944+
}
1945+
const unique = Array.from(
1946+
new Set(raw.split(',').map((s) => s.trim()).filter(Boolean))
1947+
);
1948+
return unique.length > 0 ? unique : undefined;
1949+
},
19361950
agentFrameSize: () => get('CUBEJS_AGENT_FRAME_SIZE')
19371951
.default('200')
19381952
.asInt(),

packages/cubejs-backend-shared/test/env.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,38 @@ describe('getEnv', () => {
143143
);
144144
});
145145
});
146+
147+
describe('getEnv(apiSecret / apiSecrets)', () => {
148+
afterEach(() => {
149+
delete process.env.CUBEJS_API_SECRET;
150+
delete process.env.CUBEJS_API_SECRETS;
151+
});
152+
153+
test('apiSecret', () => {
154+
expect(getEnv('apiSecret')).toBeUndefined();
155+
156+
process.env.CUBEJS_API_SECRET = 'secret';
157+
expect(getEnv('apiSecret')).toBe('secret');
158+
});
159+
160+
test('apiSecrets - unset / empty / blanks resolve to undefined', () => {
161+
expect(getEnv('apiSecrets')).toBeUndefined();
162+
163+
process.env.CUBEJS_API_SECRETS = '';
164+
expect(getEnv('apiSecrets')).toBeUndefined();
165+
166+
process.env.CUBEJS_API_SECRETS = ', ,,';
167+
expect(getEnv('apiSecrets')).toBeUndefined();
168+
});
169+
170+
test('apiSecrets - trims, drops empties, deduplicates, preserves order', () => {
171+
process.env.CUBEJS_API_SECRETS = ' a , b , c ';
172+
expect(getEnv('apiSecrets')).toEqual(['a', 'b', 'c']);
173+
174+
process.env.CUBEJS_API_SECRETS = 'a,b,a,c,b';
175+
expect(getEnv('apiSecrets')).toEqual(['a', 'b', 'c']);
176+
177+
process.env.CUBEJS_API_SECRETS = 'only';
178+
expect(getEnv('apiSecrets')).toEqual(['only']);
179+
});
180+
});

0 commit comments

Comments
 (0)