@silkweave/auth 1.3.1 → 1.4.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/.turbo/turbo-build.log +6 -6
- package/.turbo/turbo-check.log +3 -7
- package/.turbo/turbo-lint.log +1 -1
- package/.turbo/turbo-prepack.log +7 -7
- package/README.md +0 -2
- package/build/index.d.ts +44 -43
- package/build/index.js +253 -220
- package/build/index.js.map +1 -1
- package/package.json +4 -3
- package/src/errors.ts +0 -4
- package/src/provider/index.ts +0 -1
- package/src/provider/proxy.ts +309 -253
- package/src/provider/types.ts +7 -1
- package/src/types.ts +4 -9
- package/src/validate.ts +5 -3
package/src/provider/proxy.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'crypto'
|
|
2
2
|
import { SignJWT, jwtVerify } from 'jose'
|
|
3
3
|
import { createMemoryStore } from '../store/memory-store.js'
|
|
4
|
-
import { AuthInfo } from '../types.js'
|
|
5
4
|
import { OAuthStore } from './store.js'
|
|
6
|
-
import { OAuthProvider, OAuthResponse } from './types.js'
|
|
5
|
+
import { AuthInfo, OAuthProvider, OAuthResponse } from './types.js'
|
|
7
6
|
import { matchRedirectUri } from './uri.js'
|
|
8
7
|
|
|
9
8
|
export interface OAuthProxyConfig {
|
|
@@ -27,62 +26,315 @@ const CORS_HEADERS: Record<string, string> = {
|
|
|
27
26
|
'Access-Control-Allow-Headers': '*'
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const tokenTtl = config.tokenTtl ?? 3600
|
|
34
|
-
const scopes = config.requiredScopes ?? []
|
|
29
|
+
function generateCode(): string {
|
|
30
|
+
return randomBytes(32).toString('base64url')
|
|
31
|
+
}
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
async function generatePkce(): Promise<{ verifier: string; challenge: string }> {
|
|
34
|
+
const verifier = randomBytes(32).toString('base64url')
|
|
35
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
36
|
+
const challenge = Buffer.from(hash).toString('base64url')
|
|
37
|
+
return { verifier, challenge }
|
|
38
|
+
}
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
async function verifyPkce(verifier: string, challenge: string): Promise<boolean> {
|
|
41
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
42
|
+
const computed = Buffer.from(hash).toString('base64url')
|
|
43
|
+
return computed === challenge
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function jsonResponse(status: number, body: Record<string, unknown>, headers: Record<string, string> = {}): OAuthResponse {
|
|
47
|
+
return {
|
|
48
|
+
status,
|
|
49
|
+
headers: { 'Content-Type': 'application/json', ...CORS_HEADERS, ...headers },
|
|
50
|
+
body
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function redirectResponse(url: string): OAuthResponse {
|
|
55
|
+
return {
|
|
56
|
+
status: 302,
|
|
57
|
+
headers: { Location: url, ...CORS_HEADERS },
|
|
58
|
+
body: undefined
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function errorResponse(status: number, error: string, description: string): OAuthResponse {
|
|
63
|
+
return jsonResponse(status, { error, error_description: description })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function signAccessToken(
|
|
67
|
+
key: Uint8Array,
|
|
68
|
+
opts: { scopes: string[]; email?: string; sub?: string; clientId: string; issuer: string; ttl: number }
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
const now = Math.floor(Date.now() / 1000)
|
|
71
|
+
return new SignJWT({ scope: opts.scopes.join(' '), email: opts.email })
|
|
72
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
73
|
+
.setSubject(opts.sub ?? opts.email ?? opts.clientId)
|
|
74
|
+
.setIssuer(opts.issuer)
|
|
75
|
+
.setAudience(opts.issuer)
|
|
76
|
+
.setIssuedAt(now)
|
|
77
|
+
.setExpirationTime(now + opts.ttl)
|
|
78
|
+
.sign(key)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function handleRegister(
|
|
82
|
+
req: { body?: Record<string, string> },
|
|
83
|
+
store: OAuthStore,
|
|
84
|
+
allowedRedirectUris: string[]
|
|
85
|
+
): Promise<OAuthResponse> {
|
|
86
|
+
const body = req.body
|
|
87
|
+
if (!body) { return errorResponse(400, 'invalid_request', 'Missing request body') }
|
|
88
|
+
|
|
89
|
+
const redirectUris = body.redirect_uris
|
|
90
|
+
if (!redirectUris) { return errorResponse(400, 'invalid_request', 'redirect_uris is required') }
|
|
91
|
+
|
|
92
|
+
let uris: string[]
|
|
93
|
+
try {
|
|
94
|
+
uris = typeof redirectUris === 'string' ? JSON.parse(redirectUris) : redirectUris
|
|
95
|
+
} catch {
|
|
96
|
+
uris = [redirectUris]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!Array.isArray(uris) || uris.length === 0) {
|
|
100
|
+
return errorResponse(400, 'invalid_request', 'redirect_uris must be a non-empty array')
|
|
46
101
|
}
|
|
47
102
|
|
|
48
|
-
|
|
49
|
-
|
|
103
|
+
for (const uri of uris) {
|
|
104
|
+
if (!matchRedirectUri(uri, allowedRedirectUris)) {
|
|
105
|
+
return errorResponse(400, 'invalid_redirect_uri', `Redirect URI not allowed: ${uri}`)
|
|
106
|
+
}
|
|
50
107
|
}
|
|
51
108
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
109
|
+
const clientId = randomUUID()
|
|
110
|
+
const clientSecret = randomBytes(32).toString('base64url')
|
|
111
|
+
const registration = {
|
|
112
|
+
clientId,
|
|
113
|
+
clientSecret,
|
|
114
|
+
redirectUris: uris,
|
|
115
|
+
clientName: body.client_name,
|
|
116
|
+
createdAt: Date.now() / 1000
|
|
57
117
|
}
|
|
58
118
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
119
|
+
await store.saveClient(clientId, registration)
|
|
120
|
+
|
|
121
|
+
return jsonResponse(201, {
|
|
122
|
+
client_id: clientId,
|
|
123
|
+
client_secret: clientSecret,
|
|
124
|
+
redirect_uris: uris,
|
|
125
|
+
client_name: body.client_name,
|
|
126
|
+
token_endpoint_auth_method: 'none'
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function resolveClient(
|
|
131
|
+
clientId: string,
|
|
132
|
+
store: OAuthStore,
|
|
133
|
+
allowedRedirectUris: string[]
|
|
134
|
+
): Promise<{ clientId: string; clientSecret: string; redirectUris: string[]; clientName?: string; createdAt: number } | OAuthResponse> {
|
|
135
|
+
const existing = await store.getClient(clientId)
|
|
136
|
+
if (existing) { return existing }
|
|
137
|
+
|
|
138
|
+
if (!clientId.startsWith('https://')) {
|
|
139
|
+
return errorResponse(400, 'invalid_client', 'Unknown client_id')
|
|
63
140
|
}
|
|
64
141
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
142
|
+
try {
|
|
143
|
+
const metaRes = await fetch(clientId)
|
|
144
|
+
if (!metaRes.ok) {
|
|
145
|
+
return errorResponse(400, 'invalid_client', 'Failed to fetch client metadata document')
|
|
146
|
+
}
|
|
147
|
+
const meta = await metaRes.json() as Record<string, unknown>
|
|
148
|
+
const metaRedirectUris = meta.redirect_uris as string[] | undefined
|
|
149
|
+
if (!Array.isArray(metaRedirectUris) || metaRedirectUris.length === 0) {
|
|
150
|
+
return errorResponse(400, 'invalid_client', 'Client metadata must include redirect_uris')
|
|
151
|
+
}
|
|
152
|
+
for (const uri of metaRedirectUris) {
|
|
153
|
+
if (!matchRedirectUri(uri, allowedRedirectUris)) {
|
|
154
|
+
return errorResponse(400, 'invalid_redirect_uri', `Redirect URI not allowed: ${uri}`)
|
|
155
|
+
}
|
|
70
156
|
}
|
|
157
|
+
const client = {
|
|
158
|
+
clientId,
|
|
159
|
+
clientSecret: '',
|
|
160
|
+
redirectUris: metaRedirectUris,
|
|
161
|
+
clientName: meta.client_name as string | undefined,
|
|
162
|
+
createdAt: Date.now() / 1000
|
|
163
|
+
}
|
|
164
|
+
await store.saveClient(clientId, client)
|
|
165
|
+
return client
|
|
166
|
+
} catch {
|
|
167
|
+
return errorResponse(400, 'invalid_client', 'Failed to fetch client metadata document')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function exchangeUpstreamCode(
|
|
172
|
+
code: string,
|
|
173
|
+
config: OAuthProxyConfig,
|
|
174
|
+
callbackPath: string,
|
|
175
|
+
pkceVerifier: string
|
|
176
|
+
): Promise<{ accessToken: string; idToken?: string } | OAuthResponse> {
|
|
177
|
+
const tokenBody = new URLSearchParams({
|
|
178
|
+
grant_type: 'authorization_code',
|
|
179
|
+
code,
|
|
180
|
+
redirect_uri: `${config.resourceUrl}${callbackPath}`,
|
|
181
|
+
client_id: config.clientId,
|
|
182
|
+
client_secret: config.clientSecret,
|
|
183
|
+
code_verifier: pkceVerifier
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const tokenResponse = await fetch(config.tokenUrl, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
189
|
+
body: tokenBody.toString()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
if (!tokenResponse.ok) {
|
|
193
|
+
const errorBody = await tokenResponse.text()
|
|
194
|
+
console.error('Upstream token exchange failed:', errorBody)
|
|
195
|
+
return errorResponse(502, 'upstream_error', 'Failed to exchange code with upstream provider')
|
|
71
196
|
}
|
|
72
197
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
198
|
+
const tokens = await tokenResponse.json() as Record<string, unknown>
|
|
199
|
+
return {
|
|
200
|
+
accessToken: tokens.access_token as string,
|
|
201
|
+
idToken: tokens.id_token as string | undefined
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function fetchUserinfo(
|
|
206
|
+
userinfoUrl: string | undefined,
|
|
207
|
+
accessToken: string
|
|
208
|
+
): Promise<{ email?: string; sub?: string }> {
|
|
209
|
+
if (!userinfoUrl || !accessToken) { return {} }
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch(userinfoUrl, {
|
|
212
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
213
|
+
})
|
|
214
|
+
if (res.ok) {
|
|
215
|
+
const userinfo = await res.json() as Record<string, unknown>
|
|
216
|
+
return { email: userinfo.email as string | undefined, sub: userinfo.sub as string | undefined }
|
|
78
217
|
}
|
|
218
|
+
} catch {
|
|
219
|
+
// Userinfo fetch is best-effort
|
|
220
|
+
}
|
|
221
|
+
return {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function handleRefreshToken(
|
|
225
|
+
body: Record<string, string>,
|
|
226
|
+
store: OAuthStore,
|
|
227
|
+
getSigningKey: () => Promise<Uint8Array>,
|
|
228
|
+
config: { resourceUrl: string; tokenTtl: number }
|
|
229
|
+
): Promise<OAuthResponse> {
|
|
230
|
+
const refreshToken = body.refresh_token
|
|
231
|
+
if (!refreshToken) {
|
|
232
|
+
return errorResponse(400, 'invalid_request', 'Missing refresh_token')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const tokenData = await store.getRefreshToken(refreshToken)
|
|
236
|
+
if (!tokenData) {
|
|
237
|
+
return errorResponse(400, 'invalid_grant', 'Invalid or expired refresh token')
|
|
79
238
|
}
|
|
80
239
|
|
|
81
|
-
|
|
82
|
-
|
|
240
|
+
const key = await getSigningKey()
|
|
241
|
+
const accessToken = await signAccessToken(key, {
|
|
242
|
+
scopes: tokenData.scopes,
|
|
243
|
+
email: tokenData.email,
|
|
244
|
+
sub: tokenData.sub,
|
|
245
|
+
clientId: tokenData.clientId,
|
|
246
|
+
issuer: config.resourceUrl,
|
|
247
|
+
ttl: config.tokenTtl
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
return jsonResponse(200, {
|
|
251
|
+
access_token: accessToken,
|
|
252
|
+
token_type: 'Bearer',
|
|
253
|
+
expires_in: config.tokenTtl,
|
|
254
|
+
scope: tokenData.scopes.join(' '),
|
|
255
|
+
refresh_token: refreshToken
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function handleAuthorizationCode(
|
|
260
|
+
body: Record<string, string>,
|
|
261
|
+
store: OAuthStore,
|
|
262
|
+
getSigningKey: () => Promise<Uint8Array>,
|
|
263
|
+
config: { resourceUrl: string; tokenTtl: number }
|
|
264
|
+
): Promise<OAuthResponse> {
|
|
265
|
+
const code = body.code
|
|
266
|
+
const redirectUri = body.redirect_uri
|
|
267
|
+
const clientId = body.client_id
|
|
268
|
+
const codeVerifier = body.code_verifier
|
|
269
|
+
|
|
270
|
+
if (!code || !codeVerifier || !clientId) {
|
|
271
|
+
return errorResponse(400, 'invalid_request', 'Missing required parameters')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const authCode = await store.getAuthCode(code)
|
|
275
|
+
if (!authCode) { return errorResponse(400, 'invalid_grant', 'Invalid or expired authorization code') }
|
|
276
|
+
|
|
277
|
+
await store.deleteAuthCode(code)
|
|
278
|
+
|
|
279
|
+
if (authCode.clientId !== clientId) {
|
|
280
|
+
return errorResponse(400, 'invalid_grant', 'client_id mismatch')
|
|
281
|
+
}
|
|
282
|
+
if (redirectUri && authCode.redirectUri !== redirectUri) {
|
|
283
|
+
return errorResponse(400, 'invalid_grant', 'redirect_uri mismatch')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const pkceValid = await verifyPkce(codeVerifier, authCode.codeChallenge)
|
|
287
|
+
if (!pkceValid) {
|
|
288
|
+
return errorResponse(400, 'invalid_grant', 'PKCE verification failed')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const key = await getSigningKey()
|
|
292
|
+
const accessToken = await signAccessToken(key, {
|
|
293
|
+
scopes: authCode.scopes,
|
|
294
|
+
email: authCode.email,
|
|
295
|
+
sub: authCode.sub,
|
|
296
|
+
clientId: authCode.clientId,
|
|
297
|
+
issuer: config.resourceUrl,
|
|
298
|
+
ttl: config.tokenTtl
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const refreshToken = randomBytes(32).toString('base64url')
|
|
302
|
+
await store.saveRefreshToken(refreshToken, {
|
|
303
|
+
clientId: authCode.clientId,
|
|
304
|
+
scopes: authCode.scopes,
|
|
305
|
+
email: authCode.email,
|
|
306
|
+
sub: authCode.sub,
|
|
307
|
+
expiresAt: Math.floor(Date.now() / 1000) + 30 * 24 * 3600
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
return jsonResponse(200, {
|
|
311
|
+
access_token: accessToken,
|
|
312
|
+
token_type: 'Bearer',
|
|
313
|
+
expires_in: config.tokenTtl,
|
|
314
|
+
scope: authCode.scopes.join(' '),
|
|
315
|
+
refresh_token: refreshToken
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function createOAuthProxy(config: OAuthProxyConfig): OAuthProvider {
|
|
320
|
+
const store = config.store ?? createMemoryStore()
|
|
321
|
+
const callbackPath = config.callbackPath ?? '/auth/callback'
|
|
322
|
+
const tokenTtl = config.tokenTtl ?? 3600
|
|
323
|
+
const scopes = config.requiredScopes ?? []
|
|
324
|
+
|
|
325
|
+
let signingKey: Uint8Array | null = null
|
|
326
|
+
|
|
327
|
+
async function getSigningKey(): Promise<Uint8Array> {
|
|
328
|
+
if (signingKey) { return signingKey }
|
|
329
|
+
if (config.signingKey) {
|
|
330
|
+
signingKey = new TextEncoder().encode(config.signingKey)
|
|
331
|
+
} else {
|
|
332
|
+
signingKey = randomBytes(32)
|
|
333
|
+
}
|
|
334
|
+
return signingKey
|
|
83
335
|
}
|
|
84
336
|
|
|
85
|
-
|
|
337
|
+
return {
|
|
86
338
|
metadata(): OAuthResponse {
|
|
87
339
|
return jsonResponse(200, {
|
|
88
340
|
issuer: config.resourceUrl,
|
|
@@ -97,49 +349,8 @@ export function createOAuthProxy(config: OAuthProxyConfig): OAuthProvider {
|
|
|
97
349
|
}, { 'Cache-Control': 'max-age=3600' })
|
|
98
350
|
},
|
|
99
351
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (!body) { return errorResponse(400, 'invalid_request', 'Missing request body') }
|
|
103
|
-
|
|
104
|
-
const redirectUris = body.redirect_uris
|
|
105
|
-
if (!redirectUris) { return errorResponse(400, 'invalid_request', 'redirect_uris is required') }
|
|
106
|
-
|
|
107
|
-
let uris: string[]
|
|
108
|
-
try {
|
|
109
|
-
uris = typeof redirectUris === 'string' ? JSON.parse(redirectUris) : redirectUris
|
|
110
|
-
} catch {
|
|
111
|
-
uris = [redirectUris]
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (!Array.isArray(uris) || uris.length === 0) {
|
|
115
|
-
return errorResponse(400, 'invalid_request', 'redirect_uris must be a non-empty array')
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
for (const uri of uris) {
|
|
119
|
-
if (!matchRedirectUri(uri, config.redirectUris)) {
|
|
120
|
-
return errorResponse(400, 'invalid_redirect_uri', `Redirect URI not allowed: ${uri}`)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const clientId = randomUUID()
|
|
125
|
-
const clientSecret = randomBytes(32).toString('base64url')
|
|
126
|
-
const registration = {
|
|
127
|
-
clientId,
|
|
128
|
-
clientSecret,
|
|
129
|
-
redirectUris: uris,
|
|
130
|
-
clientName: body.client_name,
|
|
131
|
-
createdAt: Date.now() / 1000
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
await store.saveClient(clientId, registration)
|
|
135
|
-
|
|
136
|
-
return jsonResponse(201, {
|
|
137
|
-
client_id: clientId,
|
|
138
|
-
client_secret: clientSecret,
|
|
139
|
-
redirect_uris: uris,
|
|
140
|
-
client_name: body.client_name,
|
|
141
|
-
token_endpoint_auth_method: 'none'
|
|
142
|
-
})
|
|
352
|
+
register(req) {
|
|
353
|
+
return handleRegister(req, store, config.redirectUris)
|
|
143
354
|
},
|
|
144
355
|
|
|
145
356
|
async authorize(req): Promise<OAuthResponse> {
|
|
@@ -162,38 +373,9 @@ export function createOAuthProxy(config: OAuthProxyConfig): OAuthProvider {
|
|
|
162
373
|
return errorResponse(400, 'invalid_request', 'PKCE with S256 is required')
|
|
163
374
|
}
|
|
164
375
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
const metaRes = await fetch(clientId)
|
|
170
|
-
if (!metaRes.ok) {
|
|
171
|
-
return errorResponse(400, 'invalid_client', 'Failed to fetch client metadata document')
|
|
172
|
-
}
|
|
173
|
-
const meta = await metaRes.json() as Record<string, unknown>
|
|
174
|
-
const metaRedirectUris = meta.redirect_uris as string[] | undefined
|
|
175
|
-
if (!Array.isArray(metaRedirectUris) || metaRedirectUris.length === 0) {
|
|
176
|
-
return errorResponse(400, 'invalid_client', 'Client metadata must include redirect_uris')
|
|
177
|
-
}
|
|
178
|
-
for (const uri of metaRedirectUris) {
|
|
179
|
-
if (!matchRedirectUri(uri, config.redirectUris)) {
|
|
180
|
-
return errorResponse(400, 'invalid_redirect_uri', `Redirect URI not allowed: ${uri}`)
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
client = {
|
|
184
|
-
clientId,
|
|
185
|
-
clientSecret: '',
|
|
186
|
-
redirectUris: metaRedirectUris,
|
|
187
|
-
clientName: meta.client_name as string | undefined,
|
|
188
|
-
createdAt: Date.now() / 1000
|
|
189
|
-
}
|
|
190
|
-
await store.saveClient(clientId, client)
|
|
191
|
-
} catch {
|
|
192
|
-
return errorResponse(400, 'invalid_client', 'Failed to fetch client metadata document')
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (!client) { return errorResponse(400, 'invalid_client', 'Unknown client_id') }
|
|
376
|
+
const result = await resolveClient(clientId, store, config.redirectUris)
|
|
377
|
+
if ('status' in result) { return result }
|
|
378
|
+
const client = result
|
|
197
379
|
|
|
198
380
|
if (!client.redirectUris.includes(redirectUri)) {
|
|
199
381
|
return errorResponse(400, 'invalid_redirect_uri', 'redirect_uri does not match registered URIs')
|
|
@@ -253,47 +435,10 @@ export function createOAuthProxy(config: OAuthProxyConfig): OAuthProvider {
|
|
|
253
435
|
await store.deletePendingAuth(proxyState)
|
|
254
436
|
await store.deletePkceVerifier(proxyState)
|
|
255
437
|
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
code: upstreamCode,
|
|
259
|
-
redirect_uri: `${config.resourceUrl}${callbackPath}`,
|
|
260
|
-
client_id: config.clientId,
|
|
261
|
-
client_secret: config.clientSecret,
|
|
262
|
-
code_verifier: pkceVerifier
|
|
263
|
-
})
|
|
438
|
+
const upstream = await exchangeUpstreamCode(upstreamCode, config, callbackPath, pkceVerifier)
|
|
439
|
+
if ('status' in upstream) { return upstream }
|
|
264
440
|
|
|
265
|
-
const
|
|
266
|
-
method: 'POST',
|
|
267
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
268
|
-
body: tokenBody.toString()
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
if (!tokenResponse.ok) {
|
|
272
|
-
const errorBody = await tokenResponse.text()
|
|
273
|
-
console.error('Upstream token exchange failed:', errorBody)
|
|
274
|
-
return errorResponse(502, 'upstream_error', 'Failed to exchange code with upstream provider')
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const tokens = await tokenResponse.json() as Record<string, unknown>
|
|
278
|
-
const upstreamAccessToken = tokens.access_token as string
|
|
279
|
-
const upstreamIdToken = tokens.id_token as string | undefined
|
|
280
|
-
|
|
281
|
-
let email: string | undefined
|
|
282
|
-
let sub: string | undefined
|
|
283
|
-
if (config.userinfoUrl && upstreamAccessToken) {
|
|
284
|
-
try {
|
|
285
|
-
const userinfoResponse = await fetch(config.userinfoUrl, {
|
|
286
|
-
headers: { Authorization: `Bearer ${upstreamAccessToken}` }
|
|
287
|
-
})
|
|
288
|
-
if (userinfoResponse.ok) {
|
|
289
|
-
const userinfo = await userinfoResponse.json() as Record<string, unknown>
|
|
290
|
-
email = userinfo.email as string | undefined
|
|
291
|
-
sub = userinfo.sub as string | undefined
|
|
292
|
-
}
|
|
293
|
-
} catch {
|
|
294
|
-
// Userinfo fetch is best-effort
|
|
295
|
-
}
|
|
296
|
-
}
|
|
441
|
+
const userinfo = await fetchUserinfo(config.userinfoUrl, upstream.accessToken)
|
|
297
442
|
|
|
298
443
|
const mcpCode = generateCode()
|
|
299
444
|
await store.saveAuthCode(mcpCode, {
|
|
@@ -302,10 +447,10 @@ export function createOAuthProxy(config: OAuthProxyConfig): OAuthProvider {
|
|
|
302
447
|
codeChallenge: pending.codeChallenge,
|
|
303
448
|
codeChallengeMethod: pending.codeChallengeMethod,
|
|
304
449
|
scopes: pending.scope.split(' ').filter(Boolean),
|
|
305
|
-
upstreamAccessToken,
|
|
306
|
-
upstreamIdToken,
|
|
307
|
-
email,
|
|
308
|
-
sub,
|
|
450
|
+
upstreamAccessToken: upstream.accessToken,
|
|
451
|
+
upstreamIdToken: upstream.idToken,
|
|
452
|
+
email: userinfo.email,
|
|
453
|
+
sub: userinfo.sub,
|
|
309
454
|
expiresAt: Date.now() / 1000 + 600
|
|
310
455
|
})
|
|
311
456
|
|
|
@@ -322,102 +467,15 @@ export function createOAuthProxy(config: OAuthProxyConfig): OAuthProvider {
|
|
|
322
467
|
const body = req.body
|
|
323
468
|
if (!body) { return errorResponse(400, 'invalid_request', 'Missing request body') }
|
|
324
469
|
|
|
325
|
-
const
|
|
470
|
+
const tokenConfig = { resourceUrl: config.resourceUrl, tokenTtl }
|
|
326
471
|
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
if (!refreshToken) {
|
|
330
|
-
return errorResponse(400, 'invalid_request', 'Missing refresh_token')
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const tokenData = await store.getRefreshToken(refreshToken)
|
|
334
|
-
if (!tokenData) {
|
|
335
|
-
return errorResponse(400, 'invalid_grant', 'Invalid or expired refresh token')
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const key = await getSigningKey()
|
|
339
|
-
const now = Math.floor(Date.now() / 1000)
|
|
340
|
-
const accessToken = await new SignJWT({
|
|
341
|
-
scope: tokenData.scopes.join(' '),
|
|
342
|
-
email: tokenData.email
|
|
343
|
-
})
|
|
344
|
-
.setProtectedHeader({ alg: 'HS256' })
|
|
345
|
-
.setSubject(tokenData.sub ?? tokenData.email ?? tokenData.clientId)
|
|
346
|
-
.setIssuer(config.resourceUrl)
|
|
347
|
-
.setAudience(config.resourceUrl)
|
|
348
|
-
.setIssuedAt(now)
|
|
349
|
-
.setExpirationTime(now + tokenTtl)
|
|
350
|
-
.sign(key)
|
|
351
|
-
|
|
352
|
-
return jsonResponse(200, {
|
|
353
|
-
access_token: accessToken,
|
|
354
|
-
token_type: 'Bearer',
|
|
355
|
-
expires_in: tokenTtl,
|
|
356
|
-
scope: tokenData.scopes.join(' '),
|
|
357
|
-
refresh_token: refreshToken
|
|
358
|
-
})
|
|
472
|
+
if (body.grant_type === 'refresh_token') {
|
|
473
|
+
return handleRefreshToken(body, store, getSigningKey, tokenConfig)
|
|
359
474
|
}
|
|
360
|
-
|
|
361
|
-
if (grantType !== 'authorization_code') {
|
|
475
|
+
if (body.grant_type !== 'authorization_code') {
|
|
362
476
|
return errorResponse(400, 'unsupported_grant_type', 'Supported grant types: authorization_code, refresh_token')
|
|
363
477
|
}
|
|
364
|
-
|
|
365
|
-
const code = body.code
|
|
366
|
-
const redirectUri = body.redirect_uri
|
|
367
|
-
const clientId = body.client_id
|
|
368
|
-
const codeVerifier = body.code_verifier
|
|
369
|
-
|
|
370
|
-
if (!code || !codeVerifier || !clientId) {
|
|
371
|
-
return errorResponse(400, 'invalid_request', 'Missing required parameters')
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const authCode = await store.getAuthCode(code)
|
|
375
|
-
if (!authCode) { return errorResponse(400, 'invalid_grant', 'Invalid or expired authorization code') }
|
|
376
|
-
|
|
377
|
-
await store.deleteAuthCode(code)
|
|
378
|
-
|
|
379
|
-
if (authCode.clientId !== clientId) {
|
|
380
|
-
return errorResponse(400, 'invalid_grant', 'client_id mismatch')
|
|
381
|
-
}
|
|
382
|
-
if (redirectUri && authCode.redirectUri !== redirectUri) {
|
|
383
|
-
return errorResponse(400, 'invalid_grant', 'redirect_uri mismatch')
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const pkceValid = await verifyPkce(codeVerifier, authCode.codeChallenge)
|
|
387
|
-
if (!pkceValid) {
|
|
388
|
-
return errorResponse(400, 'invalid_grant', 'PKCE verification failed')
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const key = await getSigningKey()
|
|
392
|
-
const now = Math.floor(Date.now() / 1000)
|
|
393
|
-
const accessToken = await new SignJWT({
|
|
394
|
-
scope: authCode.scopes.join(' '),
|
|
395
|
-
email: authCode.email
|
|
396
|
-
})
|
|
397
|
-
.setProtectedHeader({ alg: 'HS256' })
|
|
398
|
-
.setSubject(authCode.sub ?? authCode.email ?? authCode.clientId)
|
|
399
|
-
.setIssuer(config.resourceUrl)
|
|
400
|
-
.setAudience(config.resourceUrl)
|
|
401
|
-
.setIssuedAt(now)
|
|
402
|
-
.setExpirationTime(now + tokenTtl)
|
|
403
|
-
.sign(key)
|
|
404
|
-
|
|
405
|
-
const refreshToken = randomBytes(32).toString('base64url')
|
|
406
|
-
await store.saveRefreshToken(refreshToken, {
|
|
407
|
-
clientId: authCode.clientId,
|
|
408
|
-
scopes: authCode.scopes,
|
|
409
|
-
email: authCode.email,
|
|
410
|
-
sub: authCode.sub,
|
|
411
|
-
expiresAt: now + 30 * 24 * 3600
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
return jsonResponse(200, {
|
|
415
|
-
access_token: accessToken,
|
|
416
|
-
token_type: 'Bearer',
|
|
417
|
-
expires_in: tokenTtl,
|
|
418
|
-
scope: authCode.scopes.join(' '),
|
|
419
|
-
refresh_token: refreshToken
|
|
420
|
-
})
|
|
478
|
+
return handleAuthorizationCode(body, store, getSigningKey, tokenConfig)
|
|
421
479
|
},
|
|
422
480
|
|
|
423
481
|
async verifyToken(token): Promise<AuthInfo | undefined> {
|
|
@@ -439,6 +497,4 @@ export function createOAuthProxy(config: OAuthProxyConfig): OAuthProvider {
|
|
|
439
497
|
}
|
|
440
498
|
}
|
|
441
499
|
}
|
|
442
|
-
|
|
443
|
-
return provider
|
|
444
500
|
}
|
package/src/provider/types.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { SilkweaveContext } from '@silkweave/core'
|
|
2
|
+
import type { AuthInfo, OAuthProvider } from './provider/types.js'
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
-
token: string
|
|
5
|
-
clientId?: string
|
|
6
|
-
scopes?: string[]
|
|
7
|
-
expiresAt?: number
|
|
8
|
-
[key: string]: unknown
|
|
9
|
-
}
|
|
4
|
+
export type { AuthInfo } from './provider/types.js'
|
|
10
5
|
|
|
11
|
-
export type VerifyToken = (token: string) => Promise<AuthInfo | undefined>
|
|
6
|
+
export type VerifyToken = (token: string, context: SilkweaveContext) => Promise<AuthInfo | undefined>
|
|
12
7
|
|
|
13
8
|
export interface AuthConfig {
|
|
14
9
|
verifyToken: VerifyToken
|