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.
- package/.cursor/commands/mcp-only.md +1 -0
- package/.github/workflows/npm-publish.yml +33 -0
- package/.github/workflows/pages.yml +40 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +8 -0
- package/DEVELOPMENT.md +31 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/api/index.ts +12 -0
- package/bun.lock +102 -0
- package/dist/auth/oauth-flow.d.ts +24 -0
- package/dist/auth/oauth-flow.d.ts.map +1 -0
- package/dist/auth/oauth-flow.js +184 -0
- package/dist/auth/oauth-flow.js.map +1 -0
- package/dist/auth/oauth-manager.d.ts +13 -0
- package/dist/auth/oauth-manager.d.ts.map +1 -0
- package/dist/auth/oauth-manager.js +25 -0
- package/dist/auth/oauth-manager.js.map +1 -0
- package/dist/auth/provider.d.ts +42 -0
- package/dist/auth/provider.d.ts.map +1 -0
- package/dist/auth/provider.js +270 -0
- package/dist/auth/provider.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +91 -0
- package/dist/cli.js.map +1 -0
- package/dist/mcp/proxy.d.ts +16 -0
- package/dist/mcp/proxy.d.ts.map +1 -0
- package/dist/mcp/proxy.js +85 -0
- package/dist/mcp/proxy.js.map +1 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +50 -0
- package/dist/mcp.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +149 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +6 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +808 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/tunnels.d.ts +7 -0
- package/dist/routes/tunnels.d.ts.map +1 -0
- package/dist/routes/tunnels.js +44 -0
- package/dist/routes/tunnels.js.map +1 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +157 -0
- package/dist/server.js.map +1 -0
- package/dist/tunnel/providers/cloudflare.d.ts +9 -0
- package/dist/tunnel/providers/cloudflare.d.ts.map +1 -0
- package/dist/tunnel/providers/cloudflare.js +47 -0
- package/dist/tunnel/providers/cloudflare.js.map +1 -0
- package/dist/tunnel/providers/index.d.ts +4 -0
- package/dist/tunnel/providers/index.d.ts.map +1 -0
- package/dist/tunnel/providers/index.js +13 -0
- package/dist/tunnel/providers/index.js.map +1 -0
- package/dist/tunnel/providers/ngrok.d.ts +10 -0
- package/dist/tunnel/providers/ngrok.d.ts.map +1 -0
- package/dist/tunnel/providers/ngrok.js +52 -0
- package/dist/tunnel/providers/ngrok.js.map +1 -0
- package/dist/tunnel/providers/tailscale.d.ts +10 -0
- package/dist/tunnel/providers/tailscale.d.ts.map +1 -0
- package/dist/tunnel/providers/tailscale.js +48 -0
- package/dist/tunnel/providers/tailscale.js.map +1 -0
- package/dist/tunnel/registry.d.ts +14 -0
- package/dist/tunnel/registry.d.ts.map +1 -0
- package/dist/tunnel/registry.js +86 -0
- package/dist/tunnel/registry.js.map +1 -0
- package/dist/tunnel/types.d.ts +26 -0
- package/dist/tunnel/types.d.ts.map +1 -0
- package/dist/tunnel/types.js +6 -0
- package/dist/tunnel/types.js.map +1 -0
- package/dist/tunnel/utils.d.ts +18 -0
- package/dist/tunnel/utils.d.ts.map +1 -0
- package/dist/tunnel/utils.js +57 -0
- package/dist/tunnel/utils.js.map +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/anthropic-to-openai-converter.d.ts +103 -0
- package/dist/utils/anthropic-to-openai-converter.d.ts.map +1 -0
- package/dist/utils/anthropic-to-openai-converter.js +376 -0
- package/dist/utils/anthropic-to-openai-converter.js.map +1 -0
- package/dist/utils/chat-to-responses.d.ts +59 -0
- package/dist/utils/chat-to-responses.d.ts.map +1 -0
- package/dist/utils/chat-to-responses.js +395 -0
- package/dist/utils/chat-to-responses.js.map +1 -0
- package/dist/utils/chatgpt-instructions.d.ts +3 -0
- package/dist/utils/chatgpt-instructions.d.ts.map +1 -0
- package/dist/utils/chatgpt-instructions.js +12 -0
- package/dist/utils/chatgpt-instructions.js.map +1 -0
- package/dist/utils/cli-args.d.ts +3 -0
- package/dist/utils/cli-args.d.ts.map +1 -0
- package/dist/utils/cli-args.js +10 -0
- package/dist/utils/cli-args.js.map +1 -0
- package/dist/utils/cors-bypass.d.ts +4 -0
- package/dist/utils/cors-bypass.d.ts.map +1 -0
- package/dist/utils/cors-bypass.js +30 -0
- package/dist/utils/cors-bypass.js.map +1 -0
- package/dist/utils/cursor-byok-bypass.d.ts +37 -0
- package/dist/utils/cursor-byok-bypass.d.ts.map +1 -0
- package/dist/utils/cursor-byok-bypass.js +53 -0
- package/dist/utils/cursor-byok-bypass.js.map +1 -0
- package/dist/utils/logger.d.ts +19 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +192 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/port.d.ts +27 -0
- package/dist/utils/port.d.ts.map +1 -0
- package/dist/utils/port.js +78 -0
- package/dist/utils/port.js.map +1 -0
- package/dist/utils/setup-instructions.d.ts +10 -0
- package/dist/utils/setup-instructions.d.ts.map +1 -0
- package/dist/utils/setup-instructions.js +49 -0
- package/dist/utils/setup-instructions.js.map +1 -0
- package/env.example +25 -0
- package/index.html +992 -0
- package/package.json +57 -0
- package/public/.nojekyll +0 -0
- package/public/assets/chat.png +0 -0
- package/public/assets/demo.gif +0 -0
- package/public/assets/demo.mp4 +0 -0
- package/public/assets/setup.png +0 -0
- package/public/assets/ui.png +0 -0
- package/public/index.html +292 -0
- package/release-please-config.json +10 -0
- package/src/auth/provider.ts +412 -0
- package/src/cli.ts +97 -0
- package/src/mcp/proxy.ts +64 -0
- package/src/mcp.ts +56 -0
- package/src/oauth/authorize.ts +270 -0
- package/src/oauth/crypto.ts +198 -0
- package/src/oauth/dcr.ts +129 -0
- package/src/oauth/metadata.ts +40 -0
- package/src/oauth/token.ts +173 -0
- package/src/routes/auth.ts +149 -0
- package/src/routes/chat.ts +983 -0
- package/src/routes/oauth.ts +220 -0
- package/src/routes/tunnels.ts +45 -0
- package/src/server.ts +204 -0
- package/src/tunnel/providers/cloudflare.ts +50 -0
- package/src/tunnel/providers/index.ts +7 -0
- package/src/tunnel/providers/ngrok.ts +56 -0
- package/src/tunnel/providers/tailscale.ts +50 -0
- package/src/tunnel/registry.ts +96 -0
- package/src/tunnel/types.ts +32 -0
- package/src/tunnel/utils.ts +59 -0
- package/src/types.ts +55 -0
- package/src/utils/anthropic-to-openai-converter.ts +578 -0
- package/src/utils/chat-to-responses.ts +512 -0
- package/src/utils/chatgpt-instructions.ts +7 -0
- package/src/utils/cli-args.ts +8 -0
- package/src/utils/cors-bypass.ts +39 -0
- package/src/utils/cursor-byok-bypass.ts +56 -0
- package/src/utils/logger.ts +174 -0
- package/src/utils/port.ts +99 -0
- package/src/utils/setup-instructions.ts +59 -0
- package/tsconfig.json +22 -0
- 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
|
+
}
|
package/src/oauth/dcr.ts
ADDED
|
@@ -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
|
+
}
|