Skip to content

Commit 0317c0d

Browse files
committed
Merge branch 'rc-v1' of ssh://ansible.subtype.internal:2424/subtype/subspace-api into rc-v1
2 parents 68ebeb7 + 59edfe4 commit 0317c0d

File tree

9 files changed

+975
-13
lines changed

9 files changed

+975
-13
lines changed

package-lock.json

Lines changed: 864 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,17 @@
2424
"@modelcontextprotocol/sdk": "^1.9.0",
2525
"express-jwt": "^8.5.1",
2626
"express-rate-limit": "^7.5.0",
27+
"express-session": "^1.18.1",
2728
"jose": "^6.0.10",
29+
"keycloak-connect": "^26.1.1",
2830
"prettier": "3.5.3",
2931
"yahoo-finance2": "^2.13.3",
3032
"zod": "^3.24.2"
3133
},
3234
"devDependencies": {
3335
"@types/express": "^5.0.1",
3436
"@types/express-jwt": "^6.0.4",
37+
"@types/express-session": "^1.18.1",
3538
"@types/jsonwebtoken": "^9.0.9",
3639
"@types/node": "^22.14.1",
3740
"dotenv": "^16.5.0",

src/configs/keycloakConfig.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Keycloak config file
2+
// Edit this file or update .env file
3+
export const keycloakConfig = {
4+
realm: process.env.KEYCLOAK_REALM || 'subspace',
5+
'auth-server-url': process.env.KEYCLOAK_AUTH_URL || 'https://auth.subtype.space',
6+
resource: process.env.KEYCLOAK_RESOURCE || 'subspace-api',
7+
'ssl-required': 'external',
8+
'confidential-port': 443,
9+
'bearer-only': true,
10+
}
11+

src/server.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import { Request } from 'express-jwt'
55
import trmnlRouter from './v1/routers/trmnlRouter.js'
66
import statusRouter from './v1/routers/statusRouter.js'
77
import helmet from 'helmet'
8+
import session from 'express-session'
9+
10+
import KeycloakConnect from 'keycloak-connect'
11+
import { keycloakConfig } from './configs/keycloakConfig.js'
812

913
// MCP import shenanigans
1014
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
1115
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'
1216
import { registerTools } from './v1/mcp/registerTools.js'
1317

14-
import { logIncomingAuth, authRequired, authOptional } from './utils/auth.js'
18+
import { logIncomingAuth } from './utils/auth.js'
1519
import { rateLimiter } from './utils/rateLimiter.js'
1620

1721
logger.info('Initializing MCP server...')
@@ -29,16 +33,28 @@ const PORT = process.env.PORT || 9595
2933
const ACTIVE_VERSION = process.env.API_VERSION || 'v1'
3034
// Register and enable model context protocol tools
3135
const transports: { [sessionId: string]: SSEServerTransport } = {}
36+
const memoryStore = new session.MemoryStore()
37+
const keycloak = new KeycloakConnect({ store: memoryStore }, keycloakConfig)
3238

3339
logger.info('Registering tools with MCP server...')
3440
registerTools(mcpServer)
3541

3642
logger.info('Setting up middleware...')
43+
// SESSION_SECRET should just be a super long random base64 encoded string
44+
server.use(
45+
session({
46+
secret: process.env.SESSION_SECRET!,
47+
resave: false,
48+
saveUninitialized: true,
49+
store: memoryStore,
50+
})
51+
)
52+
server.use(keycloak.middleware())
3753
server.use(helmet())
38-
logger.info('Initializing routes...')
54+
server.use(rateLimiter)
3955

4056
// Declare regular REST API routing
41-
server.use(authOptional, rateLimiter)
57+
logger.info('Initializing routes...')
4258

4359
//server.use('/v1/trmnl', express.json(), trmnlRouter) disable this route because it's just not active right now
4460
server.use('/', statusRouter)
@@ -48,6 +64,8 @@ server.use('/health', express.json(), statusRouter)
4864
server.set('trust proxy', 1)
4965

5066
server.use(function (err: any, req: Request, res: Response, next: NextFunction) {
67+
logger.debug(err)
68+
5169
if (err.name === 'UnauthorizedError') {
5270
logger.warn('JWT failed authentication')
5371
res.status(401).send({ message: 'Unauthorized' })
@@ -61,7 +79,7 @@ server.use(function (err: any, req: Request, res: Response, next: NextFunction)
6179

6280
// MCP Setup
6381
// Discovery endpoint
64-
server.get('/sse', logIncomingAuth, authRequired, async (req: Request, res: Response) => {
82+
server.get('/sse', logIncomingAuth, keycloak.protect(), async (req: Request, res: Response) => {
6583
const transport = new SSEServerTransport('/messages', res)
6684
transports[transport.sessionId] = transport
6785
logger.info('New MCP session created:', transport.sessionId)
@@ -73,7 +91,7 @@ server.get('/sse', logIncomingAuth, authRequired, async (req: Request, res: Resp
7391
})
7492

7593
// MCP Handler
76-
server.post('/messages', logIncomingAuth, authRequired, async (req: Request, res: Response) => {
94+
server.post('/messages', logIncomingAuth, keycloak.protect(), async (req: Request, res: Response) => {
7795
const sessionId = req.query.sessionId as string
7896

7997
if (typeof sessionId != 'string') {
@@ -90,6 +108,15 @@ server.post('/messages', logIncomingAuth, authRequired, async (req: Request, res
90108
}
91109
})
92110

111+
// oauth
112+
server.get('/.well-known/oauth-protected-resource', async (_: Request, res: Response) => {
113+
const baseURL = `https://api.subtype.space`
114+
res.json({
115+
resource: baseURL,
116+
authorization_servers: [`https://auth.subtype.space`],
117+
})
118+
})
119+
93120
server.listen(PORT, () => {
94121
logger.info(`Using log level: ${process.env.LOG_LEVEL || 'info'}`)
95122
logger.info('Using API version:', ACTIVE_VERSION)

src/types/express/index.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'express'
2+
3+
declare module 'express-serve-static-core' {
4+
interface Request {
5+
kauth?: {
6+
grant: {
7+
access_token: {
8+
content: {
9+
preferred_username?: string
10+
clientId?: string
11+
sub: string
12+
realm_access?: {
13+
roles?: string[]
14+
}
15+
[key: string]: any
16+
}
17+
}
18+
}
19+
}
20+
}
21+
}

src/utils/auth.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,46 @@
1+
// TODO: fine-grained tool access
2+
// https://developers.cloudflare.com/agents/model-context-protocol/authorization/
3+
14
import { expressjwt } from 'express-jwt'
25
import { Request, Response, NextFunction } from 'express'
36
import { logger } from './logger.js'
47

58
const JWT_SECRET = process.env.JWT_SECRET
69

10+
// all of this may change due to keycloak oauth support
711
export function logIncomingAuth(req: Request, res: Response, next: NextFunction) {
8-
logger.debug('Incoming headers:', req.headers)
12+
logger.info(
13+
`[AUTH] Connection from ${req.headers['cf-connecting-ip'] ?? req.ip} - ${req.headers['cf-ipcountry'] ?? 'unknown country'}`
14+
)
915

16+
// For "manual" JWT
1017
const authHeader = req.headers.authorization
1118
if (!authHeader?.startsWith('Bearer ')) {
12-
logger.warn('No auth header or bad format')
19+
logger.warn('[AUTH] No auth header or bad format')
20+
logger.debug('[AUTH] Incoming headers:', req.headers)
1321
} else {
1422
const token = authHeader.split(' ')[1]
15-
logger.debug('Inspecting token:', token)
23+
logger.debug('[AUTH] Inspecting token:', token)
24+
}
25+
26+
// For Keycloak-specific OIDC
27+
// TODO -- users may not want Keycloak, make this optional/toggle disable
28+
const user = req.kauth?.grant?.access_token?.content
29+
if (user) {
30+
logger.info(`[AUTH] Authenticated as ${user.preferred_username ?? user.clientId ?? 'unknown'} (${user.sub})`)
31+
} else {
32+
logger.warn('[AUTH] No grant found on request')
33+
logger.warn('[AUTH] Possible auth failure')
1634
}
1735

1836
next()
1937
}
2038

39+
// Deprecated exports, or present to support basic JWT authentication w/ shared secret
2140
export const authRequired = expressjwt({
2241
secret: JWT_SECRET!,
42+
issuer: 'https://auth.subtype.space',
43+
audience: 'https://api.subtype.space',
2344
algorithms: ['HS256'],
2445
credentialsRequired: true,
2546
})

src/utils/rateLimiter.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,29 @@ import { Request } from 'express-jwt' // this ideally should come after the cust
33
import { logger } from './logger.js'
44
import { Response } from 'express'
55

6+
function isAuthenticated(req: Request): boolean {
7+
return Boolean(req.auth?.sub || req?.kauth?.grant?.access_token?.content?.sub)
8+
}
9+
10+
function getSessionId(req: Request): string {
11+
return req.auth?.sub ? `session-${req.auth.sub}` : (req.kauth?.grant?.access_token?.content?.sub ?? `anon-session-${req.ip}`)
12+
}
13+
614
export const rateLimiter: RateLimitRequestHandler = rateLimit({
715
windowMs: 60 * 1000,
816
limit: (req: Request): number => {
9-
logger.debug(`Rate limit check ${req.auth ? 'authenticated' : 'anon'} - ${req.headers['cf-connecting-ip'] ?? req.ip}`)
10-
return req.auth ? 60 : 5 // 60 req/min for auth, 5 for anon
17+
const ip = req.headers['cf-connecting-ip'] ?? req.ip
18+
const authed = isAuthenticated(req)
19+
logger.info(`Rate limit check for ${authed ? 'authenticated' : 'anon'} - ${ip}`)
20+
return authed ? 60 : 5 // 60 req/min for auth, 5 for anon
1121
},
1222
keyGenerator: (req: Request): string => {
13-
return req.auth?.sub ? `session-${req.auth.sub}` : req.ip!
23+
const ip = req.headers['cf-connecting-ip'] ?? req.ip
24+
return getSessionId(req) ?? ip
1425
},
1526
handler: (req: Request, res: Response) => {
16-
logger.warn('Rate limiting IP address:', req.ip)
27+
const ip = req.headers['cf-connecting-ip'] ?? req.ip
28+
logger.warn('Rate limiting IP address:', ip)
1729
res.status(429).send({ message: 'Rate limited' })
1830
},
1931
})

src/v1/mcp/registerTools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2+
import { CallToolRequest, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
23
import { z } from 'zod'
34
import { getAlerts, getForecast } from './weather.js'
45
import { getStockDetails } from './stocks.js'
56
import { getIncidents, getStationInfo, getBusInfo } from './metro.js'
67
import { logger } from '../../utils/logger.js'
78

9+
810
export function registerTools(mcpServer: McpServer) {
911
mcpServer.tool(
1012
'get-alerts',

tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"strict": true,
99
"esModuleInterop": true,
1010
"skipLibCheck": true,
11-
"forceConsistentCasingInFileNames": false
11+
"forceConsistentCasingInFileNames": false,
12+
"typeRoots": ["./types", "./node_modules/@types"]
1213
},
1314
"include": ["src/**/*"],
1415
"exclude": ["node_modules"]

0 commit comments

Comments
 (0)