@qlover/oauth-wrapper 0.5.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core.cjs +1 -1
- package/dist/core.d.ts +64 -23
- package/dist/core.js +1 -1
- package/dist/index.cjs +392 -414
- package/dist/index.d.ts +65 -92
- package/dist/index.js +389 -410
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,117 @@
|
|
|
1
|
+
// src/server/services/OAuthClientsService.ts
|
|
2
|
+
var OAuthClientsService = class {
|
|
3
|
+
constructor(clientsRepo) {
|
|
4
|
+
this.clientsRepo = clientsRepo;
|
|
5
|
+
}
|
|
6
|
+
clientsRepo;
|
|
7
|
+
/**
|
|
8
|
+
* List all OAuth clients owned by a user
|
|
9
|
+
* @override
|
|
10
|
+
*/
|
|
11
|
+
async listForOwner(ownerUserId) {
|
|
12
|
+
return this.clientsRepo.listClientByOwner(ownerUserId);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Get detailed information about a specific OAuth client
|
|
16
|
+
* @override
|
|
17
|
+
*/
|
|
18
|
+
async getByClientId(ownerUserId, clientId) {
|
|
19
|
+
const client = await this.clientsRepo.findClientById(clientId);
|
|
20
|
+
if (!client) {
|
|
21
|
+
throw new Error("Client not found");
|
|
22
|
+
}
|
|
23
|
+
if (client.owner_user_id !== ownerUserId) {
|
|
24
|
+
throw new Error("Access denied");
|
|
25
|
+
}
|
|
26
|
+
return this.mapToDetail(client);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Create a new OAuth client
|
|
30
|
+
* @override
|
|
31
|
+
*/
|
|
32
|
+
async create(ownerUserId, input) {
|
|
33
|
+
const result = await this.clientsRepo.createClient(ownerUserId, input);
|
|
34
|
+
return {
|
|
35
|
+
client_id: result.client.client_id,
|
|
36
|
+
client_secret: result.clientSecret,
|
|
37
|
+
confidential: result.client.confidential,
|
|
38
|
+
client_name: result.client.client_name,
|
|
39
|
+
client_uri: result.client.client_uri,
|
|
40
|
+
redirect_uris: result.client.redirect_uris,
|
|
41
|
+
created_at: result.client.created_at
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Update an existing OAuth client
|
|
46
|
+
* @override
|
|
47
|
+
*/
|
|
48
|
+
async update(ownerUserId, clientId, input) {
|
|
49
|
+
const existing = await this.clientsRepo.findClientById(clientId);
|
|
50
|
+
if (!existing) {
|
|
51
|
+
throw new Error("Client not found");
|
|
52
|
+
}
|
|
53
|
+
if (existing.owner_user_id !== ownerUserId) {
|
|
54
|
+
throw new Error("Access denied");
|
|
55
|
+
}
|
|
56
|
+
return this.clientsRepo.updateClient(ownerUserId, clientId, input);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Rotate the client secret
|
|
60
|
+
* @override
|
|
61
|
+
*/
|
|
62
|
+
async rotateSecret(ownerUserId, clientId) {
|
|
63
|
+
const existing = await this.clientsRepo.findClientById(clientId);
|
|
64
|
+
if (!existing) {
|
|
65
|
+
throw new Error("Client not found");
|
|
66
|
+
}
|
|
67
|
+
if (existing.owner_user_id !== ownerUserId) {
|
|
68
|
+
throw new Error("Access denied");
|
|
69
|
+
}
|
|
70
|
+
if (!existing.confidential) {
|
|
71
|
+
throw new Error("Public clients do not have a client_secret");
|
|
72
|
+
}
|
|
73
|
+
const result = await this.clientsRepo.rotateClientSecret(
|
|
74
|
+
ownerUserId,
|
|
75
|
+
clientId
|
|
76
|
+
);
|
|
77
|
+
return {
|
|
78
|
+
client_id: clientId,
|
|
79
|
+
client_secret: result.clientSecret
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Delete an OAuth client
|
|
84
|
+
* @override
|
|
85
|
+
*/
|
|
86
|
+
async delete(ownerUserId, clientId) {
|
|
87
|
+
const existing = await this.clientsRepo.findClientById(clientId);
|
|
88
|
+
if (!existing) {
|
|
89
|
+
throw new Error("Client not found");
|
|
90
|
+
}
|
|
91
|
+
if (existing.owner_user_id !== ownerUserId) {
|
|
92
|
+
throw new Error("Access denied");
|
|
93
|
+
}
|
|
94
|
+
await this.clientsRepo.deleteClient(ownerUserId, clientId);
|
|
95
|
+
}
|
|
96
|
+
mapToDetail(row) {
|
|
97
|
+
return {
|
|
98
|
+
client_id: row.client_id,
|
|
99
|
+
client_name: row.client_name,
|
|
100
|
+
client_uri: row.client_uri,
|
|
101
|
+
logo_uri: row.logo_uri,
|
|
102
|
+
redirect_uris: row.redirect_uris,
|
|
103
|
+
grant_types: row.grant_types,
|
|
104
|
+
scopes: row.scopes,
|
|
105
|
+
confidential: row.confidential,
|
|
106
|
+
created_at: row.created_at,
|
|
107
|
+
updated_at: row.updated_at
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// src/server/services/OAuthTokenService.ts
|
|
113
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
114
|
+
|
|
1
115
|
// src/core/schema/OAuthAuthorizeSchema.ts
|
|
2
116
|
import { z } from "zod";
|
|
3
117
|
function isOAuthRedirectUri(value) {
|
|
@@ -138,7 +252,7 @@ var OAuthRefreshTokenSchema = z2.object({
|
|
|
138
252
|
});
|
|
139
253
|
var OAuthTokenResponseSchema = z2.object({
|
|
140
254
|
access_token: z2.string(),
|
|
141
|
-
token_type: z2.
|
|
255
|
+
token_type: z2.string(),
|
|
142
256
|
expires_in: z2.number(),
|
|
143
257
|
refresh_token: z2.string().optional(),
|
|
144
258
|
scope: z2.string().optional()
|
|
@@ -234,6 +348,17 @@ function randomOAuthState() {
|
|
|
234
348
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
235
349
|
}
|
|
236
350
|
|
|
351
|
+
// src/server/utils/OAuthWrapperError.ts
|
|
352
|
+
import { ExecutorError } from "@qlover/fe-corekit";
|
|
353
|
+
var OAuthWrapperError = class extends ExecutorError {
|
|
354
|
+
constructor(id, status, cause) {
|
|
355
|
+
super(id, cause);
|
|
356
|
+
this.status = status;
|
|
357
|
+
}
|
|
358
|
+
status;
|
|
359
|
+
name = "OAuthWrapperError";
|
|
360
|
+
};
|
|
361
|
+
|
|
237
362
|
// src/server/utils/pkce.ts
|
|
238
363
|
import { createHash, timingSafeEqual } from "crypto";
|
|
239
364
|
var PKCE_VERIFIER_MIN = 43;
|
|
@@ -255,391 +380,17 @@ function isValidCodeChallenge(challenge) {
|
|
|
255
380
|
function computeS256CodeChallenge(verifier) {
|
|
256
381
|
return createHash("sha256").update(verifier).digest("base64url");
|
|
257
382
|
}
|
|
258
|
-
function verifyPkceS256(verifier, challenge) {
|
|
259
|
-
if (!isValidCodeVerifier(verifier) || !isValidCodeChallenge(challenge)) {
|
|
260
|
-
return false;
|
|
261
|
-
}
|
|
262
|
-
const computed = computeS256CodeChallenge(verifier);
|
|
263
|
-
try {
|
|
264
|
-
return timingSafeEqual(Buffer.from(computed), Buffer.from(challenge));
|
|
265
|
-
} catch {
|
|
266
|
-
return false;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// src/server/utils/authorizeUtil.ts
|
|
271
|
-
function validatePkceParams(parsed, confidential) {
|
|
272
|
-
const hasChallenge = Boolean(parsed.code_challenge?.trim());
|
|
273
|
-
const hasMethod = Boolean(parsed.code_challenge_method);
|
|
274
|
-
if (!confidential) {
|
|
275
|
-
if (!hasChallenge || parsed.code_challenge_method !== "S256") {
|
|
276
|
-
return {
|
|
277
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
278
|
-
message: "Public clients must send code_challenge and code_challenge_method=S256."
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
if (!isValidCodeChallenge(parsed.code_challenge)) {
|
|
282
|
-
return {
|
|
283
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
284
|
-
message: "Invalid code_challenge."
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
if (hasChallenge !== hasMethod) {
|
|
290
|
-
return {
|
|
291
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
292
|
-
message: "code_challenge and code_challenge_method must be sent together."
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
if (hasChallenge) {
|
|
296
|
-
if (parsed.code_challenge_method !== "S256") {
|
|
297
|
-
return {
|
|
298
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
299
|
-
message: "Only code_challenge_method=S256 is supported."
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
if (!isValidCodeChallenge(parsed.code_challenge)) {
|
|
303
|
-
return {
|
|
304
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
305
|
-
message: "Invalid code_challenge."
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
function normalizeQuery(raw) {
|
|
312
|
-
const result = {};
|
|
313
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
314
|
-
if (Array.isArray(value)) {
|
|
315
|
-
result[key] = value[0];
|
|
316
|
-
} else {
|
|
317
|
-
result[key] = value;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
if (!result.response_type) {
|
|
321
|
-
result.response_type = "code";
|
|
322
|
-
}
|
|
323
|
-
return result;
|
|
324
|
-
}
|
|
325
|
-
function isRedirectUriAllowed(redirectUri, client) {
|
|
326
|
-
return client.redirect_uris.includes(redirectUri);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// src/server/utils/oauthRedirectUtils.ts
|
|
330
|
-
function buildOAuthRedirectUrl(redirectUri, params) {
|
|
331
|
-
const url = new URL(redirectUri);
|
|
332
|
-
for (const [key, value] of Object.entries(params)) {
|
|
333
|
-
if (value != null && value !== "") {
|
|
334
|
-
url.searchParams.set(key, value);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return url.toString();
|
|
338
|
-
}
|
|
339
|
-
function parseScopeList(scope) {
|
|
340
|
-
if (!scope?.trim()) {
|
|
341
|
-
return ["openid", "profile", "email"];
|
|
342
|
-
}
|
|
343
|
-
return scope.trim().split(/\s+/).filter(Boolean);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// src/server/services/OAuthAbstractProvider.ts
|
|
347
|
-
import { ExecutorError } from "@qlover/fe-corekit";
|
|
348
|
-
import { randomBytes } from "crypto";
|
|
349
|
-
var OAuthAbstractProvider = class {
|
|
350
|
-
authCodeTTLMs = 5 * 60 * 1e3;
|
|
351
|
-
isQuery(query) {
|
|
352
|
-
return OAuthAuthorizeQuerySchema.safeParse(query).success;
|
|
353
|
-
}
|
|
354
|
-
isValidateConsent(value) {
|
|
355
|
-
return OAuthConsentBodySchema.safeParse(value).success;
|
|
356
|
-
}
|
|
357
|
-
generageSessionPayload(userInfo) {
|
|
358
|
-
return {
|
|
359
|
-
userId: String(userInfo.id),
|
|
360
|
-
email: userInfo.email,
|
|
361
|
-
name: userInfo.name ?? userInfo.email,
|
|
362
|
-
providerSessionToken: userInfo.sessionToken
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
/**
|
|
366
|
-
* @override
|
|
367
|
-
*/
|
|
368
|
-
async logout(userId) {
|
|
369
|
-
const oauthRepo = this.getOAuthRepo();
|
|
370
|
-
await oauthRepo.revokeRefreshTokensByUserId(userId);
|
|
371
|
-
await oauthRepo.upsertUserCredentials(userId, {
|
|
372
|
-
provider_refresh_token: null,
|
|
373
|
-
provider_session_token: null
|
|
374
|
-
});
|
|
375
|
-
await this.clearSession();
|
|
376
|
-
}
|
|
377
|
-
/**
|
|
378
|
-
* @override
|
|
379
|
-
*/
|
|
380
|
-
async resolveAuthorizePage(rawQuery) {
|
|
381
|
-
const query = normalizeQuery(rawQuery);
|
|
382
|
-
if (!this.isQuery(query)) {
|
|
383
|
-
return {
|
|
384
|
-
ok: false,
|
|
385
|
-
error: {
|
|
386
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
387
|
-
message: "Missing or invalid authorization request parameters."
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
if (query.response_type !== "code") {
|
|
392
|
-
return {
|
|
393
|
-
ok: false,
|
|
394
|
-
error: {
|
|
395
|
-
errorKey: OAuthRfcCodes.UNSUPPORTED_RESPONSE_TYPE,
|
|
396
|
-
message: "Only response_type=code is supported."
|
|
397
|
-
}
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
const oauthRepo = this.getOAuthRepo();
|
|
401
|
-
const client = await oauthRepo.findClientById(query.client_id);
|
|
402
|
-
if (!client) {
|
|
403
|
-
return {
|
|
404
|
-
ok: false,
|
|
405
|
-
error: {
|
|
406
|
-
errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
|
|
407
|
-
message: "Unknown client_id."
|
|
408
|
-
}
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
if (!isRedirectUriAllowed(query.redirect_uri, client)) {
|
|
412
|
-
return {
|
|
413
|
-
ok: false,
|
|
414
|
-
error: {
|
|
415
|
-
errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
|
|
416
|
-
message: "redirect_uri is not registered for this client."
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
const requestedScopes = parseScopeList(query.scope);
|
|
421
|
-
const invalidScope = requestedScopes.find(
|
|
422
|
-
(scope) => !client.scopes.includes(scope)
|
|
423
|
-
);
|
|
424
|
-
if (invalidScope) {
|
|
425
|
-
return {
|
|
426
|
-
ok: false,
|
|
427
|
-
error: {
|
|
428
|
-
errorKey: OAuthRfcCodes.INVALID_SCOPE,
|
|
429
|
-
message: `Scope "${invalidScope}" is not allowed for this client.`
|
|
430
|
-
}
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
const pkceError = validatePkceParams(query, client.confidential);
|
|
434
|
-
if (pkceError) {
|
|
435
|
-
return { ok: false, error: pkceError };
|
|
436
|
-
}
|
|
437
|
-
return {
|
|
438
|
-
ok: true,
|
|
439
|
-
data: {
|
|
440
|
-
clientId: client.client_id,
|
|
441
|
-
clientName: client.client_name,
|
|
442
|
-
clientUri: client.client_uri ?? null,
|
|
443
|
-
logoUri: client.logo_uri ?? null,
|
|
444
|
-
redirectUri: query.redirect_uri,
|
|
445
|
-
scopes: requestedScopes,
|
|
446
|
-
state: query.state,
|
|
447
|
-
responseType: "code",
|
|
448
|
-
codeChallenge: query.code_challenge,
|
|
449
|
-
codeChallengeMethod: query.code_challenge_method,
|
|
450
|
-
confidential: client.confidential
|
|
451
|
-
}
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* @override
|
|
456
|
-
*/
|
|
457
|
-
async processConsent(requestBody) {
|
|
458
|
-
if (!this.isValidateConsent(requestBody)) {
|
|
459
|
-
throw new ExecutorError(
|
|
460
|
-
OAuthRfcCodes.INVALID_REQUEST,
|
|
461
|
-
"Invalid consent request body"
|
|
462
|
-
);
|
|
463
|
-
}
|
|
464
|
-
const session = await this.getSession();
|
|
465
|
-
if (!session) {
|
|
466
|
-
throw new ExecutorError(
|
|
467
|
-
OAuthRfcCodes.ACCESS_DENIED,
|
|
468
|
-
"User session expired. Please sign in again."
|
|
469
|
-
);
|
|
470
|
-
}
|
|
471
|
-
const pageResult = await this.resolveAuthorizePage({
|
|
472
|
-
response_type: "code",
|
|
473
|
-
client_id: requestBody.client_id,
|
|
474
|
-
redirect_uri: requestBody.redirect_uri,
|
|
475
|
-
scope: requestBody.scope,
|
|
476
|
-
state: requestBody.state,
|
|
477
|
-
code_challenge: requestBody.code_challenge,
|
|
478
|
-
code_challenge_method: requestBody.code_challenge_method
|
|
479
|
-
});
|
|
480
|
-
if (!pageResult.ok) {
|
|
481
|
-
throw new ExecutorError(
|
|
482
|
-
pageResult.error.errorKey,
|
|
483
|
-
pageResult.error.message
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
const { data } = pageResult;
|
|
487
|
-
if (requestBody.action === "deny") {
|
|
488
|
-
return {
|
|
489
|
-
redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
|
|
490
|
-
error: OAuthRfcCodes.ACCESS_DENIED,
|
|
491
|
-
error_description: "The resource owner denied the request",
|
|
492
|
-
state: data.state
|
|
493
|
-
})
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
const code = randomBytes(32).toString("base64url");
|
|
497
|
-
const expiresAt = new Date(Date.now() + this.authCodeTTLMs).toISOString();
|
|
498
|
-
const oauthRepo = this.getOAuthRepo();
|
|
499
|
-
await oauthRepo.create({
|
|
500
|
-
code,
|
|
501
|
-
client_id: data.clientId,
|
|
502
|
-
user_id: session.userId,
|
|
503
|
-
redirect_uri: data.redirectUri,
|
|
504
|
-
scope: data.scopes.join(" ") || null,
|
|
505
|
-
code_challenge: data.codeChallenge ?? null,
|
|
506
|
-
code_challenge_method: data.codeChallengeMethod ?? null,
|
|
507
|
-
expires_at: expiresAt
|
|
508
|
-
});
|
|
509
|
-
void requestBody.trust;
|
|
510
|
-
return {
|
|
511
|
-
redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
|
|
512
|
-
code,
|
|
513
|
-
state: data.state
|
|
514
|
-
})
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
// src/server/services/OAuthClientsService.ts
|
|
520
|
-
var OAuthClientsService = class {
|
|
521
|
-
constructor(clientsRepo) {
|
|
522
|
-
this.clientsRepo = clientsRepo;
|
|
523
|
-
}
|
|
524
|
-
clientsRepo;
|
|
525
|
-
/**
|
|
526
|
-
* List all OAuth clients owned by a user
|
|
527
|
-
* @override
|
|
528
|
-
*/
|
|
529
|
-
async listForOwner(ownerUserId) {
|
|
530
|
-
return this.clientsRepo.listClientByOwner(ownerUserId);
|
|
531
|
-
}
|
|
532
|
-
/**
|
|
533
|
-
* Get detailed information about a specific OAuth client
|
|
534
|
-
* @override
|
|
535
|
-
*/
|
|
536
|
-
async getByClientId(ownerUserId, clientId) {
|
|
537
|
-
const client = await this.clientsRepo.findClientById(clientId);
|
|
538
|
-
if (!client) {
|
|
539
|
-
throw new Error("Client not found");
|
|
540
|
-
}
|
|
541
|
-
if (client.owner_user_id !== ownerUserId) {
|
|
542
|
-
throw new Error("Access denied");
|
|
543
|
-
}
|
|
544
|
-
return this.mapToDetail(client);
|
|
545
|
-
}
|
|
546
|
-
/**
|
|
547
|
-
* Create a new OAuth client
|
|
548
|
-
* @override
|
|
549
|
-
*/
|
|
550
|
-
async create(ownerUserId, input) {
|
|
551
|
-
const result = await this.clientsRepo.createClient(ownerUserId, input);
|
|
552
|
-
return {
|
|
553
|
-
client_id: result.client.client_id,
|
|
554
|
-
client_secret: result.clientSecret,
|
|
555
|
-
confidential: result.client.confidential,
|
|
556
|
-
client_name: result.client.client_name,
|
|
557
|
-
client_uri: result.client.client_uri,
|
|
558
|
-
redirect_uris: result.client.redirect_uris,
|
|
559
|
-
created_at: result.client.created_at
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
/**
|
|
563
|
-
* Update an existing OAuth client
|
|
564
|
-
* @override
|
|
565
|
-
*/
|
|
566
|
-
async update(ownerUserId, clientId, input) {
|
|
567
|
-
const existing = await this.clientsRepo.findClientById(clientId);
|
|
568
|
-
if (!existing) {
|
|
569
|
-
throw new Error("Client not found");
|
|
570
|
-
}
|
|
571
|
-
if (existing.owner_user_id !== ownerUserId) {
|
|
572
|
-
throw new Error("Access denied");
|
|
573
|
-
}
|
|
574
|
-
return this.clientsRepo.updateClient(ownerUserId, clientId, input);
|
|
575
|
-
}
|
|
576
|
-
/**
|
|
577
|
-
* Rotate the client secret
|
|
578
|
-
* @override
|
|
579
|
-
*/
|
|
580
|
-
async rotateSecret(ownerUserId, clientId) {
|
|
581
|
-
const existing = await this.clientsRepo.findClientById(clientId);
|
|
582
|
-
if (!existing) {
|
|
583
|
-
throw new Error("Client not found");
|
|
584
|
-
}
|
|
585
|
-
if (existing.owner_user_id !== ownerUserId) {
|
|
586
|
-
throw new Error("Access denied");
|
|
587
|
-
}
|
|
588
|
-
if (!existing.confidential) {
|
|
589
|
-
throw new Error("Public clients do not have a client_secret");
|
|
590
|
-
}
|
|
591
|
-
const result = await this.clientsRepo.rotateClientSecret(
|
|
592
|
-
ownerUserId,
|
|
593
|
-
clientId
|
|
594
|
-
);
|
|
595
|
-
return {
|
|
596
|
-
client_id: clientId,
|
|
597
|
-
client_secret: result.clientSecret
|
|
598
|
-
};
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* Delete an OAuth client
|
|
602
|
-
* @override
|
|
603
|
-
*/
|
|
604
|
-
async delete(ownerUserId, clientId) {
|
|
605
|
-
const existing = await this.clientsRepo.findClientById(clientId);
|
|
606
|
-
if (!existing) {
|
|
607
|
-
throw new Error("Client not found");
|
|
608
|
-
}
|
|
609
|
-
if (existing.owner_user_id !== ownerUserId) {
|
|
610
|
-
throw new Error("Access denied");
|
|
611
|
-
}
|
|
612
|
-
await this.clientsRepo.deleteClient(ownerUserId, clientId);
|
|
613
|
-
}
|
|
614
|
-
mapToDetail(row) {
|
|
615
|
-
return {
|
|
616
|
-
client_id: row.client_id,
|
|
617
|
-
client_name: row.client_name,
|
|
618
|
-
client_uri: row.client_uri,
|
|
619
|
-
logo_uri: row.logo_uri,
|
|
620
|
-
redirect_uris: row.redirect_uris,
|
|
621
|
-
grant_types: row.grant_types,
|
|
622
|
-
scopes: row.scopes,
|
|
623
|
-
confidential: row.confidential,
|
|
624
|
-
created_at: row.created_at,
|
|
625
|
-
updated_at: row.updated_at
|
|
626
|
-
};
|
|
383
|
+
function verifyPkceS256(verifier, challenge) {
|
|
384
|
+
if (!isValidCodeVerifier(verifier) || !isValidCodeChallenge(challenge)) {
|
|
385
|
+
return false;
|
|
627
386
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
// src/server/utils/OAuthWrapperError.ts
|
|
634
|
-
import { ExecutorError as ExecutorError2 } from "@qlover/fe-corekit";
|
|
635
|
-
var OAuthWrapperError = class extends ExecutorError2 {
|
|
636
|
-
constructor(id, status, cause) {
|
|
637
|
-
super(id, cause);
|
|
638
|
-
this.status = status;
|
|
387
|
+
const computed = computeS256CodeChallenge(verifier);
|
|
388
|
+
try {
|
|
389
|
+
return timingSafeEqual(Buffer.from(computed), Buffer.from(challenge));
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
639
392
|
}
|
|
640
|
-
|
|
641
|
-
name = "OAuthWrapperError";
|
|
642
|
-
};
|
|
393
|
+
}
|
|
643
394
|
|
|
644
395
|
// src/server/services/OAuthTokenService.ts
|
|
645
396
|
function hashOpaqueToken(token) {
|
|
@@ -795,7 +546,9 @@ var OAuthTokenService = class _OAuthTokenService {
|
|
|
795
546
|
}
|
|
796
547
|
try {
|
|
797
548
|
const access = await this.exchangeProviderAccessToken({
|
|
798
|
-
|
|
549
|
+
providerRefreshToken: sessionToken,
|
|
550
|
+
// TODO:
|
|
551
|
+
userId: ""
|
|
799
552
|
});
|
|
800
553
|
if (access.refresh_token) {
|
|
801
554
|
await this.oauthRepo.upsertUserCredentials(userId, {
|
|
@@ -805,6 +558,7 @@ var OAuthTokenService = class _OAuthTokenService {
|
|
|
805
558
|
});
|
|
806
559
|
}
|
|
807
560
|
return {
|
|
561
|
+
...access,
|
|
808
562
|
access_token: access.access_token,
|
|
809
563
|
expires_in: access.expires_in
|
|
810
564
|
};
|
|
@@ -817,7 +571,7 @@ var OAuthTokenService = class _OAuthTokenService {
|
|
|
817
571
|
}
|
|
818
572
|
}
|
|
819
573
|
async issueMiddlewareRefreshToken(clientId, userId) {
|
|
820
|
-
const plain =
|
|
574
|
+
const plain = randomBytes(32).toString("base64url");
|
|
821
575
|
const expiresAt = new Date(
|
|
822
576
|
Date.now() + _OAuthTokenService.REFRESH_TOKEN_TTL_MS
|
|
823
577
|
).toISOString();
|
|
@@ -865,9 +619,88 @@ var OAuthTokenService = class _OAuthTokenService {
|
|
|
865
619
|
};
|
|
866
620
|
|
|
867
621
|
// src/server/services/OAuthWrapperService.ts
|
|
868
|
-
|
|
622
|
+
import { ExecutorError as ExecutorError2 } from "@qlover/fe-corekit";
|
|
623
|
+
|
|
624
|
+
// src/server/utils/authorizeUtil.ts
|
|
625
|
+
function validatePkceParams(parsed, confidential) {
|
|
626
|
+
const hasChallenge = Boolean(parsed.code_challenge?.trim());
|
|
627
|
+
const hasMethod = Boolean(parsed.code_challenge_method);
|
|
628
|
+
if (!confidential) {
|
|
629
|
+
if (!hasChallenge || parsed.code_challenge_method !== "S256") {
|
|
630
|
+
return {
|
|
631
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
632
|
+
message: "Public clients must send code_challenge and code_challenge_method=S256."
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
if (!isValidCodeChallenge(parsed.code_challenge)) {
|
|
636
|
+
return {
|
|
637
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
638
|
+
message: "Invalid code_challenge."
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
if (hasChallenge !== hasMethod) {
|
|
644
|
+
return {
|
|
645
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
646
|
+
message: "code_challenge and code_challenge_method must be sent together."
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
if (hasChallenge) {
|
|
650
|
+
if (parsed.code_challenge_method !== "S256") {
|
|
651
|
+
return {
|
|
652
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
653
|
+
message: "Only code_challenge_method=S256 is supported."
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
if (!isValidCodeChallenge(parsed.code_challenge)) {
|
|
657
|
+
return {
|
|
658
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
659
|
+
message: "Invalid code_challenge."
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
function normalizeQuery(raw) {
|
|
666
|
+
const result = {};
|
|
667
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
668
|
+
if (Array.isArray(value)) {
|
|
669
|
+
result[key] = value[0];
|
|
670
|
+
} else {
|
|
671
|
+
result[key] = value;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!result.response_type) {
|
|
675
|
+
result.response_type = "code";
|
|
676
|
+
}
|
|
677
|
+
return result;
|
|
678
|
+
}
|
|
679
|
+
function isRedirectUriAllowed(redirectUri, client) {
|
|
680
|
+
return client.redirect_uris.includes(redirectUri);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// src/server/utils/oauthRedirectUtils.ts
|
|
684
|
+
function buildOAuthRedirectUrl(redirectUri, params) {
|
|
685
|
+
const url = new URL(redirectUri);
|
|
686
|
+
for (const [key, value] of Object.entries(params)) {
|
|
687
|
+
if (value != null && value !== "") {
|
|
688
|
+
url.searchParams.set(key, value);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return url.toString();
|
|
692
|
+
}
|
|
693
|
+
function parseScopeList(scope) {
|
|
694
|
+
if (!scope?.trim()) {
|
|
695
|
+
return ["openid", "profile", "email"];
|
|
696
|
+
}
|
|
697
|
+
return scope.trim().split(/\s+/).filter(Boolean);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// src/server/services/OAuthWrapperService.ts
|
|
701
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
702
|
+
var OAuthWrapperService = class {
|
|
869
703
|
constructor(oauthSession, tokenEncryption, oauthRepo) {
|
|
870
|
-
super();
|
|
871
704
|
this.oauthSession = oauthSession;
|
|
872
705
|
this.oauthRepo = oauthRepo;
|
|
873
706
|
this.tokenService = new OAuthTokenService(
|
|
@@ -878,26 +711,29 @@ var OAuthWrapperService = class extends OAuthAbstractProvider {
|
|
|
878
711
|
}
|
|
879
712
|
oauthSession;
|
|
880
713
|
oauthRepo;
|
|
714
|
+
authCodeTTLMs = 5 * 60 * 1e3;
|
|
881
715
|
tokenService;
|
|
882
716
|
/**
|
|
883
717
|
* @override
|
|
884
718
|
*/
|
|
885
719
|
async login(params) {
|
|
886
|
-
const
|
|
887
|
-
const sessionToken =
|
|
720
|
+
const session = await this.providerLogin(params);
|
|
721
|
+
const sessionToken = session.providerRefreshToken;
|
|
888
722
|
if (!sessionToken) {
|
|
889
723
|
throw new Error("User provider login did not return a session token");
|
|
890
724
|
}
|
|
891
725
|
const userInfo = await this.providerGetUserInfo(sessionToken);
|
|
892
|
-
const sessionPayload =
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
726
|
+
const sessionPayload = {
|
|
727
|
+
...session,
|
|
728
|
+
user: {
|
|
729
|
+
...session.user,
|
|
730
|
+
...userInfo
|
|
731
|
+
}
|
|
732
|
+
};
|
|
897
733
|
this.oauthSession.setSession(sessionPayload);
|
|
898
734
|
const oauthrepo = this.getOAuthRepo();
|
|
899
735
|
await oauthrepo.upsertUserCredentials(sessionPayload.userId, {
|
|
900
|
-
provider_session_token: sessionPayload.
|
|
736
|
+
provider_session_token: sessionPayload.providerRefreshToken
|
|
901
737
|
});
|
|
902
738
|
return sessionPayload;
|
|
903
739
|
}
|
|
@@ -945,26 +781,170 @@ var OAuthWrapperService = class extends OAuthAbstractProvider {
|
|
|
945
781
|
async getUserInfoWithAccessToken(accessToken) {
|
|
946
782
|
try {
|
|
947
783
|
const profile = await this.providerGetUserInfoByAccessToken(accessToken);
|
|
948
|
-
return this.
|
|
784
|
+
return this.toUser(profile);
|
|
949
785
|
} catch {
|
|
950
786
|
throw new OAuthWrapperError(OAuthRfcCodes.INVALID_TOKEN, 401);
|
|
951
787
|
}
|
|
952
788
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
789
|
+
toUser(profile) {
|
|
790
|
+
return profile;
|
|
791
|
+
}
|
|
792
|
+
isQuery(query) {
|
|
793
|
+
return OAuthAuthorizeQuerySchema.safeParse(query).success;
|
|
794
|
+
}
|
|
795
|
+
isValidateConsent(value) {
|
|
796
|
+
return OAuthConsentBodySchema.safeParse(value).success;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* @override
|
|
800
|
+
*/
|
|
801
|
+
async logout(userId) {
|
|
802
|
+
const oauthRepo = this.getOAuthRepo();
|
|
803
|
+
await oauthRepo.revokeRefreshTokensByUserId(userId);
|
|
804
|
+
await oauthRepo.upsertUserCredentials(userId, {
|
|
805
|
+
provider_refresh_token: null,
|
|
806
|
+
provider_session_token: null
|
|
807
|
+
});
|
|
808
|
+
await this.clearSession();
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* @override
|
|
812
|
+
*/
|
|
813
|
+
async resolveAuthorizePage(rawQuery) {
|
|
814
|
+
const query = normalizeQuery(rawQuery);
|
|
815
|
+
if (!this.isQuery(query)) {
|
|
816
|
+
return {
|
|
817
|
+
ok: false,
|
|
818
|
+
error: {
|
|
819
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
820
|
+
message: "Missing or invalid authorization request parameters."
|
|
821
|
+
}
|
|
822
|
+
};
|
|
957
823
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
824
|
+
if (query.response_type !== "code") {
|
|
825
|
+
return {
|
|
826
|
+
ok: false,
|
|
827
|
+
error: {
|
|
828
|
+
errorKey: OAuthRfcCodes.UNSUPPORTED_RESPONSE_TYPE,
|
|
829
|
+
message: "Only response_type=code is supported."
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
const oauthRepo = this.getOAuthRepo();
|
|
834
|
+
const client = await oauthRepo.findClientById(query.client_id);
|
|
835
|
+
if (!client) {
|
|
836
|
+
return {
|
|
837
|
+
ok: false,
|
|
838
|
+
error: {
|
|
839
|
+
errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
|
|
840
|
+
message: "Unknown client_id."
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
if (!isRedirectUriAllowed(query.redirect_uri, client)) {
|
|
845
|
+
return {
|
|
846
|
+
ok: false,
|
|
847
|
+
error: {
|
|
848
|
+
errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
|
|
849
|
+
message: "redirect_uri is not registered for this client."
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
const requestedScopes = parseScopeList(query.scope);
|
|
854
|
+
const invalidScope = requestedScopes.find(
|
|
855
|
+
(scope) => !client.scopes.includes(scope)
|
|
856
|
+
);
|
|
857
|
+
if (invalidScope) {
|
|
858
|
+
return {
|
|
859
|
+
ok: false,
|
|
860
|
+
error: {
|
|
861
|
+
errorKey: OAuthRfcCodes.INVALID_SCOPE,
|
|
862
|
+
message: `Scope "${invalidScope}" is not allowed for this client.`
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
const pkceError = validatePkceParams(query, client.confidential);
|
|
867
|
+
if (pkceError) {
|
|
868
|
+
return { ok: false, error: pkceError };
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
ok: true,
|
|
872
|
+
data: {
|
|
873
|
+
clientId: client.client_id,
|
|
874
|
+
clientName: client.client_name,
|
|
875
|
+
clientUri: client.client_uri ?? null,
|
|
876
|
+
logoUri: client.logo_uri ?? null,
|
|
877
|
+
redirectUri: query.redirect_uri,
|
|
878
|
+
scopes: requestedScopes,
|
|
879
|
+
state: query.state,
|
|
880
|
+
responseType: "code",
|
|
881
|
+
codeChallenge: query.code_challenge,
|
|
882
|
+
codeChallengeMethod: query.code_challenge_method,
|
|
883
|
+
confidential: client.confidential
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* @override
|
|
889
|
+
*/
|
|
890
|
+
async processConsent(requestBody) {
|
|
891
|
+
if (!this.isValidateConsent(requestBody)) {
|
|
892
|
+
throw new ExecutorError2(
|
|
893
|
+
OAuthRfcCodes.INVALID_REQUEST,
|
|
894
|
+
"Invalid consent request body"
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
const session = await this.getSession();
|
|
898
|
+
if (!session) {
|
|
899
|
+
throw new ExecutorError2(
|
|
900
|
+
OAuthRfcCodes.ACCESS_DENIED,
|
|
901
|
+
"User session expired. Please sign in again."
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
const pageResult = await this.resolveAuthorizePage({
|
|
905
|
+
response_type: "code",
|
|
906
|
+
client_id: requestBody.client_id,
|
|
907
|
+
redirect_uri: requestBody.redirect_uri,
|
|
908
|
+
scope: requestBody.scope,
|
|
909
|
+
state: requestBody.state,
|
|
910
|
+
code_challenge: requestBody.code_challenge,
|
|
911
|
+
code_challenge_method: requestBody.code_challenge_method
|
|
912
|
+
});
|
|
913
|
+
if (!pageResult.ok) {
|
|
914
|
+
throw new ExecutorError2(
|
|
915
|
+
pageResult.error.errorKey,
|
|
916
|
+
pageResult.error.message
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
const { data } = pageResult;
|
|
920
|
+
if (requestBody.action === "deny") {
|
|
921
|
+
return {
|
|
922
|
+
redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
|
|
923
|
+
error: OAuthRfcCodes.ACCESS_DENIED,
|
|
924
|
+
error_description: "The resource owner denied the request",
|
|
925
|
+
state: data.state
|
|
926
|
+
})
|
|
927
|
+
};
|
|
961
928
|
}
|
|
962
|
-
const
|
|
929
|
+
const code = randomBytes2(32).toString("base64url");
|
|
930
|
+
const expiresAt = new Date(Date.now() + this.authCodeTTLMs).toISOString();
|
|
931
|
+
const oauthRepo = this.getOAuthRepo();
|
|
932
|
+
await oauthRepo.create({
|
|
933
|
+
code,
|
|
934
|
+
client_id: data.clientId,
|
|
935
|
+
user_id: session.userId,
|
|
936
|
+
redirect_uri: data.redirectUri,
|
|
937
|
+
scope: data.scopes.join(" ") || null,
|
|
938
|
+
code_challenge: data.codeChallenge ?? null,
|
|
939
|
+
code_challenge_method: data.codeChallengeMethod ?? null,
|
|
940
|
+
expires_at: expiresAt
|
|
941
|
+
});
|
|
942
|
+
void requestBody.trust;
|
|
963
943
|
return {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
944
|
+
redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
|
|
945
|
+
code,
|
|
946
|
+
state: data.state
|
|
947
|
+
})
|
|
968
948
|
};
|
|
969
949
|
}
|
|
970
950
|
};
|
|
@@ -994,7 +974,6 @@ async function verifyClientSecret(secret, storedHash) {
|
|
|
994
974
|
return timingSafeEqual2(derived, expected);
|
|
995
975
|
}
|
|
996
976
|
export {
|
|
997
|
-
OAuthAbstractProvider,
|
|
998
977
|
OAuthAuthorizationCodeRowSchema,
|
|
999
978
|
OAuthAuthorizeQuerySchema,
|
|
1000
979
|
OAuthClientCreateResponseSchema,
|