@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/gateways/statefulBridge.d.ts +22 -0
  4. package/dist/gateways/statefulBridge.js +211 -0
  5. package/dist/gateways/statefulBridge.js.map +1 -0
  6. package/dist/gateways/statelessBridge.d.ts +22 -0
  7. package/dist/gateways/statelessBridge.js +234 -0
  8. package/dist/gateways/statelessBridge.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +117 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/lib/authMiddleware.d.ts +12 -0
  13. package/dist/lib/authMiddleware.js +41 -0
  14. package/dist/lib/authMiddleware.js.map +1 -0
  15. package/dist/lib/cors.d.ts +3 -0
  16. package/dist/lib/cors.js +29 -0
  17. package/dist/lib/cors.js.map +1 -0
  18. package/dist/lib/discoveryRoutes.d.ts +14 -0
  19. package/dist/lib/discoveryRoutes.js +86 -0
  20. package/dist/lib/discoveryRoutes.js.map +1 -0
  21. package/dist/lib/getLogger.d.ts +3 -0
  22. package/dist/lib/getLogger.js +34 -0
  23. package/dist/lib/getLogger.js.map +1 -0
  24. package/dist/lib/headers.d.ts +2 -0
  25. package/dist/lib/headers.js +19 -0
  26. package/dist/lib/headers.js.map +1 -0
  27. package/dist/lib/onSignals.d.ts +6 -0
  28. package/dist/lib/onSignals.js +16 -0
  29. package/dist/lib/onSignals.js.map +1 -0
  30. package/dist/lib/sessionAccessCounter.d.ts +11 -0
  31. package/dist/lib/sessionAccessCounter.js +72 -0
  32. package/dist/lib/sessionAccessCounter.js.map +1 -0
  33. package/dist/types.d.ts +11 -0
  34. package/dist/types.js +2 -0
  35. package/dist/types.js.map +1 -0
  36. package/package.json +41 -0
  37. package/src/gateways/statefulBridge.ts +271 -0
  38. package/src/gateways/statelessBridge.ts +310 -0
  39. package/src/index.ts +121 -0
  40. package/src/lib/authMiddleware.ts +58 -0
  41. package/src/lib/cors.ts +30 -0
  42. package/src/lib/discoveryRoutes.ts +95 -0
  43. package/src/lib/getLogger.ts +49 -0
  44. package/src/lib/headers.ts +26 -0
  45. package/src/lib/onSignals.ts +24 -0
  46. package/src/lib/sessionAccessCounter.ts +98 -0
  47. 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
+ }
@@ -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
+ }