sub-bridge 1.0.0

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 (163) hide show
  1. package/.cursor/commands/mcp-only.md +1 -0
  2. package/.github/workflows/npm-publish.yml +33 -0
  3. package/.github/workflows/pages.yml +40 -0
  4. package/.github/workflows/release-please.yml +21 -0
  5. package/.release-please-manifest.json +3 -0
  6. package/CHANGELOG.md +8 -0
  7. package/DEVELOPMENT.md +31 -0
  8. package/LICENSE +21 -0
  9. package/README.md +87 -0
  10. package/api/index.ts +12 -0
  11. package/bun.lock +102 -0
  12. package/dist/auth/oauth-flow.d.ts +24 -0
  13. package/dist/auth/oauth-flow.d.ts.map +1 -0
  14. package/dist/auth/oauth-flow.js +184 -0
  15. package/dist/auth/oauth-flow.js.map +1 -0
  16. package/dist/auth/oauth-manager.d.ts +13 -0
  17. package/dist/auth/oauth-manager.d.ts.map +1 -0
  18. package/dist/auth/oauth-manager.js +25 -0
  19. package/dist/auth/oauth-manager.js.map +1 -0
  20. package/dist/auth/provider.d.ts +42 -0
  21. package/dist/auth/provider.d.ts.map +1 -0
  22. package/dist/auth/provider.js +270 -0
  23. package/dist/auth/provider.js.map +1 -0
  24. package/dist/cli.d.ts +3 -0
  25. package/dist/cli.d.ts.map +1 -0
  26. package/dist/cli.js +91 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/mcp/proxy.d.ts +16 -0
  29. package/dist/mcp/proxy.d.ts.map +1 -0
  30. package/dist/mcp/proxy.js +85 -0
  31. package/dist/mcp/proxy.js.map +1 -0
  32. package/dist/mcp.d.ts +3 -0
  33. package/dist/mcp.d.ts.map +1 -0
  34. package/dist/mcp.js +50 -0
  35. package/dist/mcp.js.map +1 -0
  36. package/dist/routes/auth.d.ts +6 -0
  37. package/dist/routes/auth.d.ts.map +1 -0
  38. package/dist/routes/auth.js +149 -0
  39. package/dist/routes/auth.js.map +1 -0
  40. package/dist/routes/chat.d.ts +6 -0
  41. package/dist/routes/chat.d.ts.map +1 -0
  42. package/dist/routes/chat.js +808 -0
  43. package/dist/routes/chat.js.map +1 -0
  44. package/dist/routes/tunnels.d.ts +7 -0
  45. package/dist/routes/tunnels.d.ts.map +1 -0
  46. package/dist/routes/tunnels.js +44 -0
  47. package/dist/routes/tunnels.js.map +1 -0
  48. package/dist/server.d.ts +25 -0
  49. package/dist/server.d.ts.map +1 -0
  50. package/dist/server.js +157 -0
  51. package/dist/server.js.map +1 -0
  52. package/dist/tunnel/providers/cloudflare.d.ts +9 -0
  53. package/dist/tunnel/providers/cloudflare.d.ts.map +1 -0
  54. package/dist/tunnel/providers/cloudflare.js +47 -0
  55. package/dist/tunnel/providers/cloudflare.js.map +1 -0
  56. package/dist/tunnel/providers/index.d.ts +4 -0
  57. package/dist/tunnel/providers/index.d.ts.map +1 -0
  58. package/dist/tunnel/providers/index.js +13 -0
  59. package/dist/tunnel/providers/index.js.map +1 -0
  60. package/dist/tunnel/providers/ngrok.d.ts +10 -0
  61. package/dist/tunnel/providers/ngrok.d.ts.map +1 -0
  62. package/dist/tunnel/providers/ngrok.js +52 -0
  63. package/dist/tunnel/providers/ngrok.js.map +1 -0
  64. package/dist/tunnel/providers/tailscale.d.ts +10 -0
  65. package/dist/tunnel/providers/tailscale.d.ts.map +1 -0
  66. package/dist/tunnel/providers/tailscale.js +48 -0
  67. package/dist/tunnel/providers/tailscale.js.map +1 -0
  68. package/dist/tunnel/registry.d.ts +14 -0
  69. package/dist/tunnel/registry.d.ts.map +1 -0
  70. package/dist/tunnel/registry.js +86 -0
  71. package/dist/tunnel/registry.js.map +1 -0
  72. package/dist/tunnel/types.d.ts +26 -0
  73. package/dist/tunnel/types.d.ts.map +1 -0
  74. package/dist/tunnel/types.js +6 -0
  75. package/dist/tunnel/types.js.map +1 -0
  76. package/dist/tunnel/utils.d.ts +18 -0
  77. package/dist/tunnel/utils.d.ts.map +1 -0
  78. package/dist/tunnel/utils.js +57 -0
  79. package/dist/tunnel/utils.js.map +1 -0
  80. package/dist/types.d.ts +52 -0
  81. package/dist/types.d.ts.map +1 -0
  82. package/dist/types.js +4 -0
  83. package/dist/types.js.map +1 -0
  84. package/dist/utils/anthropic-to-openai-converter.d.ts +103 -0
  85. package/dist/utils/anthropic-to-openai-converter.d.ts.map +1 -0
  86. package/dist/utils/anthropic-to-openai-converter.js +376 -0
  87. package/dist/utils/anthropic-to-openai-converter.js.map +1 -0
  88. package/dist/utils/chat-to-responses.d.ts +59 -0
  89. package/dist/utils/chat-to-responses.d.ts.map +1 -0
  90. package/dist/utils/chat-to-responses.js +395 -0
  91. package/dist/utils/chat-to-responses.js.map +1 -0
  92. package/dist/utils/chatgpt-instructions.d.ts +3 -0
  93. package/dist/utils/chatgpt-instructions.d.ts.map +1 -0
  94. package/dist/utils/chatgpt-instructions.js +12 -0
  95. package/dist/utils/chatgpt-instructions.js.map +1 -0
  96. package/dist/utils/cli-args.d.ts +3 -0
  97. package/dist/utils/cli-args.d.ts.map +1 -0
  98. package/dist/utils/cli-args.js +10 -0
  99. package/dist/utils/cli-args.js.map +1 -0
  100. package/dist/utils/cors-bypass.d.ts +4 -0
  101. package/dist/utils/cors-bypass.d.ts.map +1 -0
  102. package/dist/utils/cors-bypass.js +30 -0
  103. package/dist/utils/cors-bypass.js.map +1 -0
  104. package/dist/utils/cursor-byok-bypass.d.ts +37 -0
  105. package/dist/utils/cursor-byok-bypass.d.ts.map +1 -0
  106. package/dist/utils/cursor-byok-bypass.js +53 -0
  107. package/dist/utils/cursor-byok-bypass.js.map +1 -0
  108. package/dist/utils/logger.d.ts +19 -0
  109. package/dist/utils/logger.d.ts.map +1 -0
  110. package/dist/utils/logger.js +192 -0
  111. package/dist/utils/logger.js.map +1 -0
  112. package/dist/utils/port.d.ts +27 -0
  113. package/dist/utils/port.d.ts.map +1 -0
  114. package/dist/utils/port.js +78 -0
  115. package/dist/utils/port.js.map +1 -0
  116. package/dist/utils/setup-instructions.d.ts +10 -0
  117. package/dist/utils/setup-instructions.d.ts.map +1 -0
  118. package/dist/utils/setup-instructions.js +49 -0
  119. package/dist/utils/setup-instructions.js.map +1 -0
  120. package/env.example +25 -0
  121. package/index.html +992 -0
  122. package/package.json +57 -0
  123. package/public/.nojekyll +0 -0
  124. package/public/assets/chat.png +0 -0
  125. package/public/assets/demo.gif +0 -0
  126. package/public/assets/demo.mp4 +0 -0
  127. package/public/assets/setup.png +0 -0
  128. package/public/assets/ui.png +0 -0
  129. package/public/index.html +292 -0
  130. package/release-please-config.json +10 -0
  131. package/src/auth/provider.ts +412 -0
  132. package/src/cli.ts +97 -0
  133. package/src/mcp/proxy.ts +64 -0
  134. package/src/mcp.ts +56 -0
  135. package/src/oauth/authorize.ts +270 -0
  136. package/src/oauth/crypto.ts +198 -0
  137. package/src/oauth/dcr.ts +129 -0
  138. package/src/oauth/metadata.ts +40 -0
  139. package/src/oauth/token.ts +173 -0
  140. package/src/routes/auth.ts +149 -0
  141. package/src/routes/chat.ts +983 -0
  142. package/src/routes/oauth.ts +220 -0
  143. package/src/routes/tunnels.ts +45 -0
  144. package/src/server.ts +204 -0
  145. package/src/tunnel/providers/cloudflare.ts +50 -0
  146. package/src/tunnel/providers/index.ts +7 -0
  147. package/src/tunnel/providers/ngrok.ts +56 -0
  148. package/src/tunnel/providers/tailscale.ts +50 -0
  149. package/src/tunnel/registry.ts +96 -0
  150. package/src/tunnel/types.ts +32 -0
  151. package/src/tunnel/utils.ts +59 -0
  152. package/src/types.ts +55 -0
  153. package/src/utils/anthropic-to-openai-converter.ts +578 -0
  154. package/src/utils/chat-to-responses.ts +512 -0
  155. package/src/utils/chatgpt-instructions.ts +7 -0
  156. package/src/utils/cli-args.ts +8 -0
  157. package/src/utils/cors-bypass.ts +39 -0
  158. package/src/utils/cursor-byok-bypass.ts +56 -0
  159. package/src/utils/logger.ts +174 -0
  160. package/src/utils/port.ts +99 -0
  161. package/src/utils/setup-instructions.ts +59 -0
  162. package/tsconfig.json +22 -0
  163. package/vercel.json +20 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * OAuth Authorization Endpoint
3
+ *
4
+ * Handles the /oauth/authorize endpoint. When an MCP client redirects here,
5
+ * we redirect to the landing page with OAuth params. After the user authenticates
6
+ * with Claude/ChatGPT, the frontend calls /oauth/code to get an authorization code.
7
+ */
8
+ import crypto from 'node:crypto'
9
+ import { generateAuthCode } from './crypto'
10
+ import { getClient, validateRedirectUri } from './dcr'
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ export interface AuthorizationRequest {
17
+ client_id: string
18
+ redirect_uri: string
19
+ response_type: string
20
+ state?: string
21
+ scope?: string
22
+ code_challenge?: string
23
+ code_challenge_method?: string
24
+ }
25
+
26
+ export interface PendingAuthorization {
27
+ clientId: string
28
+ redirectUri: string
29
+ state?: string
30
+ scope?: string
31
+ codeChallenge?: string
32
+ codeChallengeMethod?: string
33
+ createdAt: number
34
+ }
35
+
36
+ export interface AuthorizationCode {
37
+ code: string
38
+ clientId: string
39
+ redirectUri: string
40
+ userId: string
41
+ providers: {
42
+ claude?: {
43
+ access_token: string
44
+ refresh_token?: string
45
+ expires_at?: number
46
+ }
47
+ chatgpt?: {
48
+ access_token: string
49
+ account_id: string
50
+ }
51
+ }
52
+ codeChallenge?: string
53
+ codeChallengeMethod?: string
54
+ createdAt: number
55
+ expiresAt: number
56
+ }
57
+
58
+ // ============================================================================
59
+ // Storage (In-Memory)
60
+ // ============================================================================
61
+
62
+ const AUTH_CODE_TTL_MS = 10 * 60 * 1000 // 10 minutes
63
+
64
+ // Pending authorizations waiting for user to complete auth
65
+ const pendingAuthorizations = new Map<string, PendingAuthorization>()
66
+
67
+ // Issued authorization codes waiting for token exchange
68
+ const authorizationCodes = new Map<string, AuthorizationCode>()
69
+
70
+ // Cleanup expired entries periodically
71
+ setInterval(() => {
72
+ const now = Date.now()
73
+ for (const [id, pending] of pendingAuthorizations.entries()) {
74
+ if (now - pending.createdAt > AUTH_CODE_TTL_MS) {
75
+ pendingAuthorizations.delete(id)
76
+ }
77
+ }
78
+ for (const [code, auth] of authorizationCodes.entries()) {
79
+ if (now > auth.expiresAt) {
80
+ authorizationCodes.delete(code)
81
+ }
82
+ }
83
+ }, 60 * 1000)
84
+
85
+ // ============================================================================
86
+ // Authorization Functions
87
+ // ============================================================================
88
+
89
+ /**
90
+ * Validate and process an authorization request.
91
+ * Returns a pending authorization ID if valid, or an error.
92
+ */
93
+ export function startAuthorization(
94
+ request: AuthorizationRequest
95
+ ): { pendingId: string } | { error: string; errorDescription: string } {
96
+ // Validate response_type
97
+ if (request.response_type !== 'code') {
98
+ return {
99
+ error: 'unsupported_response_type',
100
+ errorDescription: 'Only "code" response type is supported',
101
+ }
102
+ }
103
+
104
+ // Validate client exists
105
+ const client = getClient(request.client_id)
106
+ if (!client) {
107
+ return {
108
+ error: 'invalid_client',
109
+ errorDescription: 'Client not registered. Use /oauth/register first.',
110
+ }
111
+ }
112
+
113
+ // Validate redirect_uri
114
+ if (!validateRedirectUri(request.client_id, request.redirect_uri)) {
115
+ return {
116
+ error: 'invalid_redirect_uri',
117
+ errorDescription: 'Redirect URI not registered for this client',
118
+ }
119
+ }
120
+
121
+ // Create pending authorization
122
+ const pendingId = crypto.randomBytes(16).toString('hex')
123
+ pendingAuthorizations.set(pendingId, {
124
+ clientId: request.client_id,
125
+ redirectUri: request.redirect_uri,
126
+ state: request.state,
127
+ scope: request.scope,
128
+ codeChallenge: request.code_challenge,
129
+ codeChallengeMethod: request.code_challenge_method,
130
+ createdAt: Date.now(),
131
+ })
132
+
133
+ return { pendingId }
134
+ }
135
+
136
+ /**
137
+ * Get pending authorization by ID.
138
+ */
139
+ export function getPendingAuthorization(pendingId: string): PendingAuthorization | null {
140
+ const pending = pendingAuthorizations.get(pendingId)
141
+ if (!pending) return null
142
+ if (Date.now() - pending.createdAt > AUTH_CODE_TTL_MS) {
143
+ pendingAuthorizations.delete(pendingId)
144
+ return null
145
+ }
146
+ return pending
147
+ }
148
+
149
+ /**
150
+ * Complete authorization and generate an authorization code.
151
+ * Called after user successfully authenticates with Claude/ChatGPT.
152
+ */
153
+ export function completeAuthorization(
154
+ pendingId: string,
155
+ userId: string,
156
+ providers: AuthorizationCode['providers']
157
+ ): { code: string; redirectUri: string; state?: string } | { error: string } {
158
+ const pending = pendingAuthorizations.get(pendingId)
159
+ if (!pending) {
160
+ return { error: 'Authorization session expired or not found' }
161
+ }
162
+
163
+ // Generate authorization code
164
+ const code = generateAuthCode()
165
+ const now = Date.now()
166
+
167
+ authorizationCodes.set(code, {
168
+ code,
169
+ clientId: pending.clientId,
170
+ redirectUri: pending.redirectUri,
171
+ userId,
172
+ providers,
173
+ codeChallenge: pending.codeChallenge,
174
+ codeChallengeMethod: pending.codeChallengeMethod,
175
+ createdAt: now,
176
+ expiresAt: now + AUTH_CODE_TTL_MS,
177
+ })
178
+
179
+ // Clean up pending authorization
180
+ pendingAuthorizations.delete(pendingId)
181
+
182
+ return {
183
+ code,
184
+ redirectUri: pending.redirectUri,
185
+ state: pending.state,
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Exchange authorization code for token data.
191
+ * Validates and consumes the code (one-time use).
192
+ */
193
+ export function exchangeAuthorizationCode(
194
+ code: string,
195
+ clientId: string,
196
+ redirectUri: string,
197
+ codeVerifier?: string
198
+ ): AuthorizationCode | { error: string; errorDescription: string } {
199
+ const authCode = authorizationCodes.get(code)
200
+
201
+ if (!authCode) {
202
+ return {
203
+ error: 'invalid_grant',
204
+ errorDescription: 'Authorization code not found or expired',
205
+ }
206
+ }
207
+
208
+ // Validate client_id matches
209
+ if (authCode.clientId !== clientId) {
210
+ authorizationCodes.delete(code)
211
+ return {
212
+ error: 'invalid_grant',
213
+ errorDescription: 'Client ID mismatch',
214
+ }
215
+ }
216
+
217
+ // Validate redirect_uri matches
218
+ if (authCode.redirectUri !== redirectUri) {
219
+ authorizationCodes.delete(code)
220
+ return {
221
+ error: 'invalid_grant',
222
+ errorDescription: 'Redirect URI mismatch',
223
+ }
224
+ }
225
+
226
+ // Validate PKCE if code challenge was provided
227
+ if (authCode.codeChallenge) {
228
+ if (!codeVerifier) {
229
+ authorizationCodes.delete(code)
230
+ return {
231
+ error: 'invalid_grant',
232
+ errorDescription: 'Code verifier required',
233
+ }
234
+ }
235
+
236
+ const method = authCode.codeChallengeMethod || 'plain'
237
+ let expectedChallenge: string
238
+
239
+ if (method === 'S256') {
240
+ expectedChallenge = crypto
241
+ .createHash('sha256')
242
+ .update(codeVerifier)
243
+ .digest('base64url')
244
+ } else {
245
+ expectedChallenge = codeVerifier
246
+ }
247
+
248
+ if (expectedChallenge !== authCode.codeChallenge) {
249
+ authorizationCodes.delete(code)
250
+ return {
251
+ error: 'invalid_grant',
252
+ errorDescription: 'Code verifier mismatch',
253
+ }
254
+ }
255
+ }
256
+
257
+ // Check expiration
258
+ if (Date.now() > authCode.expiresAt) {
259
+ authorizationCodes.delete(code)
260
+ return {
261
+ error: 'invalid_grant',
262
+ errorDescription: 'Authorization code expired',
263
+ }
264
+ }
265
+
266
+ // Consume the code (one-time use)
267
+ authorizationCodes.delete(code)
268
+
269
+ return authCode
270
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Cryptographic utilities for MCP OAuth token encryption
3
+ *
4
+ * Uses AES-256-GCM for authenticated encryption of upstream credentials.
5
+ * Server secret is generated on first run and stored locally.
6
+ */
7
+ import crypto from 'node:crypto'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import os from 'node:os'
11
+
12
+ // ============================================================================
13
+ // Configuration
14
+ // ============================================================================
15
+
16
+ const SECRET_DIR = path.join(os.homedir(), '.sub-bridge')
17
+ const SECRET_FILE = path.join(SECRET_DIR, 'secret.key')
18
+ const ALGORITHM = 'aes-256-gcm'
19
+ const IV_LENGTH = 12 // GCM recommended IV length
20
+ const TAG_LENGTH = 16 // GCM auth tag length
21
+
22
+ // ============================================================================
23
+ // Server Secret Management
24
+ // ============================================================================
25
+
26
+ let cachedSecret: Buffer | null = null
27
+
28
+ /**
29
+ * Get or generate the server encryption secret.
30
+ * Secret is 32 bytes (256 bits) for AES-256.
31
+ */
32
+ export function getServerSecret(): Buffer {
33
+ if (cachedSecret) return cachedSecret
34
+
35
+ // Try to read existing secret
36
+ try {
37
+ if (fs.existsSync(SECRET_FILE)) {
38
+ const secret = fs.readFileSync(SECRET_FILE)
39
+ if (secret.length === 32) {
40
+ cachedSecret = secret
41
+ return cachedSecret
42
+ }
43
+ }
44
+ } catch {
45
+ // Will generate new secret
46
+ }
47
+
48
+ // Generate new secret
49
+ const secret = crypto.randomBytes(32)
50
+
51
+ // Ensure directory exists
52
+ if (!fs.existsSync(SECRET_DIR)) {
53
+ fs.mkdirSync(SECRET_DIR, { recursive: true, mode: 0o700 })
54
+ }
55
+
56
+ // Write secret with restrictive permissions
57
+ fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 })
58
+ cachedSecret = secret
59
+ return cachedSecret
60
+ }
61
+
62
+ // ============================================================================
63
+ // Encryption / Decryption
64
+ // ============================================================================
65
+
66
+ /**
67
+ * Encrypt data using AES-256-GCM.
68
+ * Returns base64url-encoded string: IV + ciphertext + authTag
69
+ */
70
+ export function encrypt(data: string): string {
71
+ const secret = getServerSecret()
72
+ const iv = crypto.randomBytes(IV_LENGTH)
73
+
74
+ const cipher = crypto.createCipheriv(ALGORITHM, secret, iv)
75
+ const encrypted = Buffer.concat([
76
+ cipher.update(data, 'utf8'),
77
+ cipher.final()
78
+ ])
79
+ const authTag = cipher.getAuthTag()
80
+
81
+ // Combine: IV (12) + encrypted + authTag (16)
82
+ const combined = Buffer.concat([iv, encrypted, authTag])
83
+ return combined.toString('base64url')
84
+ }
85
+
86
+ /**
87
+ * Decrypt data encrypted with encrypt().
88
+ * Throws on invalid/tampered data.
89
+ */
90
+ export function decrypt(encryptedData: string): string {
91
+ const secret = getServerSecret()
92
+ const combined = Buffer.from(encryptedData, 'base64url')
93
+
94
+ if (combined.length < IV_LENGTH + TAG_LENGTH) {
95
+ throw new Error('Invalid encrypted data: too short')
96
+ }
97
+
98
+ const iv = combined.subarray(0, IV_LENGTH)
99
+ const authTag = combined.subarray(combined.length - TAG_LENGTH)
100
+ const encrypted = combined.subarray(IV_LENGTH, combined.length - TAG_LENGTH)
101
+
102
+ const decipher = crypto.createDecipheriv(ALGORITHM, secret, iv)
103
+ decipher.setAuthTag(authTag)
104
+
105
+ try {
106
+ const decrypted = Buffer.concat([
107
+ decipher.update(encrypted),
108
+ decipher.final()
109
+ ])
110
+ return decrypted.toString('utf8')
111
+ } catch {
112
+ throw new Error('Decryption failed: invalid or tampered data')
113
+ }
114
+ }
115
+
116
+ // ============================================================================
117
+ // JWT-like Token Operations
118
+ // ============================================================================
119
+
120
+ export interface SubBridgeTokenPayload {
121
+ iss: 'sub-bridge'
122
+ sub: string // User identifier
123
+ aud: string // Client ID
124
+ exp: number // Expiration timestamp
125
+ iat: number // Issued at timestamp
126
+ providers: {
127
+ claude?: {
128
+ access_token: string
129
+ refresh_token?: string
130
+ expires_at?: number
131
+ }
132
+ chatgpt?: {
133
+ access_token: string
134
+ account_id: string
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Create an encrypted access token containing upstream credentials.
141
+ * Format: sb1.<encrypted_payload>
142
+ *
143
+ * The "sb1" prefix identifies this as a Sub Bridge v1 token.
144
+ */
145
+ export function createAccessToken(payload: SubBridgeTokenPayload): string {
146
+ const json = JSON.stringify(payload)
147
+ const encrypted = encrypt(json)
148
+ return `sb1.${encrypted}`
149
+ }
150
+
151
+ /**
152
+ * Decode and validate an access token.
153
+ * Returns null if token is invalid or expired.
154
+ */
155
+ export function decodeAccessToken(token: string): SubBridgeTokenPayload | null {
156
+ if (!token.startsWith('sb1.')) {
157
+ return null
158
+ }
159
+
160
+ try {
161
+ const encrypted = token.slice(4) // Remove "sb1." prefix
162
+ const json = decrypt(encrypted)
163
+ const payload = JSON.parse(json) as SubBridgeTokenPayload
164
+
165
+ // Validate required fields
166
+ if (payload.iss !== 'sub-bridge') return null
167
+ if (!payload.sub || !payload.aud) return null
168
+ if (!payload.exp || !payload.iat) return null
169
+
170
+ // Check expiration
171
+ if (Date.now() > payload.exp * 1000) return null
172
+
173
+ return payload
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Generate a random authorization code.
181
+ */
182
+ export function generateAuthCode(): string {
183
+ return crypto.randomBytes(32).toString('base64url')
184
+ }
185
+
186
+ /**
187
+ * Generate a random client ID for DCR.
188
+ */
189
+ export function generateClientId(): string {
190
+ return `sb_${crypto.randomBytes(16).toString('hex')}`
191
+ }
192
+
193
+ /**
194
+ * Generate a random client secret for DCR.
195
+ */
196
+ export function generateClientSecret(): string {
197
+ return crypto.randomBytes(32).toString('base64url')
198
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Dynamic Client Registration (RFC 7591)
3
+ *
4
+ * Allows MCP clients to register themselves without manual setup.
5
+ * Client registrations are stored in-memory (local-only deployment).
6
+ */
7
+ import { generateClientId, generateClientSecret } from './crypto'
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ export interface ClientRegistration {
14
+ client_id: string
15
+ client_secret?: string
16
+ client_name?: string
17
+ redirect_uris: string[]
18
+ grant_types: string[]
19
+ response_types: string[]
20
+ token_endpoint_auth_method: string
21
+ created_at: number
22
+ }
23
+
24
+ export interface RegistrationRequest {
25
+ client_name?: string
26
+ redirect_uris?: string[]
27
+ grant_types?: string[]
28
+ response_types?: string[]
29
+ token_endpoint_auth_method?: string
30
+ }
31
+
32
+ export interface RegistrationResponse {
33
+ client_id: string
34
+ client_secret?: string
35
+ client_name?: string
36
+ redirect_uris: string[]
37
+ grant_types: string[]
38
+ response_types: string[]
39
+ token_endpoint_auth_method: string
40
+ client_id_issued_at: number
41
+ }
42
+
43
+ // ============================================================================
44
+ // Client Storage (In-Memory)
45
+ // ============================================================================
46
+
47
+ const clients = new Map<string, ClientRegistration>()
48
+
49
+ /**
50
+ * Register a new OAuth client.
51
+ */
52
+ export function registerClient(request: RegistrationRequest): RegistrationResponse {
53
+ const clientId = generateClientId()
54
+ const clientSecret = request.token_endpoint_auth_method === 'none'
55
+ ? undefined
56
+ : generateClientSecret()
57
+
58
+ const now = Math.floor(Date.now() / 1000)
59
+
60
+ const registration: ClientRegistration = {
61
+ client_id: clientId,
62
+ client_secret: clientSecret,
63
+ client_name: request.client_name,
64
+ redirect_uris: request.redirect_uris || [],
65
+ grant_types: request.grant_types || ['authorization_code'],
66
+ response_types: request.response_types || ['code'],
67
+ token_endpoint_auth_method: request.token_endpoint_auth_method || 'client_secret_post',
68
+ created_at: now,
69
+ }
70
+
71
+ clients.set(clientId, registration)
72
+
73
+ return {
74
+ client_id: clientId,
75
+ client_secret: clientSecret,
76
+ client_name: registration.client_name,
77
+ redirect_uris: registration.redirect_uris,
78
+ grant_types: registration.grant_types,
79
+ response_types: registration.response_types,
80
+ token_endpoint_auth_method: registration.token_endpoint_auth_method,
81
+ client_id_issued_at: now,
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Get a registered client by ID.
87
+ */
88
+ export function getClient(clientId: string): ClientRegistration | undefined {
89
+ return clients.get(clientId)
90
+ }
91
+
92
+ /**
93
+ * Validate client credentials for token endpoint.
94
+ */
95
+ export function validateClient(
96
+ clientId: string,
97
+ clientSecret?: string
98
+ ): ClientRegistration | null {
99
+ const client = clients.get(clientId)
100
+ if (!client) return null
101
+
102
+ // For public clients (no secret required)
103
+ if (client.token_endpoint_auth_method === 'none') {
104
+ return client
105
+ }
106
+
107
+ // For confidential clients, verify secret
108
+ if (client.client_secret && client.client_secret === clientSecret) {
109
+ return client
110
+ }
111
+
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * Validate redirect URI against registered URIs.
117
+ */
118
+ export function validateRedirectUri(
119
+ clientId: string,
120
+ redirectUri: string
121
+ ): boolean {
122
+ const client = clients.get(clientId)
123
+ if (!client) return false
124
+
125
+ // If no redirect URIs registered, allow any (for development)
126
+ if (client.redirect_uris.length === 0) return true
127
+
128
+ return client.redirect_uris.includes(redirectUri)
129
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * OAuth 2.0 Authorization Server Metadata (RFC 8414)
3
+ *
4
+ * Provides the /.well-known/oauth-authorization-server endpoint
5
+ * that MCP clients use to discover OAuth endpoints.
6
+ */
7
+
8
+ export interface OAuthMetadata {
9
+ issuer: string
10
+ authorization_endpoint: string
11
+ token_endpoint: string
12
+ registration_endpoint?: string
13
+ scopes_supported: string[]
14
+ response_types_supported: string[]
15
+ grant_types_supported: string[]
16
+ token_endpoint_auth_methods_supported: string[]
17
+ code_challenge_methods_supported: string[]
18
+ service_documentation?: string
19
+ }
20
+
21
+ /**
22
+ * Generate OAuth metadata for the given issuer URL.
23
+ */
24
+ export function getOAuthMetadata(issuerUrl: string): OAuthMetadata {
25
+ // Ensure no trailing slash
26
+ const baseUrl = issuerUrl.replace(/\/$/, '')
27
+
28
+ return {
29
+ issuer: baseUrl,
30
+ authorization_endpoint: `${baseUrl}/oauth/authorize`,
31
+ token_endpoint: `${baseUrl}/oauth/token`,
32
+ registration_endpoint: `${baseUrl}/oauth/register`,
33
+ scopes_supported: ['openid', 'profile', 'offline_access'],
34
+ response_types_supported: ['code'],
35
+ grant_types_supported: ['authorization_code', 'refresh_token'],
36
+ token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
37
+ code_challenge_methods_supported: ['S256', 'plain'],
38
+ service_documentation: 'https://github.com/anthropics/sub-bridge',
39
+ }
40
+ }