@qlover/oauth-wrapper 0.5.0 → 0.6.3
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/CHANGELOG.md +7 -0
- package/dist/core.cjs +1 -1
- package/dist/core.d.ts +65 -24
- 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 +3 -3
package/dist/index.cjs
CHANGED
|
@@ -20,7 +20,6 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/server/index.ts
|
|
21
21
|
var server_exports = {};
|
|
22
22
|
__export(server_exports, {
|
|
23
|
-
OAuthAbstractProvider: () => OAuthAbstractProvider,
|
|
24
23
|
OAuthAuthorizationCodeRowSchema: () => OAuthAuthorizationCodeRowSchema,
|
|
25
24
|
OAuthAuthorizeQuerySchema: () => OAuthAuthorizeQuerySchema,
|
|
26
25
|
OAuthClientCreateResponseSchema: () => OAuthClientCreateResponseSchema,
|
|
@@ -66,6 +65,120 @@ __export(server_exports, {
|
|
|
66
65
|
});
|
|
67
66
|
module.exports = __toCommonJS(server_exports);
|
|
68
67
|
|
|
68
|
+
// src/server/services/OAuthClientsService.ts
|
|
69
|
+
var OAuthClientsService = class {
|
|
70
|
+
constructor(clientsRepo) {
|
|
71
|
+
this.clientsRepo = clientsRepo;
|
|
72
|
+
}
|
|
73
|
+
clientsRepo;
|
|
74
|
+
/**
|
|
75
|
+
* List all OAuth clients owned by a user
|
|
76
|
+
* @override
|
|
77
|
+
*/
|
|
78
|
+
async listForOwner(ownerUserId) {
|
|
79
|
+
return this.clientsRepo.listClientByOwner(ownerUserId);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Get detailed information about a specific OAuth client
|
|
83
|
+
* @override
|
|
84
|
+
*/
|
|
85
|
+
async getByClientId(ownerUserId, clientId) {
|
|
86
|
+
const client = await this.clientsRepo.findClientById(clientId);
|
|
87
|
+
if (!client) {
|
|
88
|
+
throw new Error("Client not found");
|
|
89
|
+
}
|
|
90
|
+
if (client.owner_user_id !== ownerUserId) {
|
|
91
|
+
throw new Error("Access denied");
|
|
92
|
+
}
|
|
93
|
+
return this.mapToDetail(client);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Create a new OAuth client
|
|
97
|
+
* @override
|
|
98
|
+
*/
|
|
99
|
+
async create(ownerUserId, input) {
|
|
100
|
+
const result = await this.clientsRepo.createClient(ownerUserId, input);
|
|
101
|
+
return {
|
|
102
|
+
client_id: result.client.client_id,
|
|
103
|
+
client_secret: result.clientSecret,
|
|
104
|
+
confidential: result.client.confidential,
|
|
105
|
+
client_name: result.client.client_name,
|
|
106
|
+
client_uri: result.client.client_uri,
|
|
107
|
+
redirect_uris: result.client.redirect_uris,
|
|
108
|
+
created_at: result.client.created_at
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Update an existing OAuth client
|
|
113
|
+
* @override
|
|
114
|
+
*/
|
|
115
|
+
async update(ownerUserId, clientId, input) {
|
|
116
|
+
const existing = await this.clientsRepo.findClientById(clientId);
|
|
117
|
+
if (!existing) {
|
|
118
|
+
throw new Error("Client not found");
|
|
119
|
+
}
|
|
120
|
+
if (existing.owner_user_id !== ownerUserId) {
|
|
121
|
+
throw new Error("Access denied");
|
|
122
|
+
}
|
|
123
|
+
return this.clientsRepo.updateClient(ownerUserId, clientId, input);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Rotate the client secret
|
|
127
|
+
* @override
|
|
128
|
+
*/
|
|
129
|
+
async rotateSecret(ownerUserId, clientId) {
|
|
130
|
+
const existing = await this.clientsRepo.findClientById(clientId);
|
|
131
|
+
if (!existing) {
|
|
132
|
+
throw new Error("Client not found");
|
|
133
|
+
}
|
|
134
|
+
if (existing.owner_user_id !== ownerUserId) {
|
|
135
|
+
throw new Error("Access denied");
|
|
136
|
+
}
|
|
137
|
+
if (!existing.confidential) {
|
|
138
|
+
throw new Error("Public clients do not have a client_secret");
|
|
139
|
+
}
|
|
140
|
+
const result = await this.clientsRepo.rotateClientSecret(
|
|
141
|
+
ownerUserId,
|
|
142
|
+
clientId
|
|
143
|
+
);
|
|
144
|
+
return {
|
|
145
|
+
client_id: clientId,
|
|
146
|
+
client_secret: result.clientSecret
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Delete an OAuth client
|
|
151
|
+
* @override
|
|
152
|
+
*/
|
|
153
|
+
async delete(ownerUserId, clientId) {
|
|
154
|
+
const existing = await this.clientsRepo.findClientById(clientId);
|
|
155
|
+
if (!existing) {
|
|
156
|
+
throw new Error("Client not found");
|
|
157
|
+
}
|
|
158
|
+
if (existing.owner_user_id !== ownerUserId) {
|
|
159
|
+
throw new Error("Access denied");
|
|
160
|
+
}
|
|
161
|
+
await this.clientsRepo.deleteClient(ownerUserId, clientId);
|
|
162
|
+
}
|
|
163
|
+
mapToDetail(row) {
|
|
164
|
+
return {
|
|
165
|
+
client_id: row.client_id,
|
|
166
|
+
client_name: row.client_name,
|
|
167
|
+
client_uri: row.client_uri,
|
|
168
|
+
logo_uri: row.logo_uri,
|
|
169
|
+
redirect_uris: row.redirect_uris,
|
|
170
|
+
grant_types: row.grant_types,
|
|
171
|
+
scopes: row.scopes,
|
|
172
|
+
confidential: row.confidential,
|
|
173
|
+
created_at: row.created_at,
|
|
174
|
+
updated_at: row.updated_at
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// src/server/services/OAuthTokenService.ts
|
|
180
|
+
var import_crypto2 = require("crypto");
|
|
181
|
+
|
|
69
182
|
// src/core/schema/OAuthAuthorizeSchema.ts
|
|
70
183
|
var import_zod = require("zod");
|
|
71
184
|
function isOAuthRedirectUri(value) {
|
|
@@ -206,7 +319,7 @@ var OAuthRefreshTokenSchema = import_zod2.z.object({
|
|
|
206
319
|
});
|
|
207
320
|
var OAuthTokenResponseSchema = import_zod2.z.object({
|
|
208
321
|
access_token: import_zod2.z.string(),
|
|
209
|
-
token_type: import_zod2.z.
|
|
322
|
+
token_type: import_zod2.z.string(),
|
|
210
323
|
expires_in: import_zod2.z.number(),
|
|
211
324
|
refresh_token: import_zod2.z.string().optional(),
|
|
212
325
|
scope: import_zod2.z.string().optional()
|
|
@@ -302,6 +415,17 @@ function randomOAuthState() {
|
|
|
302
415
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
303
416
|
}
|
|
304
417
|
|
|
418
|
+
// src/server/utils/OAuthWrapperError.ts
|
|
419
|
+
var import_fe_corekit = require("@qlover/fe-corekit");
|
|
420
|
+
var OAuthWrapperError = class extends import_fe_corekit.ExecutorError {
|
|
421
|
+
constructor(id, status, cause) {
|
|
422
|
+
super(id, cause);
|
|
423
|
+
this.status = status;
|
|
424
|
+
}
|
|
425
|
+
status;
|
|
426
|
+
name = "OAuthWrapperError";
|
|
427
|
+
};
|
|
428
|
+
|
|
305
429
|
// src/server/utils/pkce.ts
|
|
306
430
|
var import_crypto = require("crypto");
|
|
307
431
|
var PKCE_VERIFIER_MIN = 43;
|
|
@@ -325,393 +449,19 @@ function computeS256CodeChallenge(verifier) {
|
|
|
325
449
|
}
|
|
326
450
|
function verifyPkceS256(verifier, challenge) {
|
|
327
451
|
if (!isValidCodeVerifier(verifier) || !isValidCodeChallenge(challenge)) {
|
|
328
|
-
return false;
|
|
329
|
-
}
|
|
330
|
-
const computed = computeS256CodeChallenge(verifier);
|
|
331
|
-
try {
|
|
332
|
-
return (0, import_crypto.timingSafeEqual)(Buffer.from(computed), Buffer.from(challenge));
|
|
333
|
-
} catch {
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// src/server/utils/authorizeUtil.ts
|
|
339
|
-
function validatePkceParams(parsed, confidential) {
|
|
340
|
-
const hasChallenge = Boolean(parsed.code_challenge?.trim());
|
|
341
|
-
const hasMethod = Boolean(parsed.code_challenge_method);
|
|
342
|
-
if (!confidential) {
|
|
343
|
-
if (!hasChallenge || parsed.code_challenge_method !== "S256") {
|
|
344
|
-
return {
|
|
345
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
346
|
-
message: "Public clients must send code_challenge and code_challenge_method=S256."
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
if (!isValidCodeChallenge(parsed.code_challenge)) {
|
|
350
|
-
return {
|
|
351
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
352
|
-
message: "Invalid code_challenge."
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
return null;
|
|
356
|
-
}
|
|
357
|
-
if (hasChallenge !== hasMethod) {
|
|
358
|
-
return {
|
|
359
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
360
|
-
message: "code_challenge and code_challenge_method must be sent together."
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
if (hasChallenge) {
|
|
364
|
-
if (parsed.code_challenge_method !== "S256") {
|
|
365
|
-
return {
|
|
366
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
367
|
-
message: "Only code_challenge_method=S256 is supported."
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
if (!isValidCodeChallenge(parsed.code_challenge)) {
|
|
371
|
-
return {
|
|
372
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
373
|
-
message: "Invalid code_challenge."
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
return null;
|
|
378
|
-
}
|
|
379
|
-
function normalizeQuery(raw) {
|
|
380
|
-
const result = {};
|
|
381
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
382
|
-
if (Array.isArray(value)) {
|
|
383
|
-
result[key] = value[0];
|
|
384
|
-
} else {
|
|
385
|
-
result[key] = value;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
if (!result.response_type) {
|
|
389
|
-
result.response_type = "code";
|
|
390
|
-
}
|
|
391
|
-
return result;
|
|
392
|
-
}
|
|
393
|
-
function isRedirectUriAllowed(redirectUri, client) {
|
|
394
|
-
return client.redirect_uris.includes(redirectUri);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// src/server/utils/oauthRedirectUtils.ts
|
|
398
|
-
function buildOAuthRedirectUrl(redirectUri, params) {
|
|
399
|
-
const url = new URL(redirectUri);
|
|
400
|
-
for (const [key, value] of Object.entries(params)) {
|
|
401
|
-
if (value != null && value !== "") {
|
|
402
|
-
url.searchParams.set(key, value);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
return url.toString();
|
|
406
|
-
}
|
|
407
|
-
function parseScopeList(scope) {
|
|
408
|
-
if (!scope?.trim()) {
|
|
409
|
-
return ["openid", "profile", "email"];
|
|
410
|
-
}
|
|
411
|
-
return scope.trim().split(/\s+/).filter(Boolean);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// src/server/services/OAuthAbstractProvider.ts
|
|
415
|
-
var import_fe_corekit = require("@qlover/fe-corekit");
|
|
416
|
-
var import_crypto2 = require("crypto");
|
|
417
|
-
var OAuthAbstractProvider = class {
|
|
418
|
-
authCodeTTLMs = 5 * 60 * 1e3;
|
|
419
|
-
isQuery(query) {
|
|
420
|
-
return OAuthAuthorizeQuerySchema.safeParse(query).success;
|
|
421
|
-
}
|
|
422
|
-
isValidateConsent(value) {
|
|
423
|
-
return OAuthConsentBodySchema.safeParse(value).success;
|
|
424
|
-
}
|
|
425
|
-
generageSessionPayload(userInfo) {
|
|
426
|
-
return {
|
|
427
|
-
userId: String(userInfo.id),
|
|
428
|
-
email: userInfo.email,
|
|
429
|
-
name: userInfo.name ?? userInfo.email,
|
|
430
|
-
providerSessionToken: userInfo.sessionToken
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
/**
|
|
434
|
-
* @override
|
|
435
|
-
*/
|
|
436
|
-
async logout(userId) {
|
|
437
|
-
const oauthRepo = this.getOAuthRepo();
|
|
438
|
-
await oauthRepo.revokeRefreshTokensByUserId(userId);
|
|
439
|
-
await oauthRepo.upsertUserCredentials(userId, {
|
|
440
|
-
provider_refresh_token: null,
|
|
441
|
-
provider_session_token: null
|
|
442
|
-
});
|
|
443
|
-
await this.clearSession();
|
|
444
|
-
}
|
|
445
|
-
/**
|
|
446
|
-
* @override
|
|
447
|
-
*/
|
|
448
|
-
async resolveAuthorizePage(rawQuery) {
|
|
449
|
-
const query = normalizeQuery(rawQuery);
|
|
450
|
-
if (!this.isQuery(query)) {
|
|
451
|
-
return {
|
|
452
|
-
ok: false,
|
|
453
|
-
error: {
|
|
454
|
-
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
455
|
-
message: "Missing or invalid authorization request parameters."
|
|
456
|
-
}
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
if (query.response_type !== "code") {
|
|
460
|
-
return {
|
|
461
|
-
ok: false,
|
|
462
|
-
error: {
|
|
463
|
-
errorKey: OAuthRfcCodes.UNSUPPORTED_RESPONSE_TYPE,
|
|
464
|
-
message: "Only response_type=code is supported."
|
|
465
|
-
}
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
const oauthRepo = this.getOAuthRepo();
|
|
469
|
-
const client = await oauthRepo.findClientById(query.client_id);
|
|
470
|
-
if (!client) {
|
|
471
|
-
return {
|
|
472
|
-
ok: false,
|
|
473
|
-
error: {
|
|
474
|
-
errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
|
|
475
|
-
message: "Unknown client_id."
|
|
476
|
-
}
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
if (!isRedirectUriAllowed(query.redirect_uri, client)) {
|
|
480
|
-
return {
|
|
481
|
-
ok: false,
|
|
482
|
-
error: {
|
|
483
|
-
errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
|
|
484
|
-
message: "redirect_uri is not registered for this client."
|
|
485
|
-
}
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
const requestedScopes = parseScopeList(query.scope);
|
|
489
|
-
const invalidScope = requestedScopes.find(
|
|
490
|
-
(scope) => !client.scopes.includes(scope)
|
|
491
|
-
);
|
|
492
|
-
if (invalidScope) {
|
|
493
|
-
return {
|
|
494
|
-
ok: false,
|
|
495
|
-
error: {
|
|
496
|
-
errorKey: OAuthRfcCodes.INVALID_SCOPE,
|
|
497
|
-
message: `Scope "${invalidScope}" is not allowed for this client.`
|
|
498
|
-
}
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
const pkceError = validatePkceParams(query, client.confidential);
|
|
502
|
-
if (pkceError) {
|
|
503
|
-
return { ok: false, error: pkceError };
|
|
504
|
-
}
|
|
505
|
-
return {
|
|
506
|
-
ok: true,
|
|
507
|
-
data: {
|
|
508
|
-
clientId: client.client_id,
|
|
509
|
-
clientName: client.client_name,
|
|
510
|
-
clientUri: client.client_uri ?? null,
|
|
511
|
-
logoUri: client.logo_uri ?? null,
|
|
512
|
-
redirectUri: query.redirect_uri,
|
|
513
|
-
scopes: requestedScopes,
|
|
514
|
-
state: query.state,
|
|
515
|
-
responseType: "code",
|
|
516
|
-
codeChallenge: query.code_challenge,
|
|
517
|
-
codeChallengeMethod: query.code_challenge_method,
|
|
518
|
-
confidential: client.confidential
|
|
519
|
-
}
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
/**
|
|
523
|
-
* @override
|
|
524
|
-
*/
|
|
525
|
-
async processConsent(requestBody) {
|
|
526
|
-
if (!this.isValidateConsent(requestBody)) {
|
|
527
|
-
throw new import_fe_corekit.ExecutorError(
|
|
528
|
-
OAuthRfcCodes.INVALID_REQUEST,
|
|
529
|
-
"Invalid consent request body"
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
const session = await this.getSession();
|
|
533
|
-
if (!session) {
|
|
534
|
-
throw new import_fe_corekit.ExecutorError(
|
|
535
|
-
OAuthRfcCodes.ACCESS_DENIED,
|
|
536
|
-
"User session expired. Please sign in again."
|
|
537
|
-
);
|
|
538
|
-
}
|
|
539
|
-
const pageResult = await this.resolveAuthorizePage({
|
|
540
|
-
response_type: "code",
|
|
541
|
-
client_id: requestBody.client_id,
|
|
542
|
-
redirect_uri: requestBody.redirect_uri,
|
|
543
|
-
scope: requestBody.scope,
|
|
544
|
-
state: requestBody.state,
|
|
545
|
-
code_challenge: requestBody.code_challenge,
|
|
546
|
-
code_challenge_method: requestBody.code_challenge_method
|
|
547
|
-
});
|
|
548
|
-
if (!pageResult.ok) {
|
|
549
|
-
throw new import_fe_corekit.ExecutorError(
|
|
550
|
-
pageResult.error.errorKey,
|
|
551
|
-
pageResult.error.message
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
const { data } = pageResult;
|
|
555
|
-
if (requestBody.action === "deny") {
|
|
556
|
-
return {
|
|
557
|
-
redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
|
|
558
|
-
error: OAuthRfcCodes.ACCESS_DENIED,
|
|
559
|
-
error_description: "The resource owner denied the request",
|
|
560
|
-
state: data.state
|
|
561
|
-
})
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
const code = (0, import_crypto2.randomBytes)(32).toString("base64url");
|
|
565
|
-
const expiresAt = new Date(Date.now() + this.authCodeTTLMs).toISOString();
|
|
566
|
-
const oauthRepo = this.getOAuthRepo();
|
|
567
|
-
await oauthRepo.create({
|
|
568
|
-
code,
|
|
569
|
-
client_id: data.clientId,
|
|
570
|
-
user_id: session.userId,
|
|
571
|
-
redirect_uri: data.redirectUri,
|
|
572
|
-
scope: data.scopes.join(" ") || null,
|
|
573
|
-
code_challenge: data.codeChallenge ?? null,
|
|
574
|
-
code_challenge_method: data.codeChallengeMethod ?? null,
|
|
575
|
-
expires_at: expiresAt
|
|
576
|
-
});
|
|
577
|
-
void requestBody.trust;
|
|
578
|
-
return {
|
|
579
|
-
redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
|
|
580
|
-
code,
|
|
581
|
-
state: data.state
|
|
582
|
-
})
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
};
|
|
586
|
-
|
|
587
|
-
// src/server/services/OAuthClientsService.ts
|
|
588
|
-
var OAuthClientsService = class {
|
|
589
|
-
constructor(clientsRepo) {
|
|
590
|
-
this.clientsRepo = clientsRepo;
|
|
591
|
-
}
|
|
592
|
-
clientsRepo;
|
|
593
|
-
/**
|
|
594
|
-
* List all OAuth clients owned by a user
|
|
595
|
-
* @override
|
|
596
|
-
*/
|
|
597
|
-
async listForOwner(ownerUserId) {
|
|
598
|
-
return this.clientsRepo.listClientByOwner(ownerUserId);
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* Get detailed information about a specific OAuth client
|
|
602
|
-
* @override
|
|
603
|
-
*/
|
|
604
|
-
async getByClientId(ownerUserId, clientId) {
|
|
605
|
-
const client = await this.clientsRepo.findClientById(clientId);
|
|
606
|
-
if (!client) {
|
|
607
|
-
throw new Error("Client not found");
|
|
608
|
-
}
|
|
609
|
-
if (client.owner_user_id !== ownerUserId) {
|
|
610
|
-
throw new Error("Access denied");
|
|
611
|
-
}
|
|
612
|
-
return this.mapToDetail(client);
|
|
613
|
-
}
|
|
614
|
-
/**
|
|
615
|
-
* Create a new OAuth client
|
|
616
|
-
* @override
|
|
617
|
-
*/
|
|
618
|
-
async create(ownerUserId, input) {
|
|
619
|
-
const result = await this.clientsRepo.createClient(ownerUserId, input);
|
|
620
|
-
return {
|
|
621
|
-
client_id: result.client.client_id,
|
|
622
|
-
client_secret: result.clientSecret,
|
|
623
|
-
confidential: result.client.confidential,
|
|
624
|
-
client_name: result.client.client_name,
|
|
625
|
-
client_uri: result.client.client_uri,
|
|
626
|
-
redirect_uris: result.client.redirect_uris,
|
|
627
|
-
created_at: result.client.created_at
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
/**
|
|
631
|
-
* Update an existing OAuth client
|
|
632
|
-
* @override
|
|
633
|
-
*/
|
|
634
|
-
async update(ownerUserId, clientId, input) {
|
|
635
|
-
const existing = await this.clientsRepo.findClientById(clientId);
|
|
636
|
-
if (!existing) {
|
|
637
|
-
throw new Error("Client not found");
|
|
638
|
-
}
|
|
639
|
-
if (existing.owner_user_id !== ownerUserId) {
|
|
640
|
-
throw new Error("Access denied");
|
|
641
|
-
}
|
|
642
|
-
return this.clientsRepo.updateClient(ownerUserId, clientId, input);
|
|
643
|
-
}
|
|
644
|
-
/**
|
|
645
|
-
* Rotate the client secret
|
|
646
|
-
* @override
|
|
647
|
-
*/
|
|
648
|
-
async rotateSecret(ownerUserId, clientId) {
|
|
649
|
-
const existing = await this.clientsRepo.findClientById(clientId);
|
|
650
|
-
if (!existing) {
|
|
651
|
-
throw new Error("Client not found");
|
|
652
|
-
}
|
|
653
|
-
if (existing.owner_user_id !== ownerUserId) {
|
|
654
|
-
throw new Error("Access denied");
|
|
655
|
-
}
|
|
656
|
-
if (!existing.confidential) {
|
|
657
|
-
throw new Error("Public clients do not have a client_secret");
|
|
658
|
-
}
|
|
659
|
-
const result = await this.clientsRepo.rotateClientSecret(
|
|
660
|
-
ownerUserId,
|
|
661
|
-
clientId
|
|
662
|
-
);
|
|
663
|
-
return {
|
|
664
|
-
client_id: clientId,
|
|
665
|
-
client_secret: result.clientSecret
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
/**
|
|
669
|
-
* Delete an OAuth client
|
|
670
|
-
* @override
|
|
671
|
-
*/
|
|
672
|
-
async delete(ownerUserId, clientId) {
|
|
673
|
-
const existing = await this.clientsRepo.findClientById(clientId);
|
|
674
|
-
if (!existing) {
|
|
675
|
-
throw new Error("Client not found");
|
|
676
|
-
}
|
|
677
|
-
if (existing.owner_user_id !== ownerUserId) {
|
|
678
|
-
throw new Error("Access denied");
|
|
679
|
-
}
|
|
680
|
-
await this.clientsRepo.deleteClient(ownerUserId, clientId);
|
|
681
|
-
}
|
|
682
|
-
mapToDetail(row) {
|
|
683
|
-
return {
|
|
684
|
-
client_id: row.client_id,
|
|
685
|
-
client_name: row.client_name,
|
|
686
|
-
client_uri: row.client_uri,
|
|
687
|
-
logo_uri: row.logo_uri,
|
|
688
|
-
redirect_uris: row.redirect_uris,
|
|
689
|
-
grant_types: row.grant_types,
|
|
690
|
-
scopes: row.scopes,
|
|
691
|
-
confidential: row.confidential,
|
|
692
|
-
created_at: row.created_at,
|
|
693
|
-
updated_at: row.updated_at
|
|
694
|
-
};
|
|
452
|
+
return false;
|
|
695
453
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
// src/server/utils/OAuthWrapperError.ts
|
|
702
|
-
var import_fe_corekit2 = require("@qlover/fe-corekit");
|
|
703
|
-
var OAuthWrapperError = class extends import_fe_corekit2.ExecutorError {
|
|
704
|
-
constructor(id, status, cause) {
|
|
705
|
-
super(id, cause);
|
|
706
|
-
this.status = status;
|
|
454
|
+
const computed = computeS256CodeChallenge(verifier);
|
|
455
|
+
try {
|
|
456
|
+
return (0, import_crypto.timingSafeEqual)(Buffer.from(computed), Buffer.from(challenge));
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
707
459
|
}
|
|
708
|
-
|
|
709
|
-
name = "OAuthWrapperError";
|
|
710
|
-
};
|
|
460
|
+
}
|
|
711
461
|
|
|
712
462
|
// src/server/services/OAuthTokenService.ts
|
|
713
463
|
function hashOpaqueToken(token) {
|
|
714
|
-
return (0,
|
|
464
|
+
return (0, import_crypto2.createHash)("sha256").update(token).digest("hex");
|
|
715
465
|
}
|
|
716
466
|
var OAuthTokenService = class _OAuthTokenService {
|
|
717
467
|
constructor(tokenEncryption, exchangeProviderAccessToken, oauthRepo) {
|
|
@@ -863,7 +613,9 @@ var OAuthTokenService = class _OAuthTokenService {
|
|
|
863
613
|
}
|
|
864
614
|
try {
|
|
865
615
|
const access = await this.exchangeProviderAccessToken({
|
|
866
|
-
|
|
616
|
+
providerRefreshToken: sessionToken,
|
|
617
|
+
// TODO:
|
|
618
|
+
userId: ""
|
|
867
619
|
});
|
|
868
620
|
if (access.refresh_token) {
|
|
869
621
|
await this.oauthRepo.upsertUserCredentials(userId, {
|
|
@@ -873,6 +625,7 @@ var OAuthTokenService = class _OAuthTokenService {
|
|
|
873
625
|
});
|
|
874
626
|
}
|
|
875
627
|
return {
|
|
628
|
+
...access,
|
|
876
629
|
access_token: access.access_token,
|
|
877
630
|
expires_in: access.expires_in
|
|
878
631
|
};
|
|
@@ -885,7 +638,7 @@ var OAuthTokenService = class _OAuthTokenService {
|
|
|
885
638
|
}
|
|
886
639
|
}
|
|
887
640
|
async issueMiddlewareRefreshToken(clientId, userId) {
|
|
888
|
-
const plain = (0,
|
|
641
|
+
const plain = (0, import_crypto2.randomBytes)(32).toString("base64url");
|
|
889
642
|
const expiresAt = new Date(
|
|
890
643
|
Date.now() + _OAuthTokenService.REFRESH_TOKEN_TTL_MS
|
|
891
644
|
).toISOString();
|
|
@@ -933,9 +686,88 @@ var OAuthTokenService = class _OAuthTokenService {
|
|
|
933
686
|
};
|
|
934
687
|
|
|
935
688
|
// src/server/services/OAuthWrapperService.ts
|
|
936
|
-
var
|
|
689
|
+
var import_fe_corekit2 = require("@qlover/fe-corekit");
|
|
690
|
+
|
|
691
|
+
// src/server/utils/authorizeUtil.ts
|
|
692
|
+
function validatePkceParams(parsed, confidential) {
|
|
693
|
+
const hasChallenge = Boolean(parsed.code_challenge?.trim());
|
|
694
|
+
const hasMethod = Boolean(parsed.code_challenge_method);
|
|
695
|
+
if (!confidential) {
|
|
696
|
+
if (!hasChallenge || parsed.code_challenge_method !== "S256") {
|
|
697
|
+
return {
|
|
698
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
699
|
+
message: "Public clients must send code_challenge and code_challenge_method=S256."
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
if (!isValidCodeChallenge(parsed.code_challenge)) {
|
|
703
|
+
return {
|
|
704
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
705
|
+
message: "Invalid code_challenge."
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
if (hasChallenge !== hasMethod) {
|
|
711
|
+
return {
|
|
712
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
713
|
+
message: "code_challenge and code_challenge_method must be sent together."
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (hasChallenge) {
|
|
717
|
+
if (parsed.code_challenge_method !== "S256") {
|
|
718
|
+
return {
|
|
719
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
720
|
+
message: "Only code_challenge_method=S256 is supported."
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
if (!isValidCodeChallenge(parsed.code_challenge)) {
|
|
724
|
+
return {
|
|
725
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
726
|
+
message: "Invalid code_challenge."
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
function normalizeQuery(raw) {
|
|
733
|
+
const result = {};
|
|
734
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
735
|
+
if (Array.isArray(value)) {
|
|
736
|
+
result[key] = value[0];
|
|
737
|
+
} else {
|
|
738
|
+
result[key] = value;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (!result.response_type) {
|
|
742
|
+
result.response_type = "code";
|
|
743
|
+
}
|
|
744
|
+
return result;
|
|
745
|
+
}
|
|
746
|
+
function isRedirectUriAllowed(redirectUri, client) {
|
|
747
|
+
return client.redirect_uris.includes(redirectUri);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/server/utils/oauthRedirectUtils.ts
|
|
751
|
+
function buildOAuthRedirectUrl(redirectUri, params) {
|
|
752
|
+
const url = new URL(redirectUri);
|
|
753
|
+
for (const [key, value] of Object.entries(params)) {
|
|
754
|
+
if (value != null && value !== "") {
|
|
755
|
+
url.searchParams.set(key, value);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return url.toString();
|
|
759
|
+
}
|
|
760
|
+
function parseScopeList(scope) {
|
|
761
|
+
if (!scope?.trim()) {
|
|
762
|
+
return ["openid", "profile", "email"];
|
|
763
|
+
}
|
|
764
|
+
return scope.trim().split(/\s+/).filter(Boolean);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// src/server/services/OAuthWrapperService.ts
|
|
768
|
+
var import_node_crypto = require("crypto");
|
|
769
|
+
var OAuthWrapperService = class {
|
|
937
770
|
constructor(oauthSession, tokenEncryption, oauthRepo) {
|
|
938
|
-
super();
|
|
939
771
|
this.oauthSession = oauthSession;
|
|
940
772
|
this.oauthRepo = oauthRepo;
|
|
941
773
|
this.tokenService = new OAuthTokenService(
|
|
@@ -946,26 +778,29 @@ var OAuthWrapperService = class extends OAuthAbstractProvider {
|
|
|
946
778
|
}
|
|
947
779
|
oauthSession;
|
|
948
780
|
oauthRepo;
|
|
781
|
+
authCodeTTLMs = 5 * 60 * 1e3;
|
|
949
782
|
tokenService;
|
|
950
783
|
/**
|
|
951
784
|
* @override
|
|
952
785
|
*/
|
|
953
786
|
async login(params) {
|
|
954
|
-
const
|
|
955
|
-
const sessionToken =
|
|
787
|
+
const session = await this.providerLogin(params);
|
|
788
|
+
const sessionToken = session.providerRefreshToken;
|
|
956
789
|
if (!sessionToken) {
|
|
957
790
|
throw new Error("User provider login did not return a session token");
|
|
958
791
|
}
|
|
959
792
|
const userInfo = await this.providerGetUserInfo(sessionToken);
|
|
960
|
-
const sessionPayload =
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
793
|
+
const sessionPayload = {
|
|
794
|
+
...session,
|
|
795
|
+
user: {
|
|
796
|
+
...session.user,
|
|
797
|
+
...userInfo
|
|
798
|
+
}
|
|
799
|
+
};
|
|
965
800
|
this.oauthSession.setSession(sessionPayload);
|
|
966
801
|
const oauthrepo = this.getOAuthRepo();
|
|
967
802
|
await oauthrepo.upsertUserCredentials(sessionPayload.userId, {
|
|
968
|
-
provider_session_token: sessionPayload.
|
|
803
|
+
provider_session_token: sessionPayload.providerRefreshToken
|
|
969
804
|
});
|
|
970
805
|
return sessionPayload;
|
|
971
806
|
}
|
|
@@ -1013,38 +848,182 @@ var OAuthWrapperService = class extends OAuthAbstractProvider {
|
|
|
1013
848
|
async getUserInfoWithAccessToken(accessToken) {
|
|
1014
849
|
try {
|
|
1015
850
|
const profile = await this.providerGetUserInfoByAccessToken(accessToken);
|
|
1016
|
-
return this.
|
|
851
|
+
return this.toUser(profile);
|
|
1017
852
|
} catch {
|
|
1018
853
|
throw new OAuthWrapperError(OAuthRfcCodes.INVALID_TOKEN, 401);
|
|
1019
854
|
}
|
|
1020
855
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
856
|
+
toUser(profile) {
|
|
857
|
+
return profile;
|
|
858
|
+
}
|
|
859
|
+
isQuery(query) {
|
|
860
|
+
return OAuthAuthorizeQuerySchema.safeParse(query).success;
|
|
861
|
+
}
|
|
862
|
+
isValidateConsent(value) {
|
|
863
|
+
return OAuthConsentBodySchema.safeParse(value).success;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* @override
|
|
867
|
+
*/
|
|
868
|
+
async logout(userId) {
|
|
869
|
+
const oauthRepo = this.getOAuthRepo();
|
|
870
|
+
await oauthRepo.revokeRefreshTokensByUserId(userId);
|
|
871
|
+
await oauthRepo.upsertUserCredentials(userId, {
|
|
872
|
+
provider_refresh_token: null,
|
|
873
|
+
provider_session_token: null
|
|
874
|
+
});
|
|
875
|
+
await this.clearSession();
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* @override
|
|
879
|
+
*/
|
|
880
|
+
async resolveAuthorizePage(rawQuery) {
|
|
881
|
+
const query = normalizeQuery(rawQuery);
|
|
882
|
+
if (!this.isQuery(query)) {
|
|
883
|
+
return {
|
|
884
|
+
ok: false,
|
|
885
|
+
error: {
|
|
886
|
+
errorKey: OAuthRfcCodes.INVALID_REQUEST,
|
|
887
|
+
message: "Missing or invalid authorization request parameters."
|
|
888
|
+
}
|
|
889
|
+
};
|
|
1025
890
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
891
|
+
if (query.response_type !== "code") {
|
|
892
|
+
return {
|
|
893
|
+
ok: false,
|
|
894
|
+
error: {
|
|
895
|
+
errorKey: OAuthRfcCodes.UNSUPPORTED_RESPONSE_TYPE,
|
|
896
|
+
message: "Only response_type=code is supported."
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
const oauthRepo = this.getOAuthRepo();
|
|
901
|
+
const client = await oauthRepo.findClientById(query.client_id);
|
|
902
|
+
if (!client) {
|
|
903
|
+
return {
|
|
904
|
+
ok: false,
|
|
905
|
+
error: {
|
|
906
|
+
errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
|
|
907
|
+
message: "Unknown client_id."
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
if (!isRedirectUriAllowed(query.redirect_uri, client)) {
|
|
912
|
+
return {
|
|
913
|
+
ok: false,
|
|
914
|
+
error: {
|
|
915
|
+
errorKey: OAuthRfcCodes.UNAUTHORIZED_CLIENT,
|
|
916
|
+
message: "redirect_uri is not registered for this client."
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
const requestedScopes = parseScopeList(query.scope);
|
|
921
|
+
const invalidScope = requestedScopes.find(
|
|
922
|
+
(scope) => !client.scopes.includes(scope)
|
|
923
|
+
);
|
|
924
|
+
if (invalidScope) {
|
|
925
|
+
return {
|
|
926
|
+
ok: false,
|
|
927
|
+
error: {
|
|
928
|
+
errorKey: OAuthRfcCodes.INVALID_SCOPE,
|
|
929
|
+
message: `Scope "${invalidScope}" is not allowed for this client.`
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
const pkceError = validatePkceParams(query, client.confidential);
|
|
934
|
+
if (pkceError) {
|
|
935
|
+
return { ok: false, error: pkceError };
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
ok: true,
|
|
939
|
+
data: {
|
|
940
|
+
clientId: client.client_id,
|
|
941
|
+
clientName: client.client_name,
|
|
942
|
+
clientUri: client.client_uri ?? null,
|
|
943
|
+
logoUri: client.logo_uri ?? null,
|
|
944
|
+
redirectUri: query.redirect_uri,
|
|
945
|
+
scopes: requestedScopes,
|
|
946
|
+
state: query.state,
|
|
947
|
+
responseType: "code",
|
|
948
|
+
codeChallenge: query.code_challenge,
|
|
949
|
+
codeChallengeMethod: query.code_challenge_method,
|
|
950
|
+
confidential: client.confidential
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* @override
|
|
956
|
+
*/
|
|
957
|
+
async processConsent(requestBody) {
|
|
958
|
+
if (!this.isValidateConsent(requestBody)) {
|
|
959
|
+
throw new import_fe_corekit2.ExecutorError(
|
|
960
|
+
OAuthRfcCodes.INVALID_REQUEST,
|
|
961
|
+
"Invalid consent request body"
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
const session = await this.getSession();
|
|
965
|
+
if (!session) {
|
|
966
|
+
throw new import_fe_corekit2.ExecutorError(
|
|
967
|
+
OAuthRfcCodes.ACCESS_DENIED,
|
|
968
|
+
"User session expired. Please sign in again."
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
const pageResult = await this.resolveAuthorizePage({
|
|
972
|
+
response_type: "code",
|
|
973
|
+
client_id: requestBody.client_id,
|
|
974
|
+
redirect_uri: requestBody.redirect_uri,
|
|
975
|
+
scope: requestBody.scope,
|
|
976
|
+
state: requestBody.state,
|
|
977
|
+
code_challenge: requestBody.code_challenge,
|
|
978
|
+
code_challenge_method: requestBody.code_challenge_method
|
|
979
|
+
});
|
|
980
|
+
if (!pageResult.ok) {
|
|
981
|
+
throw new import_fe_corekit2.ExecutorError(
|
|
982
|
+
pageResult.error.errorKey,
|
|
983
|
+
pageResult.error.message
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
const { data } = pageResult;
|
|
987
|
+
if (requestBody.action === "deny") {
|
|
988
|
+
return {
|
|
989
|
+
redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
|
|
990
|
+
error: OAuthRfcCodes.ACCESS_DENIED,
|
|
991
|
+
error_description: "The resource owner denied the request",
|
|
992
|
+
state: data.state
|
|
993
|
+
})
|
|
994
|
+
};
|
|
1029
995
|
}
|
|
1030
|
-
const
|
|
996
|
+
const code = (0, import_node_crypto.randomBytes)(32).toString("base64url");
|
|
997
|
+
const expiresAt = new Date(Date.now() + this.authCodeTTLMs).toISOString();
|
|
998
|
+
const oauthRepo = this.getOAuthRepo();
|
|
999
|
+
await oauthRepo.create({
|
|
1000
|
+
code,
|
|
1001
|
+
client_id: data.clientId,
|
|
1002
|
+
user_id: session.userId,
|
|
1003
|
+
redirect_uri: data.redirectUri,
|
|
1004
|
+
scope: data.scopes.join(" ") || null,
|
|
1005
|
+
code_challenge: data.codeChallenge ?? null,
|
|
1006
|
+
code_challenge_method: data.codeChallengeMethod ?? null,
|
|
1007
|
+
expires_at: expiresAt
|
|
1008
|
+
});
|
|
1009
|
+
void requestBody.trust;
|
|
1031
1010
|
return {
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1011
|
+
redirectUrl: buildOAuthRedirectUrl(data.redirectUri, {
|
|
1012
|
+
code,
|
|
1013
|
+
state: data.state
|
|
1014
|
+
})
|
|
1036
1015
|
};
|
|
1037
1016
|
}
|
|
1038
1017
|
};
|
|
1039
1018
|
|
|
1040
1019
|
// src/server/utils/clientSecretHash.ts
|
|
1041
|
-
var
|
|
1020
|
+
var import_crypto3 = require("crypto");
|
|
1042
1021
|
var import_util = require("util");
|
|
1043
|
-
var scryptAsync = (0, import_util.promisify)(
|
|
1022
|
+
var scryptAsync = (0, import_util.promisify)(import_crypto3.scrypt);
|
|
1044
1023
|
var PREFIX = "scrypt";
|
|
1045
1024
|
var KEY_LEN = 64;
|
|
1046
1025
|
async function hashClientSecret(secret) {
|
|
1047
|
-
const salt = (0,
|
|
1026
|
+
const salt = (0, import_crypto3.randomBytes)(16);
|
|
1048
1027
|
const derived = await scryptAsync(secret, salt, KEY_LEN);
|
|
1049
1028
|
return `${PREFIX}$${salt.toString("base64")}$${derived.toString("base64")}`;
|
|
1050
1029
|
}
|
|
@@ -1059,11 +1038,10 @@ async function verifyClientSecret(secret, storedHash) {
|
|
|
1059
1038
|
if (derived.length !== expected.length) {
|
|
1060
1039
|
return false;
|
|
1061
1040
|
}
|
|
1062
|
-
return (0,
|
|
1041
|
+
return (0, import_crypto3.timingSafeEqual)(derived, expected);
|
|
1063
1042
|
}
|
|
1064
1043
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1065
1044
|
0 && (module.exports = {
|
|
1066
|
-
OAuthAbstractProvider,
|
|
1067
1045
|
OAuthAuthorizationCodeRowSchema,
|
|
1068
1046
|
OAuthAuthorizeQuerySchema,
|
|
1069
1047
|
OAuthClientCreateResponseSchema,
|