Skip to content

Commit 4a84072

Browse files
authored
feat: add public API foundation with OAuth2 authentication (CM-965) (#3841)
Signed-off-by: Yeganathan S <[email protected]>
1 parent 5a4689b commit 4a84072

File tree

39 files changed

+485
-138
lines changed

39 files changed

+485
-138
lines changed

backend/.env.dist.local

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,7 @@ CROWD_GITHUB_IS_SNOWFLAKE_ENABLED=false
158158

159159
# Tinybird
160160
CROWD_TINYBIRD_BASE_URL=http://localhost:7181/
161+
162+
# Auth0
163+
CROWD_AUTH0_ISSUER_BASE_URL=
164+
CROWD_AUTH0_AUDIENCE=

backend/.eslintrc.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ module.exports = {
1212
'prefer-destructuring': ['error', { object: false, array: false }],
1313
'no-param-reassign': 0,
1414
'no-underscore-dangle': 0,
15+
'no-unused-vars': [
16+
'error',
17+
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' },
18+
],
1519
'@typescript-eslint/naming-convention': [
1620
'error',
1721
{
@@ -46,6 +50,14 @@ module.exports = {
4650
'prefer-destructuring': ['error', { object: false, array: false }],
4751
'no-param-reassign': 0,
4852
'no-underscore-dangle': 0,
53+
'@typescript-eslint/no-unused-vars': [
54+
'error',
55+
{
56+
argsIgnorePattern: '^_',
57+
varsIgnorePattern: '^_',
58+
destructuredArrayIgnorePattern: '^_',
59+
},
60+
],
4961
'@typescript-eslint/naming-convention': [
5062
'error',
5163
{

backend/config/custom-environment-variables.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@
156156
},
157157
"auth0": {
158158
"clientId": "CROWD_AUTH0_CLIENT_ID",
159-
"jwks": "CROWD_AUTH0_JWKS"
159+
"jwks": "CROWD_AUTH0_JWKS",
160+
"issuerBaseURL": "CROWD_AUTH0_ISSUER_BASE_URL",
161+
"audience": "CROWD_AUTH0_AUDIENCE"
160162
},
161163
"sso": {
162164
"crowdTenantId": "CROWD_SSO_CROWD_TENANT_ID",

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"emoji-dictionary": "^1.0.11",
102102
"erlpack": "^0.1.4",
103103
"express": "4.17.1",
104+
"express-oauth2-jwt-bearer": "^1.7.4",
104105
"express-rate-limit": "6.5.1",
105106
"fast-levenshtein": "^3.0.0",
106107
"formidable-serverless": "1.1.1",

backend/src/api/apiRateLimiter.ts

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,15 @@
11
import rateLimit from 'express-rate-limit'
22

3-
export function createRateLimiter({
4-
max,
5-
windowMs,
6-
message,
7-
}: {
8-
max: number
9-
windowMs: number
10-
message: string
11-
}) {
3+
import { RateLimitError } from '@crowd/common'
4+
5+
export function createRateLimiter({ max, windowMs }: { max: number; windowMs: number }) {
126
return rateLimit({
137
max,
148
windowMs,
15-
message,
16-
skip: (req) => {
17-
if (req.method === 'OPTIONS') {
18-
return true
19-
}
20-
21-
if (req.originalUrl.endsWith('/import')) {
22-
return true
23-
}
24-
25-
return false
9+
handler: (_req, res) => {
10+
const err = new RateLimitError()
11+
res.status(err.status).json(err.toJSON())
2612
},
13+
skip: (req) => req.method === 'OPTIONS' || req.originalUrl.endsWith('/import'),
2714
})
2815
}

backend/src/api/auth/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@ export default (app) => {
55
const signInRateLimiter = createRateLimiter({
66
max: 100,
77
windowMs: 15 * 60 * 1000,
8-
message: 'errors.429',
98
})
109

1110
app.post(`/auth/sign-in`, signInRateLimiter, safeWrap(require('./authSignIn').default))
1211

1312
const signUpRateLimiter = createRateLimiter({
1413
max: 20,
1514
windowMs: 60 * 60 * 1000,
16-
message: 'errors.429',
1715
})
1816

1917
app.post(`/auth/sign-up`, signUpRateLimiter, safeWrap(require('./authSignUp').default))

backend/src/api/index.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { tenantMiddleware } from '../middlewares/tenantMiddleware'
3232

3333
import { createRateLimiter } from './apiRateLimiter'
3434
import authSocial from './auth/authSocial'
35+
import { publicRouter } from './public'
3536
import WebSockets from './websockets'
3637

3738
const serviceLogger = getServiceLogger()
@@ -87,6 +88,7 @@ setImmediate(async () => {
8788
)
8889

8990
app.use((req, res, next) => {
91+
// @ts-ignore
9092
req.profileSql = req.headers['x-profile-sql'] === 'true'
9193
next()
9294
})
@@ -126,30 +128,16 @@ setImmediate(async () => {
126128
})
127129
}
128130

129-
// initialize passport strategies
130-
app.use(passportStrategyMiddleware)
131-
132-
// Sets the current language of the request
133-
app.use(languageMiddleware)
134-
135-
// adds our ApiResponseHandler instance to the req object as responseHandler
136-
app.use(responseHandlerMiddleware)
137-
138-
// Configures the authentication middleware
139-
// to set the currentUser to the requests
140-
app.use(authMiddleware)
131+
// Enables Helmet, a set of tools to
132+
// increase security.
133+
app.use(helmet())
141134

142-
// Default rate limiter
143135
const defaultRateLimiter = createRateLimiter({
144136
max: 200,
145137
windowMs: 60 * 1000,
146-
message: 'errors.429',
147138
})
148-
app.use(defaultRateLimiter)
149139

150-
// Enables Helmet, a set of tools to
151-
// increase security.
152-
app.use(helmet())
140+
app.use(defaultRateLimiter)
153141

154142
app.use(
155143
bodyParser.json({
@@ -159,7 +147,25 @@ setImmediate(async () => {
159147

160148
app.use(bodyParser.urlencoded({ limit: '5mb', extended: true }))
161149

150+
// Public API uses its own OAuth2 auth and error flow
151+
// Must be mounted before internal endpoints.
152+
app.use('/', publicRouter())
153+
154+
// initialize passport strategies
155+
app.use(passportStrategyMiddleware)
156+
157+
// Sets the current language of the request
158+
app.use(languageMiddleware)
159+
160+
// adds our ApiResponseHandler instance to the req object as responseHandler
161+
app.use(responseHandlerMiddleware)
162+
163+
// Configures the authentication middleware
164+
// to set the currentUser to the requests
165+
app.use(authMiddleware)
166+
162167
app.use((req, res, next) => {
168+
// @ts-ignore
163169
req.userData = {
164170
ip: req.ip,
165171
userAgent: req.headers ? req.headers['user-agent'] : null,

backend/src/api/public/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Router } from 'express'
2+
3+
import { AUTH0_CONFIG } from '../../conf'
4+
5+
import { errorHandler } from './middlewares/errorHandler'
6+
import { oauth2Middleware } from './middlewares/oauth2Middleware'
7+
import { v1Router } from './v1'
8+
9+
export function publicRouter(): Router {
10+
const router = Router()
11+
12+
router.use('/v1', oauth2Middleware(AUTH0_CONFIG), v1Router())
13+
router.use(errorHandler)
14+
15+
return router
16+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { ErrorRequestHandler, NextFunction, Request, Response } from 'express'
2+
import {
3+
InsufficientScopeError as Auth0InsufficientScopeError,
4+
UnauthorizedError as Auth0UnauthorizedError,
5+
} from 'express-oauth2-jwt-bearer'
6+
7+
import { HttpError, InsufficientScopeError, InternalError, UnauthorizedError } from '@crowd/common'
8+
9+
/**
10+
* Converts errors to structured JSON: `{ error: { code, message } }`.
11+
* Defaults to 500 Internal Error for unhandled errors.
12+
*/
13+
export const errorHandler: ErrorRequestHandler = (
14+
error: any,
15+
req: Request,
16+
res: Response,
17+
_next: NextFunction,
18+
) => {
19+
if (error instanceof HttpError) {
20+
res.status(error.status).json(error.toJSON())
21+
return
22+
}
23+
24+
if (error instanceof Auth0InsufficientScopeError) {
25+
const httpErr = new InsufficientScopeError(error.message || undefined)
26+
res.status(httpErr.status).json(httpErr.toJSON())
27+
return
28+
}
29+
30+
if (error instanceof Auth0UnauthorizedError) {
31+
const httpErr = new UnauthorizedError(error.message || undefined)
32+
res.status(httpErr.status).json(httpErr.toJSON())
33+
return
34+
}
35+
36+
const unknownError = new InternalError()
37+
res.status(unknownError.status).json(unknownError.toJSON())
38+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { NextFunction, Request, RequestHandler, Response } from 'express'
2+
import { auth } from 'express-oauth2-jwt-bearer'
3+
4+
import { UnauthorizedError } from '@crowd/common'
5+
6+
import type { Auth0Configuration } from '@/conf/configTypes'
7+
import type { ApiRequest, Auth0TokenPayload } from '@/types/api'
8+
9+
function resolveActor(req: Request, _res: Response, next: NextFunction): void {
10+
const payload = (req.auth?.payload ?? {}) as Auth0TokenPayload
11+
12+
const rawId = payload.sub ?? payload.azp
13+
14+
if (!rawId) {
15+
next(new UnauthorizedError('Token missing caller identity'))
16+
return
17+
}
18+
19+
const id = rawId.replace(/@clients$/, '')
20+
21+
const authReq = req as ApiRequest
22+
23+
const scopes = typeof payload.scope === 'string' ? payload.scope.split(' ').filter(Boolean) : []
24+
25+
authReq.actor = { id, type: 'service', scopes }
26+
27+
next()
28+
}
29+
30+
export function oauth2Middleware(config: Auth0Configuration): RequestHandler[] {
31+
return [
32+
auth({
33+
issuerBaseURL: config.issuerBaseURL,
34+
audience: config.audience,
35+
}),
36+
resolveActor,
37+
]
38+
}

0 commit comments

Comments
 (0)