@platf/bridge 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/gateways/statefulBridge.d.ts +22 -0
- package/dist/gateways/statefulBridge.js +211 -0
- package/dist/gateways/statefulBridge.js.map +1 -0
- package/dist/gateways/statelessBridge.d.ts +22 -0
- package/dist/gateways/statelessBridge.js +234 -0
- package/dist/gateways/statelessBridge.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +117 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/authMiddleware.d.ts +12 -0
- package/dist/lib/authMiddleware.js +41 -0
- package/dist/lib/authMiddleware.js.map +1 -0
- package/dist/lib/cors.d.ts +3 -0
- package/dist/lib/cors.js +29 -0
- package/dist/lib/cors.js.map +1 -0
- package/dist/lib/discoveryRoutes.d.ts +14 -0
- package/dist/lib/discoveryRoutes.js +86 -0
- package/dist/lib/discoveryRoutes.js.map +1 -0
- package/dist/lib/getLogger.d.ts +3 -0
- package/dist/lib/getLogger.js +34 -0
- package/dist/lib/getLogger.js.map +1 -0
- package/dist/lib/headers.d.ts +2 -0
- package/dist/lib/headers.js +19 -0
- package/dist/lib/headers.js.map +1 -0
- package/dist/lib/onSignals.d.ts +6 -0
- package/dist/lib/onSignals.js +16 -0
- package/dist/lib/onSignals.js.map +1 -0
- package/dist/lib/sessionAccessCounter.d.ts +11 -0
- package/dist/lib/sessionAccessCounter.js +72 -0
- package/dist/lib/sessionAccessCounter.js.map +1 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +41 -0
- package/src/gateways/statefulBridge.ts +271 -0
- package/src/gateways/statelessBridge.ts +310 -0
- package/src/index.ts +121 -0
- package/src/lib/authMiddleware.ts +58 -0
- package/src/lib/cors.ts +30 -0
- package/src/lib/discoveryRoutes.ts +95 -0
- package/src/lib/getLogger.ts +49 -0
- package/src/lib/headers.ts +26 -0
- package/src/lib/onSignals.ts +24 -0
- package/src/lib/sessionAccessCounter.ts +98 -0
- package/src/types.ts +12 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 JWT auth middleware for the bridge.
|
|
3
|
+
*
|
|
4
|
+
* - Fetches JWKS from `{issuer}/jwks` using jose's createRemoteJWKSet (auto-caches).
|
|
5
|
+
* - Validates Bearer token: signature, `iss`, `exp`.
|
|
6
|
+
* - On failure returns 401 with RFC 9728–compliant `WWW-Authenticate` header
|
|
7
|
+
* pointing at the OAuth Protected Resource Metadata document.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
|
11
|
+
import type { RequestHandler } from 'express'
|
|
12
|
+
import type { AuthConfig, Logger } from '../types.js'
|
|
13
|
+
|
|
14
|
+
/** Build a reusable auth middleware for the given auth config */
|
|
15
|
+
export function createAuthMiddleware(auth: AuthConfig, logger: Logger): RequestHandler {
|
|
16
|
+
const jwksUri = new URL('/jwks', auth.issuer)
|
|
17
|
+
const JWKS = createRemoteJWKSet(jwksUri)
|
|
18
|
+
|
|
19
|
+
return async (req, res, next) => {
|
|
20
|
+
const authHeader = req.headers.authorization
|
|
21
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
22
|
+
return unauthorized(req, res, auth, 'missing or malformed Authorization header')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const token = authHeader.slice(7)
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { payload } = await jwtVerify(token, JWKS, {
|
|
29
|
+
issuer: auth.issuer,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Attach token payload to request for downstream use
|
|
33
|
+
;(req as any).tokenPayload = payload
|
|
34
|
+
next()
|
|
35
|
+
} catch (err: any) {
|
|
36
|
+
logger.error('[auth] JWT verification failed:', err.message ?? err)
|
|
37
|
+
return unauthorized(req, res, auth, 'invalid_token')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unauthorized(
|
|
43
|
+
req: import('express').Request,
|
|
44
|
+
res: import('express').Response,
|
|
45
|
+
auth: AuthConfig,
|
|
46
|
+
error: string,
|
|
47
|
+
) {
|
|
48
|
+
// RFC 9728: include resource_metadata URI in WWW-Authenticate
|
|
49
|
+
const scheme = req.protocol
|
|
50
|
+
const host = req.get('host')
|
|
51
|
+
const resourceMetadataUri = `${scheme}://${host}/.well-known/oauth-protected-resource`
|
|
52
|
+
|
|
53
|
+
res.setHeader(
|
|
54
|
+
'WWW-Authenticate',
|
|
55
|
+
`Bearer realm="mcp", error="${error}", resource_metadata="${resourceMetadataUri}"`,
|
|
56
|
+
)
|
|
57
|
+
res.status(401).json({ error: 'unauthorized', message: error })
|
|
58
|
+
}
|
package/src/lib/cors.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CorsOptions } from 'cors'
|
|
2
|
+
|
|
3
|
+
export function parseCorsOrigin(
|
|
4
|
+
corsValues: (string | number)[] | undefined,
|
|
5
|
+
): CorsOptions['origin'] | false {
|
|
6
|
+
if (!corsValues) return false
|
|
7
|
+
if (corsValues.length === 0) return '*'
|
|
8
|
+
|
|
9
|
+
const origins = corsValues.map((item) => `${item}`)
|
|
10
|
+
if (origins.includes('*')) return '*'
|
|
11
|
+
|
|
12
|
+
return origins.map((origin) => {
|
|
13
|
+
if (/^\/.*\/$/.test(origin)) {
|
|
14
|
+
const pattern = origin.slice(1, -1)
|
|
15
|
+
try {
|
|
16
|
+
return new RegExp(pattern)
|
|
17
|
+
} catch {
|
|
18
|
+
return origin
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return origin
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function serializeCorsOrigin(corsOrigin: CorsOptions['origin']): string {
|
|
26
|
+
return JSON.stringify(corsOrigin, (_key, value) => {
|
|
27
|
+
if (value instanceof RegExp) return value.toString()
|
|
28
|
+
return value
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 discovery endpoints for the bridge.
|
|
3
|
+
*
|
|
4
|
+
* When auth is enabled these routes expose:
|
|
5
|
+
* - GET /.well-known/oauth-protected-resource[/*] (RFC 9728)
|
|
6
|
+
* - GET /.well-known/oauth-authorization-server[/*] (RFC 8414 — proxied from issuer)
|
|
7
|
+
* - POST /oauth/register (Pseudo-DCR — RFC 7591)
|
|
8
|
+
*
|
|
9
|
+
* These endpoints are unauthenticated — they must be accessible to
|
|
10
|
+
* any client performing OAuth discovery before obtaining a token.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Router, type Request, type Response } from 'express'
|
|
14
|
+
import type { AuthConfig, Logger } from '../types.js'
|
|
15
|
+
|
|
16
|
+
export function createDiscoveryRouter(auth: AuthConfig, logger: Logger): Router {
|
|
17
|
+
const router = Router()
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* RFC 9728 — OAuth Protected Resource Metadata
|
|
21
|
+
*
|
|
22
|
+
* Tells the client:
|
|
23
|
+
* - which authorization server protects this resource
|
|
24
|
+
* - which scopes are available
|
|
25
|
+
* - where to find the authorization server metadata
|
|
26
|
+
*
|
|
27
|
+
* Handles both root and path-suffixed variants (e.g. /.well-known/oauth-protected-resource/mcp)
|
|
28
|
+
* as required by the MCP SDK for path-based resource discovery.
|
|
29
|
+
*/
|
|
30
|
+
router.get('/.well-known/oauth-protected-resource*', (req: Request, res: Response) => {
|
|
31
|
+
const scheme = req.protocol
|
|
32
|
+
const host = req.get('host')
|
|
33
|
+
const resourceMetadata = {
|
|
34
|
+
resource: `${scheme}://${host}/`,
|
|
35
|
+
authorization_servers: [auth.issuer],
|
|
36
|
+
scopes_supported: ['openid', 'profile', 'email'],
|
|
37
|
+
bearer_methods_supported: ['header'],
|
|
38
|
+
}
|
|
39
|
+
res.json(resourceMetadata)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* RFC 8414 — Authorization Server Metadata (proxied)
|
|
44
|
+
*
|
|
45
|
+
* We proxy the issuer's .well-known/oauth-authorization-server document
|
|
46
|
+
* and patch `registration_endpoint` to point to our local pseudo-DCR.
|
|
47
|
+
*
|
|
48
|
+
* Handles both root and path-suffixed variants.
|
|
49
|
+
*/
|
|
50
|
+
router.get('/.well-known/oauth-authorization-server*', async (req: Request, res: Response) => {
|
|
51
|
+
try {
|
|
52
|
+
const metadataUrl = `${auth.issuer}/.well-known/oauth-authorization-server`
|
|
53
|
+
const upstream = await fetch(metadataUrl)
|
|
54
|
+
|
|
55
|
+
if (!upstream.ok) {
|
|
56
|
+
logger.error(`[discovery] Failed to fetch AS metadata: ${upstream.status}`)
|
|
57
|
+
return res.status(502).json({ error: 'upstream_error' })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const metadata = (await upstream.json()) as Record<string, unknown>
|
|
61
|
+
|
|
62
|
+
// Patch registration_endpoint to point to our pseudo-DCR
|
|
63
|
+
const scheme = req.protocol
|
|
64
|
+
const host = req.get('host')
|
|
65
|
+
metadata.registration_endpoint = `${scheme}://${host}/oauth/register`
|
|
66
|
+
|
|
67
|
+
res.json(metadata)
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
logger.error('[discovery] Error proxying AS metadata:', err.message ?? err)
|
|
70
|
+
res.status(502).json({ error: 'upstream_error' })
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pseudo–Dynamic Client Registration (RFC 7591)
|
|
76
|
+
*
|
|
77
|
+
* Always returns the same pre-registered client_id — no new client
|
|
78
|
+
* is actually created. This lets standards-based OAuth clients
|
|
79
|
+
* (e.g., VS Code Copilot) discover the correct client_id through
|
|
80
|
+
* the normal DCR flow without requiring out-of-band configuration.
|
|
81
|
+
*/
|
|
82
|
+
router.post('/oauth/register', (_req: Request, res: Response) => {
|
|
83
|
+
res.status(201).json({
|
|
84
|
+
client_id: auth.clientId,
|
|
85
|
+
client_name: 'platf-bridge',
|
|
86
|
+
// No client_secret — public client using PKCE
|
|
87
|
+
token_endpoint_auth_method: 'none',
|
|
88
|
+
grant_types: ['authorization_code'],
|
|
89
|
+
response_types: ['code'],
|
|
90
|
+
redirect_uris: [], // Client provides its own redirect_uri
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return router
|
|
95
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import util from 'node:util'
|
|
2
|
+
import type { Logger } from '../types.js'
|
|
3
|
+
|
|
4
|
+
const defaultFormatArgs = (args: unknown[]) => args
|
|
5
|
+
|
|
6
|
+
const log =
|
|
7
|
+
({ formatArgs = defaultFormatArgs }: { formatArgs?: typeof defaultFormatArgs } = {}) =>
|
|
8
|
+
(...args: unknown[]) =>
|
|
9
|
+
console.log('[platf-bridge]', ...formatArgs(args))
|
|
10
|
+
|
|
11
|
+
const logStderr =
|
|
12
|
+
({ formatArgs = defaultFormatArgs }: { formatArgs?: typeof defaultFormatArgs } = {}) =>
|
|
13
|
+
(...args: unknown[]) =>
|
|
14
|
+
console.error('[platf-bridge]', ...formatArgs(args))
|
|
15
|
+
|
|
16
|
+
const noneLogger: Logger = {
|
|
17
|
+
info: () => {},
|
|
18
|
+
error: () => {},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const infoLogger: Logger = {
|
|
22
|
+
info: log(),
|
|
23
|
+
error: logStderr(),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const debugFormatArgs = (args: unknown[]) =>
|
|
27
|
+
args.map((arg) => {
|
|
28
|
+
if (typeof arg === 'object') {
|
|
29
|
+
return util.inspect(arg, {
|
|
30
|
+
depth: null,
|
|
31
|
+
colors: process.stderr.isTTY,
|
|
32
|
+
compact: false,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
return arg
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const debugLogger: Logger = {
|
|
39
|
+
info: log({ formatArgs: debugFormatArgs }),
|
|
40
|
+
error: logStderr({ formatArgs: debugFormatArgs }),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type LogLevel = 'none' | 'info' | 'debug'
|
|
44
|
+
|
|
45
|
+
export function getLogger(logLevel: LogLevel): Logger {
|
|
46
|
+
if (logLevel === 'none') return noneLogger
|
|
47
|
+
if (logLevel === 'debug') return debugLogger
|
|
48
|
+
return infoLogger
|
|
49
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Logger } from '../types.js'
|
|
2
|
+
|
|
3
|
+
export function parseHeaders(
|
|
4
|
+
rawHeaders: (string | number)[],
|
|
5
|
+
logger: Logger,
|
|
6
|
+
): Record<string, string> {
|
|
7
|
+
return rawHeaders.reduce<Record<string, string>>((acc, rawHeader) => {
|
|
8
|
+
const header = `${rawHeader}`
|
|
9
|
+
const colonIndex = header.indexOf(':')
|
|
10
|
+
if (colonIndex === -1) {
|
|
11
|
+
logger.error(`Invalid header format: ${header}, ignoring`)
|
|
12
|
+
return acc
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const key = header.slice(0, colonIndex).trim()
|
|
16
|
+
const value = header.slice(colonIndex + 1).trim()
|
|
17
|
+
|
|
18
|
+
if (!key || !value) {
|
|
19
|
+
logger.error(`Invalid header format: ${header}, ignoring`)
|
|
20
|
+
return acc
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
acc[key] = value
|
|
24
|
+
return acc
|
|
25
|
+
}, {})
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Logger } from '../types.js'
|
|
2
|
+
|
|
3
|
+
export interface OnSignalsOptions {
|
|
4
|
+
logger: Logger
|
|
5
|
+
cleanup?: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function onSignals({ logger, cleanup }: OnSignalsOptions): void {
|
|
9
|
+
const handleSignal = (signal: string) => {
|
|
10
|
+
logger.info(`Caught ${signal}. Exiting...`)
|
|
11
|
+
cleanup?.()
|
|
12
|
+
process.exit(0)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
process.on('SIGINT', () => handleSignal('SIGINT'))
|
|
16
|
+
process.on('SIGTERM', () => handleSignal('SIGTERM'))
|
|
17
|
+
process.on('SIGHUP', () => handleSignal('SIGHUP'))
|
|
18
|
+
|
|
19
|
+
process.stdin.on('close', () => {
|
|
20
|
+
logger.info('stdin closed. Exiting...')
|
|
21
|
+
cleanup?.()
|
|
22
|
+
process.exit(0)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Logger } from '../types.js'
|
|
2
|
+
|
|
3
|
+
export class SessionAccessCounter {
|
|
4
|
+
private sessions = new Map<
|
|
5
|
+
string,
|
|
6
|
+
{ accessCount: number } | { timeout: Timer }
|
|
7
|
+
>()
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly timeoutMs: number,
|
|
11
|
+
private readonly cleanup: (sessionId: string) => unknown,
|
|
12
|
+
private readonly logger: Logger,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
inc(sessionId: string, reason: string) {
|
|
16
|
+
this.logger.info(`SessionAccessCounter.inc() ${sessionId}, caused by ${reason}`)
|
|
17
|
+
|
|
18
|
+
const session = this.sessions.get(sessionId)
|
|
19
|
+
|
|
20
|
+
if (!session) {
|
|
21
|
+
this.logger.info(`Session access count 0 -> 1 for ${sessionId} (new session)`)
|
|
22
|
+
this.sessions.set(sessionId, { accessCount: 1 })
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if ('timeout' in session) {
|
|
27
|
+
this.logger.info(`Session access count 0 -> 1, clearing cleanup timeout for ${sessionId}`)
|
|
28
|
+
clearTimeout(session.timeout)
|
|
29
|
+
this.sessions.set(sessionId, { accessCount: 1 })
|
|
30
|
+
} else {
|
|
31
|
+
this.logger.info(
|
|
32
|
+
`Session access count ${session.accessCount} -> ${session.accessCount + 1} for ${sessionId}`,
|
|
33
|
+
)
|
|
34
|
+
session.accessCount++
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
dec(sessionId: string, reason: string) {
|
|
39
|
+
this.logger.info(`SessionAccessCounter.dec() ${sessionId}, caused by ${reason}`)
|
|
40
|
+
|
|
41
|
+
const session = this.sessions.get(sessionId)
|
|
42
|
+
|
|
43
|
+
if (!session) {
|
|
44
|
+
this.logger.error(`Called dec() on non-existent session ${sessionId}, ignoring`)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ('timeout' in session) {
|
|
49
|
+
this.logger.error(
|
|
50
|
+
`Called dec() on session ${sessionId} that is already pending cleanup, ignoring`,
|
|
51
|
+
)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (session.accessCount <= 0) {
|
|
56
|
+
throw new Error(`Invalid access count ${session.accessCount} for session ${sessionId}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
session.accessCount--
|
|
60
|
+
this.logger.info(
|
|
61
|
+
`Session access count ${session.accessCount + 1} -> ${session.accessCount} for ${sessionId}`,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if (session.accessCount === 0) {
|
|
65
|
+
this.logger.info(
|
|
66
|
+
`Session access count reached 0, setting cleanup timeout for ${sessionId}`,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
this.sessions.set(sessionId, {
|
|
70
|
+
timeout: setTimeout(() => {
|
|
71
|
+
this.logger.info(`Session ${sessionId} timed out, cleaning up`)
|
|
72
|
+
this.sessions.delete(sessionId)
|
|
73
|
+
this.cleanup(sessionId)
|
|
74
|
+
}, this.timeoutMs),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
clear(sessionId: string, runCleanup: boolean, reason: string) {
|
|
80
|
+
this.logger.info(`SessionAccessCounter.clear() ${sessionId}, caused by ${reason}`)
|
|
81
|
+
|
|
82
|
+
const session = this.sessions.get(sessionId)
|
|
83
|
+
if (!session) {
|
|
84
|
+
this.logger.info(`Attempted to clear non-existent session ${sessionId}`)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if ('timeout' in session) {
|
|
89
|
+
clearTimeout(session.timeout)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.sessions.delete(sessionId)
|
|
93
|
+
|
|
94
|
+
if (runCleanup) {
|
|
95
|
+
this.cleanup(sessionId)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface Logger {
|
|
2
|
+
info: (...args: unknown[]) => void
|
|
3
|
+
error: (...args: unknown[]) => void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** OAuth auth config passed when --authIssuer is set */
|
|
7
|
+
export interface AuthConfig {
|
|
8
|
+
/** OAuth issuer URL (e.g. https://auth.platf.ai) */
|
|
9
|
+
issuer: string
|
|
10
|
+
/** OAuth client_id for this bridge instance */
|
|
11
|
+
clientId: string
|
|
12
|
+
}
|