Skip to content

Commit 58ba472

Browse files
authored
feat: status api with key auth (#8035)
1 parent cc10a13 commit 58ba472

File tree

5 files changed

+114
-4
lines changed

5 files changed

+114
-4
lines changed

.github/workflows/integration-test.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ jobs:
4141
env:
4242
INTEGRATION_TEST: true
4343
DEV_FEATURES_ENABLED: ${{ matrix.dev-features-enabled }}
44-
# Key encryption key (KEK) for the secret vault. (For integration tests use only)
44+
# Key encryption key (KEK) for the secret vault used in integration tests
4545
SECRET_VAULT_KEK: DtPWS09unRXGuRScB60qXqCSsrjd22dUlXt/0oZgxSo=
4646
DB_URL: postgres://postgres:postgres@localhost:5432/postgres
47+
# Mock key for authorized status API access during integration tests
48+
STATUS_API_KEY: test-status-api-key
4749

4850
steps:
4951
- uses: logto-io/actions-run-logto-integration-tests@v4
Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,59 @@
11
import { createRequester } from '#src/utils/test-utils.js';
22

3+
import { EnvSet } from '../env-set/index.js';
4+
35
import statusRoutes from './status.js';
46

57
describe('status router', () => {
68
const requester = createRequester({ anonymousRoutes: statusRoutes });
7-
it('GET /status', async () => {
8-
await expect(requester.get('/status')).resolves.toHaveProperty('status', 204);
9+
10+
it('should respond with status 204', async () => {
11+
const response = await requester.get('/status');
12+
13+
expect(response.status).toBe(204);
14+
expect(response.headers).not.toHaveProperty('logto-tenant-id');
15+
});
16+
17+
it('should not respond with tenant ID when no API key is set', async () => {
18+
const response = await requester.get('/status').set('logto-status-api-key', 'any-key');
19+
20+
expect(response.status).toBe(204);
21+
expect(response.headers).not.toHaveProperty('logto-tenant-id');
22+
});
23+
});
24+
25+
describe('status router with API key set', () => {
26+
const testApiKey = 'test-status-api-key';
27+
const originalApiKey = EnvSet.values.statusApiKey;
28+
29+
beforeEach(() => {
30+
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
31+
Object.defineProperty(EnvSet.values, 'statusApiKey', {
32+
value: testApiKey,
33+
});
34+
});
35+
36+
afterEach(() => {
37+
// Restore original API key
38+
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
39+
Object.defineProperty(EnvSet.values, 'statusApiKey', {
40+
value: originalApiKey,
41+
});
42+
});
43+
44+
it('should respond with tenant ID when valid API key is provided', async () => {
45+
const requester = createRequester({ anonymousRoutes: statusRoutes });
46+
47+
const response = await requester.get('/status').set('logto-status-api-key', testApiKey);
48+
expect(response.headers).toHaveProperty('logto-tenant-id', 'mock_id');
49+
});
50+
51+
it('should not respond with tenant ID when invalid API key is provided', async () => {
52+
const requester = createRequester({ anonymousRoutes: statusRoutes });
53+
54+
const response = await requester.get('/status').set('logto-status-api-key', 'invalid-api-key');
55+
56+
expect(response.status).toBe(204);
57+
expect(response.headers).not.toHaveProperty('logto-tenant-id');
958
});
1059
});

packages/core/src/routes/status.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,33 @@
1+
import { timingSafeEqual } from 'node:crypto';
2+
3+
import { trySafe } from '@silverhand/essentials';
4+
15
import koaGuard from '#src/middleware/koa-guard.js';
26

7+
import { EnvSet } from '../env-set/index.js';
8+
39
import type { AnonymousRouter, RouterInitArgs } from './types.js';
410

5-
export default function statusRoutes<T extends AnonymousRouter>(...[router]: RouterInitArgs<T>) {
11+
const getSingleHeader = (value: string | string[] | undefined): string | undefined =>
12+
Array.isArray(value) || value === undefined ? undefined : value;
13+
14+
export default function statusRoutes<T extends AnonymousRouter>(
15+
...[router, tenant]: RouterInitArgs<T>
16+
) {
617
router.get('/status', koaGuard({ status: 204 }), async (ctx, next) => {
718
ctx.status = 204;
819

20+
const statusApiKeyHeader = getSingleHeader(ctx.request.headers['logto-status-api-key']);
21+
if (
22+
EnvSet.values.statusApiKey &&
23+
statusApiKeyHeader &&
24+
trySafe(() =>
25+
timingSafeEqual(Buffer.from(EnvSet.values.statusApiKey), Buffer.from(statusApiKeyHeader))
26+
)
27+
) {
28+
ctx.set('logto-tenant-id', tenant.id);
29+
}
30+
931
return next();
1032
});
1133
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { adminTenantApi } from '#src/api/api.js';
2+
3+
describe('status API', () => {
4+
it('should respond with status 204', async () => {
5+
const response = await adminTenantApi.get('status');
6+
expect(response.status).toBe(204);
7+
expect(response.headers).not.toHaveProperty('logto-tenant-id');
8+
});
9+
10+
it('should respond with tenant ID when valid API key is provided', async () => {
11+
const response = await adminTenantApi.get('status', {
12+
headers: {
13+
'logto-status-api-key': 'test-status-api-key',
14+
},
15+
});
16+
expect(response.status).toBe(204);
17+
expect(response.headers.get('logto-tenant-id')?.length).toBeGreaterThan(0);
18+
});
19+
20+
it('should not respond with tenant ID when invalid API key is provided', async () => {
21+
const response = await adminTenantApi.get('status', {
22+
headers: {
23+
'logto-status-api-key': 'invalid-api-key',
24+
},
25+
});
26+
expect(response.status).toBe(204);
27+
expect(response.headers).not.toHaveProperty('logto-tenant-id');
28+
});
29+
});

packages/shared/src/node/env/GlobalValues.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ export default class GlobalValues {
119119
/** Global switch for enabling/disabling case-sensitive usernames. */
120120
public readonly isCaseSensitiveUsername = yes(getEnv('CASE_SENSITIVE_USERNAME', 'true'));
121121

122+
/**
123+
* The API key for status endpoint protection. If it's set, requests to the status endpoint may
124+
* supply the key in the header for receiving response with additional details.
125+
*
126+
* @optional
127+
*/
128+
public readonly statusApiKey = getEnv('STATUS_API_KEY');
129+
122130
/** The write-only key for PostHog integration. */
123131
public readonly posthogPublicKey = process.env.POSTHOG_PUBLIC_KEY;
124132
/** The PostHog host URL for SDK to send events to. */

0 commit comments

Comments
 (0)