Skip to content

Commit 6d16d0d

Browse files
authored
feat: add static api key middleware for dev stats (CM-1055) (#3933)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent d2e767e commit 6d16d0d

8 files changed

Lines changed: 119 additions & 0 deletions

File tree

backend/src/api/public/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import { AUTH0_CONFIG } from '../../conf'
44

55
import { errorHandler } from './middlewares/errorHandler'
66
import { oauth2Middleware } from './middlewares/oauth2Middleware'
7+
import { staticApiKeyMiddleware } from './middlewares/staticApiKeyMiddleware'
78
import { v1Router } from './v1'
9+
import { devStatsRouter } from './v1/dev-stats'
810

911
export function publicRouter(): Router {
1012
const router = Router()
1113

14+
router.use('/v1/dev-stats', staticApiKeyMiddleware(), devStatsRouter())
1215
router.use('/v1', oauth2Middleware(AUTH0_CONFIG), v1Router())
1316
router.use(errorHandler)
1417

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import crypto from 'crypto'
2+
import type { NextFunction, Request, RequestHandler, Response } from 'express'
3+
4+
import { UnauthorizedError } from '@crowd/common'
5+
import { findApiKeyByHash, optionsQx, touchApiKeyLastUsed } from '@crowd/data-access-layer'
6+
7+
export function staticApiKeyMiddleware(): RequestHandler {
8+
return async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
9+
try {
10+
const authHeader = req.headers.authorization
11+
12+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
13+
next(new UnauthorizedError('Missing or invalid Authorization header'))
14+
return
15+
}
16+
17+
const providedKey = authHeader.slice('Bearer '.length)
18+
const keyHash = crypto.createHash('sha256').update(providedKey).digest('hex')
19+
20+
const qx = optionsQx(req)
21+
const apiKey = await findApiKeyByHash(qx, keyHash)
22+
23+
if (!apiKey) {
24+
next(new UnauthorizedError('Invalid API key'))
25+
return
26+
}
27+
28+
if (apiKey.revokedAt) {
29+
next(new UnauthorizedError('API key has been revoked'))
30+
return
31+
}
32+
33+
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
34+
next(new UnauthorizedError('API key has expired'))
35+
return
36+
}
37+
38+
// fire and forget — don't block the request
39+
touchApiKeyLastUsed(qx, apiKey.id).catch(() => {})
40+
41+
req.actor = { id: apiKey.name, type: 'service', scopes: apiKey.scopes }
42+
43+
next()
44+
} catch (err) {
45+
next(err)
46+
}
47+
}
48+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Router } from 'express'
2+
3+
import { createRateLimiter } from '@/api/apiRateLimiter'
4+
import { requireScopes } from '@/api/public/middlewares/requireScopes'
5+
import { SCOPES } from '@/security/scopes'
6+
7+
const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
8+
9+
export function devStatsRouter(): Router {
10+
const router = Router()
11+
12+
router.use(rateLimiter)
13+
14+
router.post('/affiliations', requireScopes([SCOPES.READ_AFFILIATIONS]), (_req, res) => {
15+
res.json({ status: 'ok' })
16+
})
17+
18+
return router
19+
}

backend/src/database/migrations/U1773938832__add-api-keys-tale.sql

Whitespace-only changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
CREATE TABLE "apiKeys" (
2+
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3+
"name" TEXT NOT NULL,
4+
"keyHash" TEXT NOT NULL UNIQUE,
5+
"keyPrefix" TEXT NOT NULL,
6+
"scopes" TEXT[] NOT NULL DEFAULT '{}',
7+
"expiresAt" TIMESTAMPTZ,
8+
"lastUsedAt" TIMESTAMPTZ,
9+
"createdById" TEXT,
10+
"revokedAt" TIMESTAMPTZ,
11+
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
12+
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
13+
);

backend/src/security/scopes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const SCOPES = {
99
WRITE_WORK_EXPERIENCES: 'write:work-experiences',
1010
READ_PROJECT_AFFILIATIONS: 'read:project-affiliations',
1111
WRITE_PROJECT_AFFILIATIONS: 'write:project-affiliations',
12+
READ_AFFILIATIONS: 'read:affiliations',
1213
} as const
1314

1415
export type Scope = (typeof SCOPES)[keyof typeof SCOPES]
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { QueryExecutor } from '../queryExecutor'
2+
3+
export interface IApiKey {
4+
id: string
5+
name: string
6+
scopes: string[]
7+
expiresAt: Date | null
8+
revokedAt: Date | null
9+
}
10+
11+
export async function findApiKeyByHash(
12+
qx: QueryExecutor,
13+
keyHash: string,
14+
): Promise<IApiKey | null> {
15+
return qx.selectOneOrNone(
16+
`
17+
SELECT id, name, scopes, "expiresAt", "revokedAt"
18+
FROM "apiKeys"
19+
WHERE "keyHash" = $(keyHash)
20+
`,
21+
{ keyHash },
22+
)
23+
}
24+
25+
export async function touchApiKeyLastUsed(qx: QueryExecutor, id: string): Promise<void> {
26+
await qx.result(
27+
`
28+
UPDATE "apiKeys"
29+
SET "lastUsedAt" = now(), "updatedAt" = now()
30+
WHERE id = $(id)
31+
`,
32+
{ id },
33+
)
34+
}

services/libs/data-access-layer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './activities'
22
export * from './activityRelations'
3+
export * from './apiKeys'
34
export * from './dashboards'
45
export * from './members'
56
export * from './organizations'

0 commit comments

Comments
 (0)